Titel   Inhalt   Suchen   Index   DOC  Handbuch der Java-Programmierung, 7. Auflage
 <<    <     >    >>   API  Kapitel 52 - Performance-Tuning

52.2 Tuning-Tipps



52.2.1 String und StringBuilder

String-Verkettung

In Java gibt es zwei unterschiedliche Klassen String und StringBuilder zur Verarbeitung von Zeichenketten, deren prinzipielle Eigenschaften in Kapitel 12 erläutert wurden. Java-Anfänger verwenden meist vorwiegend die Klasse String, denn sie stellt die meisten Methoden zur Zeichenkettenextraktion und -verarbeitung zur Verfügung und bietet mit dem +-Operator eine bequeme Möglichkeit, Zeichenketten miteinander zu verketten.

Dass diese Bequemlichkeit ihren Preis hat, zeigt folgender Programmausschnitt:

001 String s;
002 s = "";
003 for (int i = 0; i < 20000; ++i) {
004   s += "x";
005 }
Listing 52.1: Langsame String-Verkettung

Das Programmfragment hat die Aufgabe, einen String zu erstellen, der aus 20000 aneinandergereihten »x« besteht. Das ist zwar nicht sehr praxisnah, illustriert aber die häufig vorkommende Verwendung des +=-Operators auf Strings. Der obige Code ist sehr ineffizient, denn er läuft langsam und belastet das Laufzeitsystem durch 60000 temporäre Objekte, die alloziert und vom Garbage Collector wieder freigegeben werden müssen. Der Compiler übersetzt das Programmfragment etwa so:

001 String s;
002 s = "";
003 for (int i = 0; i < 20000; ++i) {
004   s = new StringBuilder(s).append("x").toString();
005 }
Listing 52.2: Wie der Java-Compiler String-Verkettungen übersetzt

Dieser Code ist in mehrfacher Hinsicht unglücklich. Pro Schleifendurchlauf wird ein temporäres StringBuilder-Objekt alloziert und mit dem zuvor erzeugten String initialisiert. Der Konstruktor von StringBuilder erzeugt ein internes Array (also eine weitere Objektinstanz), um die Zeichenkette zu speichern. Immerhin ist dieses Array 16 Byte größer als eigentlich erforderlich, so dass der nachfolgende Aufruf von append das Array nicht neu allozieren und die Zeichen umkopieren muss. Schließlich wird durch den Aufruf von toString ein neues String-Objekt erzeugt und s zugewiesen. Auf diese Weise werden pro Schleifendurchlauf drei temporäre Objekte erzeugt und der Code ist durch das wiederholte Kopieren der Zeichen im Konstruktor von StringBuilder sehr ineffizient.

Eine deutliche Verbesserung ergibt sich, wenn die Klasse StringBuilder und ihre Methode append direkt verwendet werden:

001 String s;
002 StringBuilder sb = new StringBuilder(1000);
003 for (int i = 0; i < 20000; ++i) {
004   sb.append("x");
005 }
006 s = sb.toString();
Listing 52.3: Performante String-Verkettungen mit StringBuilder.append

Hier wird zunächst ein StringBuilder erzeugt und mit einem 1000 Zeichen großen Puffer versehen. Da die StringBuilder-Klasse sich die Länge der gespeicherten Zeichenkette merkt, kann der Aufruf append("x") meist in konstanter Laufzeit erfolgen. Dabei ist ein Umkopieren nur dann erforderlich, wenn der interne Puffer nicht mehr genügend Platz bietet, um die an append übergebenen Daten zu übernehmen. In diesem Fall wird ein größeres Array alloziert und der Inhalt des bisherigen Puffers umkopiert. Insgesamt ist die letzte Version etwa um den Faktor 10 schneller als die ersten beiden und erzeugt 60000 temporäre Objekte weniger.

Interessant ist dabei der Umfang der Puffervergrößerung, den das StringBuilder-Objekt vornimmt, denn er bestimmt, wann bei fortgesetztem Aufruf von append das nächste Mal umkopiert werden muss. Anders als beispielsweise bei der Klasse Vector, die einen veränderbaren Ladefaktor besitzt, verdoppelt (!) sich die Größe eines StringBuilder-Objekts bei jeder Kapazitätserweiterung. Dadurch wird zwar möglicherweise mehr Speicher als nötig alloziert, aber die Anzahl der Kopiervorgänge wächst höchstens logarithmisch mit der Gesamtmenge der eingefügten Daten. In unserem Beispiel kann der interne Puffer zunächst 1000 Zeichen aufnehmen, wird beim nächsten Überlauf auf etwa 2000 Zeichen vergrößert, dann auf 4000, 8000, 16000 und schließlich auf 32000 Zeichen. Hätten wir die initiale Größe auf 20000 Zeichen gesetzt, wäre sogar überhaupt kein Kopiervorgang erforderlich geworden und das Programm hätte 12000 Zeichen weniger alloziert.

Bei der Verwendung der Operatoren + und += auf String-Objekten sollte man zusätzlich bedenken, dass deren Laufzeit nicht konstant ist (bzw. ausschließlich von der Länge des anzuhängenden Strings abhängt). Tatsächlich hängt sie auch stark von der Länge des Strings ab, an den angehängt werden soll, denn die Laufzeit eines Kopiervorgangs wächst nun einmal proportional zur Länge des zu kopierenden Objekts. Damit wächst das Laufzeitverhalten der Schleife in Listing 52.1 nicht linear, sondern annähernd quadratisch. Es verschlechtert sich also mit zunehmender Länge der Schleife überproportional.

 Warnung 

Einfügen und Löschen in Strings

Ein immer noch deutlicher, wenn auch nicht ganz so drastischer Vorteil bei der Verwendung der Klasse StringBuilder ergibt sich beim Einfügen von Zeichen am vorderen Ende des Strings:

001 String s;
002 s = "";
003 for (int i = 0; i < 10000; ++i) {
004   s = "x" + s;
005 }
Listing 52.4: Langsames Einfügen in einen String

In diesem Beispiel wird wiederholt ein Zeichen vorne in den String eingefügt. Der Compiler wandelt das Programm auch hier in wiederholte Aufrufe von StringBuilder-Methoden um, wobei viele Zwischenobjekte entstehen, die unnötig oft kopiert werden müssen. Eine bessere Lösung kann man auch hier durch die direkte Verwendung eines StringBuilder-Objekts erzielen:

001 String s;
002 StringBuilder sb = new StringBuilder(1000);
003 for (int i = 0; i < 10000; ++i) {
004   sb.insert(0, "x");
005 }
006 s = sb.toString();
Listing 52.5: Schnelles Einfügen in einen String

Im Test war die Laufzeit dieser Variante etwa um den Faktor vier besser als die der ersten Version; außerdem wird nicht ein einziges temporäres Objekt erzeugt. Dadurch werden zusätzlich das Memory-Subsystem und der Garbage Collector entlastet.

In der Klasse StringBuilder (beziehungsweise in der Klasse StringBuffer) gibt es eine Methode delete, mit der ein Teil der Zeichenkette gelöscht werden kann. Dadurch können beispielsweise Programmteile der folgenden Art beschleunigt werden:

String sub1 = s.substring(0, 1000) + s.substring(2000);

Anstatt hier die ersten 1000 Zeichen mit allen Zeichen ab Position 2000 zu verbinden, kann unter Verwendung eines StringBuilder auch direkt das gewünschte Stück gelöscht werden:

String sub2 = sb.delete(1000, 2000).toString();

Die Methode toString der Klasse StringBuilder

Den vorangegangenen Abschnitten kann man entnehmen, dass die Verwendung der Klasse StringBuilder meist dann sinnvoll ist, wenn die Zeichenkette zunächst aus vielen kleinen Teilen aufgebaut werden soll oder wenn sie sich häufig ändert. Ist der String dagegen fertig konstruiert oder muss auf einen vorhandenen String lesend zugegriffen werden, geht dies im Allgemeinen mit den vielseitigeren Methoden der Klasse String besser. Um einen StringBuilder in einen String zu konvertieren, wird die Methode toString aufgerufen, die durch einen kleinen Trick sehr effizient arbeitet. Anstatt beim Aufruf von toString einen Kopiervorgang zu starten, teilen sich String- und StringBuilder-Objekt nach dem Aufruf das interne Zeichenarray, d.h., beide Objekte verwenden ein- und denselben Puffer. Normalerweise wäre diese Vorgehensweise indiskutabel, denn nach der nächsten Änderung des StringBuilder-Objekts hätte sich dann auch der Inhalt des String-Objekts verändert (was per Definition nicht erlaubt ist).

Um das zu verhindern, wird vom Konstruktor der String-Klasse während des Aufrufs von toString ein shared-Flag im StringBuilder-Objekt gesetzt. Dieses wird bei allen verändernden StringBuilder-Methoden abgefragt und führt dazu, dass - wenn es gesetzt ist - der Pufferinhalt vor der Veränderung kopiert und die Änderung auf der Kopie vorgenommen wird. Ein echter Kopiervorgang wird also so lange nicht erforderlich, wie auf den StringBuilder nicht schreibend zugegriffen wird.

Die Unveränderlichkeit von String-Objekten

Da die Klasse String keine Möglichkeit bietet, die gespeicherte Zeichenkette nach der Instanzierung des Objekts zu verändern, können einige Operationen auf Zeichenketten sehr effizient implementiert werden. So erfordert beispielsweise die einfache Zuweisung zweier String-Objekte lediglich das Kopieren eines Zeigers, ohne dass durch Aliasing die Gefahr besteht, beim Ändern eines Strings versehentlich weitere Objekte zu ändern, die auf denselben Speicherbereich zeigen.

Soll ein String physikalisch kopiert werden, kann das mit Hilfe eines speziellen Konstruktors erreicht werden:

String s2 = new String(s1);

Da der interne Puffer hierbei kopiert wird, ist der Aufruf natürlich ineffizienter als die einfache Zuweisung.

Auch die Methode substring der Klasse String konnte sehr effizient implementiert werden. Sie erzeugt zwar ein neues String-Objekt, aber den internen Zeichenpuffer teilt es sich mit dem bisherigen Objekt. Lediglich die Membervariablen, in denen die Startposition und relevante Länge des Puffers festgehalten werden, müssen im neuen Objekt angepasst werden. Dadurch ist auch das Extrahieren von langen Teilzeichenketten recht performant. Dasselbe gilt für die Methode trim, die ebenfalls substring verwendet und daher keine Zeichen kopieren muss.

Durchlaufen von Zeichenketten

Soll ein String durchlaufen werden, kann mit der Methode length seine Länge ermittelt werden und durch wiederholten Aufruf von charAt können alle Zeichen nacheinander abgeholt werden. Alternativ könnte man auch zunächst ein Zeichenarray allozieren und durch Aufruf von getChars alle Zeichen hineinkopieren. Beim späteren Durchlaufen wäre dann kein Methodenaufruf mehr erforderlich, sondern die einzelnen Array-Elemente könnten direkt verwendet werden. Die Laufzeitunterschiede zwischen beiden Varianten sind allerdings minimal und werden in der Praxis kaum ins Gewicht fallen (da die Klasse String als final deklariert wurde und die Methode charAt nicht synchronized ist, kann sie sehr performant aufgerufen werden).

Das Interface CharSequence und die Methode toString

Um eine Zeichenkette aus Einzelstücken zusammenzusetzen, verwendet man am besten die Klasse StringBuilder. Doch wenn die Zeichenkette anschließend als Parameter oder Rückgabewert verwendet werden soll, wird dieser häufig über die Methode toString in einen äquivalenten String umgewandelt. Wird die Zeichenkette anschließend erneut bearbeitet, wird der übergebene String wieder in einen StringBuilder umgewandelt und so weiter.

Man könnte sich diese unnötigen Kopieroperationen sparen, indem man in diesen Fällen einfach in der Methodensignatur einen Parameter vom Typ StringBuilder statt String definiert und so das Objekt direkt übergibt. Allerdings nimmt man dann zu Gunsten der Performance eventuell Seiteneffekte in Kauf.

Falls man die Signatur der Methode allerdings nicht ändern will (etwa, weil die Methode auch mit gewöhnlichen Strings aufgerufen werden soll), stellt das JDK das Interface CharSequence bereit, das bereits in Abschnitt 12.5 vorgestellt wurde. Dieses Interface wird sowohl von der Klasse String als auch von StringBuilder implementiert und gestattet es so, Objekte beiden Typs zu übergeben.

52.2.2 Methodenaufrufe

Eine der häufigsten Operationen in objektorientierten Programmiersprachen ist der Aufruf einer Methode an einer Klasse oder an einem Objekt. Zwar werden Methodenaufrufe in Java generell recht performant ausgeführt, dennoch sollte man ihr Laufzeitverhalten einschätzen können, um in großen Programmen keine bösen Überraschungen zu erleben.

Tabelle 52.1 gibt einen Überblick über die Laufzeit (in msec.) von 500 Millionen Aufrufen einer trivialen Methode unter unterschiedlichen Bedingungen. Alle Messungen wurden mit dem JDK 1.6 auf einem AMD Dual-Core 4400+ unter Ubuntu 10 vorgenommen.

Signatur/Attribute Laufzeit
public 68
public, mit 4 Parametern 68
public static 67
protected 68
package protected 67
private 68
public synchronized 3091
public final 67

Tabelle 52.1: Geschwindigkeit von Methodenaufrufen

Dabei fallen einige Dinge auf:

Das Ergebnis legt den Verdacht nahe, dass die Virtuelle Maschine die Methoden eingebettet hat (sog. Inlining) und daher der Overhead der Übergabe eines oder mehrerer Parameter und die Kosten des Methodenaufrufs selbst keine Rolle spielen - und zwar unabhängig von der Anzahl der Parameter oder Rückgabewerte.

Der einzig wirklich allgemeingültige Rat besteht darin, Methoden nur dann als synchronized zu deklarieren, wenn es wirklich erforderlich ist. Eine Methode, die keine Membervariablen verwendet, die gleichzeitig von anderen Threads manipuliert werden, braucht auch nicht synchronisiert zu werden. Auch eine Anwendung, die nur einen einzigen Thread besitzt und deren Methoden nicht von Hintergrund-Threads aufgerufen werden, braucht überhaupt keine synchronisierten Methoden in eigenen Klassen.

Dies haben auch die Java-Entwickler erkannt und z.B. die Klasse StringBuilder zur Verfügung gestellt, die gleichwertig zu StringBuffer ist. Wesentlicher Unterschied ist, dass ihre Methoden nicht synchronisiert sind, da es so gut wie keine sinnvolle Anwendung für den verteilten Zugriff gibt.

52.2.3 Vektoren und Listen

Ein Vector ist ein bequemes Hilfsmittel, um Listen von Objekten zu speichern, auf die sowohl sequenziell als auch wahlfrei zugriffen werden kann. Aufgrund seiner einfachen Anwendung und seiner Flexibilität bezüglich der Art und Menge der zu speichernden Elemente wird er in vielen Programmen ausgiebig verwendet. Bei falschem Einsatz können Vektoren aber durchaus zum Performance-Problem werden und wir wollen daher einige Hinweise zu ihrer Verwendung geben.

Zunächst einmal ist der Datenpuffer eines Vektors als Array implementiert. Da die Größe von Arrays nach ihrer Initialisierung nicht mehr verändert werden kann, erfordert das Einfügen neuer Elemente möglicherweise das Allozieren eines neuen Puffers und das Umkopieren der vorhandenen Elemente. Ein Vector besitzt dazu die beiden Attribute Kapazität und Ladefaktor. Die Kapazität gibt an, wie viele Elemente insgesamt aufgenommen werden können, also wie groß der interne Puffer ist. Der Ladefaktor bestimmt, um wie viele Elemente der interne Puffer erweitert wird, wenn beim Einfügen eines neuen Elements nicht mehr ausreichend Platz vorhanden ist. Je kleiner die anfängliche Kapazität und der Ladefaktor sind, desto häufiger ist beim fortgesetzten Einfügen von Elementen ein zeitaufwändiges Umkopieren erforderlich.

Wird ein Vector ohne Argumente instanziert, so hat sein Puffer eine anfängliche Kapazität von 10 Objekten und der Ladefaktor ist 0. Letzteres bedeutet, dass die Kapazität bei jeder Erweiterung verdoppelt wird (analog zur Klasse StringBuilder, s. Abschnitt 52.2.1). Alternativ kann die Kapazität oder auch beide Werte beim Instanzieren an den Konstruktor übergeben werden. Durch die folgende Deklaration wird beispielsweise ein Vector mit einer anfänglichen Kapazität von 100 Elementen und einem Ladefaktor von 50 angelegt:

Vector v = new Vector(100, 50);

Ein weiteres Problem der Klasse Vector ist, dass die meisten ihrer Methoden als synchronized deklariert wurden. Dadurch kann ein Vector zwar sehr einfach als gemeinsame Datenstruktur mehrerer Threads verwendet werden. Die Zugriffsmethoden sind aber leider auch ohne Multi-Threading-Betrieb entsprechend langsam.

Seit der Version 1.2 des JDK stehen mit den Klassen LinkedList und ArrayList auch alternative Listenimplementierungen zur Verfügung, die anstelle von Vector verwendet werden können. Hier ist jedoch Vorsicht geboten, wenn das Programm nicht langsamer laufen soll als vorher. Die Klasse LinkedList implementiert die Datenstruktur in klassischer Form als doppelt verkettete Liste ihrer Elemente. Zwar entfallen dadurch die Kopiervorgänge, die beim Erweitern des Arrays erforderlich waren. Durch die Vielzahl der allozierten Objekte, in denen die Listenelemente und die Zeiger gespeichert werden müssen, und die teilweise ineffiziente Implementierung einiger Grundoperationen (insbesondere add) hat sich LinkedList jedoch im Test als relativ ineffizient herausgestellt. Wesentlich bessere Ergebnisse gab es mit der Klasse ArrayList. Sie ist ähnlich wie Vector implementiert, verzichtet aber (wie die meisten 1.2er Collections) auf die synchronized-Attribute und ist daher - insbesondere beim Zugriff mit add und get - sehr performant.

Listing 52.6 zeigt drei Methoden, die jeweils ein String-Array übergeben bekommen und daraus eine bestimmte Anzahl von Elementen zurückgeben. Die erste Version verwendet einen Vector, die zweite eine LinkedList und die dritte eine ArrayList zur Datenspeicherung. Im Test war die ArrayList-Version die schnellste, gefolgt von der Vector-Variante. Im Gegensatz zu früheren Versionen sind die Unterschiede in aktuellen JDKs aber nicht mehr allzu groß.

001 public static String[] vtest1(String el[], int retsize)
002 {
003   //Verwendet Vector
004   Vector<String> v = new Vector<String>(el.length + 10);
005   for (int i = 0; i < el.length; ++i) {
006     v.addElement(el[i]);
007   }
008   String[] ret = new String[retsize];
009   for (int i = 0; i < retsize; ++i) {
010     ret[i] = v.elementAt(i);
011   }
012   return ret;
013 }
014 
015 public static String[] vtest2(String el[], int retsize)
016 {
017   //Verwendet LinkedList
018   LinkedList<String> l = new LinkedList<String>();
019   for (int i = 0; i < el.length; ++i) {
020     l.add(el[i]);
021   }
022   String[] ret = new String[retsize];
023   Iterator<String> it = l.iterator();
024   for (int i = 0; i < retsize; ++i) {
025     ret[i] = it.next();
026   }
027   return ret;
028 }
029 
030 public static String[] vtest3(String el[], int retsize)
031 {
032   //Verwendet ArrayList
033   ArrayList<String> l = new ArrayList<String>(el.length + 10);
034   for (int i = 0; i < el.length; ++i) {
035     l.add(el[i]);
036   }
037   String[] ret = new String[retsize];
038   for (int i = 0; i < retsize; ++i) {
039     ret[i] = l.get(i);
040   }
041   return ret;
042 }
Listing 52.6: Vergleich von Listen und Vektoren

Ist es im Einzelfall dagegen erforderlich, viele Einfügungen und Löschungen innerhalb der Liste vorzunehmen, sollte eine zeigerbasierte Implementierung der arraybasierten vorgezogen werden. Während es bei Letzterer stets erforderlich ist, einen Teil des Arrays umzukopieren, wenn ein Element eingefügt oder gelöscht wird, brauchen bei den verzeigerten Datenstrukturen lediglich ein paar Verweise aktualisiert zu werden.

52.2.4 Dateizugriffe

Schreiben von Streams

Beim Schreiben in Dateien mit Hilfe von FileOutputStream- oder FileWriter-Objekten sind zwei Dinge zu beachten:

Um die Performance zu erhöhen, sollten der FileWriter in einen BufferedWriter und der FileOutputStream in einen BufferedOutputStream gekapselt werden, der mit Hilfe eines internen Puffers die Anzahl der physikalischen Schreibzugriffe reduziert. Im Test (in einer JRE 1.6 auf einer AMD 4400+ unter Ubuntu 10, vgl. Tabelle 52.2) ergab sich gegenüber dem ungepufferten Zugriff ein Geschwindigkeitszuwachs um den Faktor fünf bei den Writer-Zugriffen und um den Faktor 500 bei den OutputStream-Zugriffen. Die Standard-Puffergröße von 8 kByte ist in aller Regel ausreichend, weitere Vergrößerungen bringen keine nennenswerten Beschleunigungen.

Mit den Writer-Klassen können Character-Streams verarbeitet werden. Passend zur internen Darstellung des char-Typs in Java verwenden sie 16-Bit breite UNICODE-Zeichen zur Ein- und Ausgabe. Um eine Datei zu erzeugen, kann ein FileWriter-Objekt angelegt werden, und die Zeichen werden mit den write-Methoden geschrieben.

Das Dilemma der Writer-Klassen besteht darin, dass die meisten externen Dateien mit 8-Bit-Zeichen arbeiten, statt mit 16-Bit-UNICODE-Zeichen. Ein FileWriter führt also vor der Ausgabe eine Konvertierung der UNICODE-Zeichen durch, um sie im korrekten Format abzuspeichern. Der Aufruf der dazu verwendeten Methoden der Klasse StreamEncoder aus dem Paket sun.nio.cs kostet natürlich Zeit und vermindert die Performance der Writer-Klasse. Etwas schneller sind die (gepufferten) OutputStream-Klassen, die nicht mit Zeichen, sondern mit Bytes arbeiten. Sie führen keine aufwändige Konvertierung durch, sondern geben je Zeichen einfach dessen niederwertige 8 Bit aus. Das spart Zeit und führte im Test zu einer Beschleunigung um den Faktor 2.

Die gepufferten OutputStream-Klassen sind also an performancekritischen Stellen dann den Writer-Klassen vorzuziehen, wenn entweder sowieso Binärdaten ausgegeben werden sollen oder wenn sichergestellt ist, dass keine UNICODE-Zeichen verwendet werden, die durch das simple Abschneiden der oberen 8 Bit falsch ausgegeben würden. Da der UNICODE-Zeichensatz in den ersten 256 Zeichen zum ISO-8859-1-Zeichensatz kompatibel ist, sollten sich für die meisten europäischen und angelsächsischen Sprachen keine Probleme ergeben, wenn zur Ausgabe von Zeichen die OutputStream-Klassen verwendet werden.

Listing 52.7 erzeugt eine etwa 3 MB große Datei, bei der die Writer- und die OutputStream-Klassen verwendet werden, jeweils gepuffert und ungepuffert. Das Ergebnis finden Sie in Tabelle 52.2:

001   private final static String FILENAME = "performancetest.txt";
002   private final static int LINES = 50000;
003   private final static String NL = System.getProperty("line.separator");
004 
005   public static void createFileMitFileWriter() throws IOException
006   {
007     Writer writer = new FileWriter(FILENAME);
008     for (int i = 0; i < LINES; ++i) {
009       for (int j = 0; j < 60; ++j) {
010         writer.write('x');
011       }
012       writer.write(NL);
013     }
014     writer.close();
015   }
016 
017   public static void createFileMitBufferedFileWriter() throws IOException
018   {
019     Writer writer = new BufferedWriter(new FileWriter(FILENAME));
020     for (int i = 0; i < LINES; ++i) {
021       for (int j = 0; j < 60; ++j) {
022         writer.write('x');
023       }
024       writer.write(NL);
025     }
026     writer.close();
027   }
028   
029   public static void createFileMitFileOutputStream() throws IOException
030   {
031     OutputStream os = new FileOutputStream(FILENAME);
032     for (int i = 0; i < LINES; ++i) {
033       for (int j = 0; j < 60; ++j) {
034         os.write('x');
035       }
036       os.write('\r');
037       os.write('\n');
038     }
039     os.close();
040   }
041   
042   public static void createFileMitBufferedFileOutputStream() throws IOException
043   {
044     OutputStream os = new BufferedOutputStream(new FileOutputStream(FILENAME));
045     for (int i = 0; i < LINES; ++i) {
046       for (int j = 0; j < 60; ++j) {
047         os.write('x');
048       }
049       os.write('\r');
050       os.write('\n');
051     }
052     os.close();
053   }
Listing 52.7: Performance von Writer und OutputStream

Signatur/Attribute Laufzeit (in msec)
FileWriter 798
Buffered-FileWriter 160
FileOutputStream 33034
Buffered-FileOutputStream 71

Tabelle 52.2: Geschwindigkeit beim Schreiben von Streams

Lesen von Streams

Die Performance des sequenziellen Lesens von Zeichen- oder Byte-Streams zeigt ein ähnliches Verhalten wie die des sequenziellen Schreibens. Am langsamsten waren die ungepufferten Zugriffe mit der Klasse FileReader und FileInputStream. Die größten Geschwindigkeitsgewinne ergaben sich durch das Kapseln des FileReader und des FileInputStream in einen BufferedReader bzw. BufferedInputStream. Die Performance war dann ca. zwanzig bis dreißig Mal höher als im ungepufferten Fall.

Der Umstieg auf das byte-orientierte Einlesen mit den Klassen FileInputStream und BufferedInputStream brachte dagegen keine Vorteile. Möglicherweise muss der zur Eingabekonvertierung in den Reader-Klassen verwendete ByteToCharConverter weniger Aufwand treiben, als ausgabeseitig nötig war.

RandomAccess-Dateien

Der wahlfreie Zugriff auf eine Datei zum Lesen oder Schreiben erfolgt in Java mit der Klasse RandomAccessFile. Da sie nicht Bestandteil der Reader- Writer-, InputStream- oder OutputStream-Hierarchien ist, besteht auch nicht die Möglichkeit, sie zum Zweck der Pufferung zu schachteln. Tatsächlich ist der ungepufferte byteweise Zugriff auf ein RandomAccessFile sehr langsam, er liegt etwa in der Größenordnung des ungepufferten Zugriffs auf Character-Streams. Wesentlich schneller kann mit Hilfe der read- und write-Methoden gearbeitet werden, wenn nicht nur ein einzelnes, sondern ein ganzes Array von Bytes verarbeitet wird. Je nach Puffergröße und Verarbeitungsaufwand werden dann Geschwindigkeiten wie bei gepufferten Bytestreams oder höher erzielt. Das folgende Beispiel zeigt, wie man mit einem nur 100 Byte großen Puffer eine Random-Access-Datei bereits sehr schnell lesen kann.

001 public static void randomtest2()
002 throws IOException
003 {
004   RandomAccessFile file = new RandomAccessFile(FILENAME, "rw");
005   int cnt = 0;
006   byte[] buf = new byte[100];
007   while (true) {
008     int num = file.read(buf);
009     if (num <= 0) {
010       break;
011     }
012     cnt += num;
013   }
014   System.out.println(cnt + " Bytes read");
015   file.close();
016 }
Listing 52.8: Gepufferter Zugriff auf Random-Access-Dateien

Das Programm liest die komplette Datei in Stücken von jeweils 100 Byte ein. Der Rückgabewert von read gibt die tatsächliche Zahl gelesener Bytes an. Sie entspricht normalerweise der Puffergröße, liegt aber beim letzten Datenpaket darunter, wenn die Dateigröße nicht zufällig ein Vielfaches der Puffergröße ist. Die Performance von randomtest2 ist sehr gut, sie lag auf dem Testrechner (AMD 4400+ Dual Core, Ubuntu 10 mit JDK 1.6) bei etwa 50 MByte pro Sekunde. Ein wesentlicher Grund ist darin zu suchen, dass durch den programmeigenen Puffer ein Großteil der Methodenaufrufe zum Lesen einzelner Bytes vermieden wird. Auf die gleiche Weise lassen sich auch die streamorientierten Dateizugriffe beschleunigen, wenn die Anwendung nicht unbedingt darauf angewiesen ist, zeichenweise zu lesen bzw. zu schreiben.

52.2.5 JDBC

Bei der Verwendung der JDBC-API, also bei der Arbeit mit relationalen Datenbanken, gibt es aus Performance-Sicht ein paar Dinge zu beachten. Natürlich hängt das Laufzeitverhalten vom konkret verwendeten Datenbanksystem und seinem Treiber ab, denn die Produkte der verschiedenen Hersteller besitzen unterschiedliche Stärken und Schwächen. Einige wichtige Aussagen zur Performance lassen sich aber auch in allgemeingültiger Weise treffen.

Autocommit

Wenn wir uns via JDBC mit einer Datenbank verbinden, erhalten wir ein Objekt vom Typ Connection. Standardmäßig ist diese Verbindung im Autocommit-Modus, d.h. wenn wir diesen Modus nicht explizit mit setAutocommit(false) ausschalten, wird jedes Statement vom Treiber automatisch mit einem COMMIT quittiert. Dies wirkt sich natürlich negativ auf die Performance aus, denn das Persistieren der Transaktion benötigt wegen der erforderlichen externen Schreibzugriffe relativ viel Zeit. Listing 52.9 ist beispielsweise mit eingeschaltetem Autocommit nur etwa halb so schnell wie ohne (gemessen mit der JavaDB mit eingebetteter Datenbank).

Abgesehen von den negativen Auswirkungen auf die Performance ist der Autocommit-Modus in der Praxis auch aus fachlichen Erwägungen oft nicht gewünscht. Gerade die Möglichkeit, bei einer relationalen Datenbank die Transaktionsgrenzen selbst setzen zu können, ist ein häufig benötigtes Feature bei der Entwicklung mehrbenutzerfähiger Anwendungen, das mit dem Autocommit-Modus de facto ausgeschaltet wird.

 Hinweis 

PreparedStatements

In der Praxis werden aus einer Anwendung heraus häufig gleiche oder ähnliche Anfragen wiederholt an die Datenbank gesendet. Die Anfragen werden in SQL formuliert und vom Datenbankmanagementsystem zur Laufzeit übersetzt. Das DBMS erstellt einen sogenannten Accessplan (aka Zugriffsplan oder Zugriffspfad), der anschließend zur Ausführung kommt. Je komplexer die Anfrage ist, desto größer ist der Aufwand, einen guten Zugriffspfad zu finden. Bei einem Join beispielsweise steigt der Aufwand für das Auffinden eines guten Zugriffsplans überlinear mit der Anzahl der beteiligten Tabellen.

Für das Datenbankmanagementsystem ist es in solchen Fällen eine große Hilfe, wenn man ein PreparedStatement verwendet. Nachdem es durch Aufruf von prepareStatement an der Connection erzeugt wurde, kann man es wiederholt durch Aufruf von execute-Methode zur Ausführung bringen. Das DBMS braucht in diesem Fall nur ein einziges Mal (nämlich beim Aufruf von prepareStatement) einen passenden Zugriffspfad zu berechnen und spart so bei jedem weiteren Aufruf eine Menge Zeit gegenüber der Verwendung eines nicht preparierten Statements.

Das folgende Listing schreibt in einer Schleife 100 Sätze in eine Tabelle und benutzt dazu ein PreparedStatement:

001 PreparedStatement ps = conn.prepareStatement(
002   "insert into test (col) values (?)"
003 );
004 for (int i = 0; i < 100; i++) {
005   ps.setInt(1, i);
006   ps.execute();
007 }
Listing 52.9: Verwendung von PreparedStatements

Die Laufzeit eines gleichwertigen Programms benötigt unter Verwendung gewöhnlicher Statement-Objekte mehr als zehnmal so lang (gemessen mit der JavaDB mit eingebetteter Datenbank):

001 Statement s = conn.createStatement();
002 for (int i = 0; i < 100; i++) {
003   s.execute("insert into test (col) values (" + i + ")");
004 }
Listing 52.10: Ungünstige Verwendung von Statements

52.2.6 Autoboxing und Autounboxing

Mit der Version 5 des JDK wurde das Konzept des Autoboxing eingeführt, also die automatische, transparente Überführung von primitiven Datentypen in ihre Wrapper-Klassen (vgl. Abschnitt 11.2.3). Dies erleichert zwar die Lesbarkeit des Codes, aber der sorglose Umgang mit Autoboxing und Autounboxing kann zu Performance-Problemen führen.

Das folgende Programm berechnet die ersten 500.000 Fibonacci-Zahlen. (Zur Erinnerung: Eine Fibonacci-Folge beginnt mit den Zahlen 0 und 1, alle weiteren werden aus der Summe ihrer beiden Vorgänger errechnet.) Die Folge lautet also 0, 1, 1, 2, 3, 5, 8 usw. Das Ergebnis wird von unserem Programm in einem Array gespeichert:

001 /* Fibonacci.java */
002 
003 public class Fibonacci
004 {
005   private final static int N = 500000;
006   private final int[] werte = new int[N]; 
007   
008   Fibonacci()
009   {
010     werte[0] = 0;
011     werte[1] = 1;
012   }
013   
014   private void fibonacci()
015   {
016     for (int i = 2; i < N; i++) {
017       werte[i] = werte[i-1] + werte[i-2]; 
018     }
019   }
020 }
Fibonacci.java
Listing 52.11: Programm zum Berechnen von Fibonacci-Zahlen

Das abgebildete Listing berechnet die Fibonacci-Folge sehr schnell, denn es arbeitet ausschließlich mit int-Primitiven. Etwa zehnmal langsamer ist das Programm, wenn man Zeile 006 durch folgende ersetzt:

  private final Integer[] werte = new Integer[N]; 

Durch diese kleine Änderung findet in Zeile 017 mehrfach Autoboxing statt:

Diese Verkomplizierung führt zu der oben beschriebenen deutlichen Verlangsamung des Programms. Autoboxing ist leider nicht immer auf den ersten Blick zu erkennen, sondern kann leicht übersehen werden. Eine häufige Falle sind beispielsweise typisierte Collections, die vom Typ Integer, Double usw. sind.

52.2.7 Speicheroptimierung

Neben den direkten Prozessoraktivitäten hat auch die Art und Weise, in der das Programm mit dem Hauptspeicher umgeht, einen erheblichen Einfluss auf dessen Performance. Einige der Aspekte, die dabei eine Rolle spielen, sind:


 Titel   Inhalt   Suchen   Index   DOC  Handbuch der Java-Programmierung, 7. Auflage, Addison Wesley, Version 7.0
 <<    <     >    >>   API  © 1998, 2011 Guido Krüger & Heiko Hansen, http://www.javabuch.de