Titel | Inhalt | Suchen | Index | DOC | Handbuch der Java-Programmierung, 3. Auflage
<< | < | > | >> | API | Kapitel 49 - Performance-Tuning

## 49.2 Tuning-Tips

- 49.2 Tuning-Tips
  - 49.2.1 String und StringBuffer
    - String-Verkettung
    - Einfügen und Löschen in Strings
    - Die Methode toString der Klasse StringBuffer
    - Die Unveränderlichkeit von String-Objekten
    - Durchlaufen von Zeichenketten
  - 49.2.2 Methodenaufrufe
  - 49.2.3 Vektoren und Listen
  - 49.2.4 Dateizugriffe
    - Schreiben von Streams
    - Lesen von Streams
    - RandomAccess-Dateien
  - 49.2.5 Speicher-Optimierung

### 49.2.1 String und StringBuffer

#### String-Verkettung

In Java gibt es zwei unterschiedliche Klassen `String` und `StringBuffer` zur Verarbeitung von Zeichenketten, deren prinzipielle Eigenschaften in Kapitel 11 erläutert wurden. Java-Anfänger verwenden meist hauptsächlich 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.

Daß 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 49.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 StringBuffer(s).append("x").toString();
    005 }

Listing 49.2: Wie der Java-Compiler String-Verkettungen übersetzt

Dieser Code ist in mehrfacher Hinsicht unglücklich. Pro Schleifendurchlauf wird ein temporäres `StringBuffer`-Objekt alloziert und mit dem zuvor erzeugten String initialisiert. Der Konstruktor von `StringBuffer` 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 daß der nachfolgende Aufruf von `append` das Array nicht neu allozieren und die Zeichen umkopieren muß. 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 `StringBuffer` sehr ineffizient.

Eine eminente Verbesserung ergibt sich, wenn die Klasse `StringBuffer` und ihre Methode `append` direkt verwendet werden:

    001 String s;
    002 StringBuffer sb = new StringBuffer(1000);
    003 for (int i = 0; i < 20000; ++i) {
    004   sb.append("x");
    005 }
    006 s = sb.toString();

Listing 49.3: Performante String-Verkettungen mit StringBuffer.append

Hier wird zunächst ein `StringBuffer` erzeugt und mit einem 1000 Zeichen großen Puffer versehen. Da die `StringBuffer`-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. In der Summe 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 `StringBuffer`-Objekt vornimmt, denn er bestimmt, wann bei fortgesetztem Aufruf von `append` das nächste Mal umkopiert werden muß. Anders als beispielsweise bei der Klasse `Vector`, die einen veränderbaren Ladefaktor besitzt, verdoppelt sich die Größe eines `StringBuffer`-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, daß 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 49.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 von `StringBuffer` 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 49.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 `StringBuffer`-Methoden um, wobei unnötig viele Zwischenobjekte entstehen und unnötig oft kopiert werden muß. Eine bessere Lösung kann man auch hier durch direkte Verwendung eines `StringBuffer`-Objekts erzielen:

    001 String s;
    002 StringBuffer sb = new StringBuffer(1000);
    003 for (int i = 0; i < 10000; ++i) {
    004   sb.insert(0, "x");
    005 }
    006 s = sb.toString();

Listing 49.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, so daß zusätzlich das Memory-Subsystem und der Garbage Collector entlastet werden.

|  |  |  | Seit dem JDK 1.2 gibt es in der Klasse `StringBuffer` 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 `StringBuffers` auch direkt das gewünschte Stück gelöscht werden:
    String sub2 = sb.delete(1000, 2000).toString();
JDK1.1-1.4

#### Die Methode toString der Klasse StringBuffer

Den vorangegangenen Abschnitten kann man entnehmen, daß die Verwendung der Klasse `StringBuffer` 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 muß auf einen vorhandenen String lesend zugegriffen werden, geht dies im allgemeinen mit den vielseitigeren Methoden der Klasse `String` besser. Um einen `String` in einen `StringBuffer` 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 `StringBuffer`-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 `StringBuffer`-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 `StringBuffer`-Objekt gesetzt. Dieses wird bei allen verändernden `StringBuffer`-Methoden abgefragt und führt dazu, daß - wenn es gesetzt ist - der Pufferinhalt vor der Veränderung kopiert und die Änderung auf der Kopie vorgenommen wird. Ein echter Kopiervorgang wird also solange nicht erforderlich, wie auf den `StringBuffer` 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 daß 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 angepaßt 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 muß.

#### Durchlaufen von Zeichenketten

Soll ein `String` durchlaufen werden, so 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).

### 49.2.2 Methodenaufrufe

Eine der häufigsten Operationen in objektorientierten Programmiersprachen ist der Aufruf einer Methode an einer Klasse oder einem Objekt. Generell werden Methodenaufrufe in Java recht performant ausgeführt. Ihr Laufzeitverhalten ist jedoch stark von ihrer Signatur und ihren Attributen abhängig. Tabelle 49.1 gibt einen Überblick über die Laufzeit (in msec.) von 10 Millionen Aufrufen einer trivialen Methode unter unterschiedlichen Bedingungen. Alle Messungen wurden mit dem JDK 1.2 Beta 4 auf einem PentiumII-266 unter Windows 95 vorgenommen.

Signatur/Attribute | Ohne JIT | Mit JIT
public | 5650 | 280
public, mit 4 Parametern | 7800 | 390
public static | 5060 | 110
protected | 5770 | 280
private | 5820 | 50
public synchronized | 9500 | 4660
public final | 6260 | 50

Tabelle 49.1: Geschwindigkeit von Methodenaufrufen

Dabei fallen einige Dinge auf:

- In jedem Fall bringt der JIT einen erheblichen Geschwindigkeitsvorteil. Er liegt (mit Ausnahme der `synchronized`-Methode) in diesem Beispiel durchweg bei über einer Zehnerpotenz.
- Methoden des Typs `final` und `private` werden am schnellsten ausgeführt, insbesondere bei aktiviertem JIT.
- Klassenmethoden werden schneller ausgeführt als Instanzmethoden.
- Die Übergabe von Parametern erfordert zusätzliche Zeit. In unserem Beispiel wurden vier Argumente (`int`, `String`, `double` und `boolean`) übergeben.
- Mit Abstand am langsamsten ist der Aufruf von Methoden, die das `synchronized`-Attribut verwenden, denn der Zugriff auf die Sperre zur Synchronisation in Multi-Threading-Umgebungen kostet erhebliche Zeit. Auch der Just-In-Time-Compiler bringt hier keine nennenswerten Vorteile.

Weiterhin ist zu beachten, daß der polymorphe Aufruf von Methoden Zeit kostet (was nicht aus dieser Tabelle abgelesen werden kann). Ist beispielsweise aus einer Klasse A eine weitere Klasse B abgeleitet, so ist der Aufruf von Methoden auf einem Objekt des Typs A kostspieliger als der auf einem Objekt des Typs `B`.

Aus diesen Ergebnissen allgemeingültige Empfehlungen abzuleiten, ist schwierig. Zwar empfiehlt es sich offensichtlich, Methoden als `private` bzw. `final` zu deklarieren, wenn sicher ist, daß sie in abgeleiteten Klassen nicht aufgerufen bzw. überlagert werden sollen. Auch könnte man versuchen, verstärkt Klassenmethoden zu verwenden oder zur Vermeidung von polymorphen Aufrufen die Vererbungshierachie zu beschränken oder mit Hilfe des Attributs `final` ganz abzuschneiden. All diese Entscheidungen hätten aber einen starken Einfluß auf das Klassendesign der Anwendung und könnten sich leicht an anderer Stelle als Sackgasse herausstellen.

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. Eine Anwendung, die nur einen einzigen Thread besitzt und deren Methoden nicht von Hintergrundthreads aufgerufen werden, braucht überhaupt keine synchronisierten Methoden in eigenen Klassen.

### 49.2.3 Vektoren und Listen

Ein `Vector` ist ein bequemes Hilfsmittel, um Listen von Objekten zu speichern, auf die sowohl sequentiell 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 durchaus zum Performance-Killer 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 zeitaufwendiges 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, daß die Kapazität bei jeder Erweiterung verdoppelt wird (analog zur Klasse `StringBuffer`, s. Abschnitt 49.2.1). Alternativ kann die Kapazität oder auch beide Werte beim Instanzieren an den Konstruktor übergeben werden. Durch die folgende Deklaration wird 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, daß 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, soll das Programm nicht langsamer laufen 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 bei aktiviertem JIT und Zugriff mit `add` und `get` sehr - performant. |  |
JDK1.1-1.4

Listing 49.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 dritte Version eindeutig die schnellste. Bei aktiviertem JIT und Übergabe von 100000 Elementen, von denen jeweils die Hälfte zurückgegeben wurden, war das Verhältnis der Laufzeiten der drei Methoden etwa 3:18:1.

    001 public static String[] vtest1(String el[], int retsize)
    002 {
    003   //Verwendet Vector
    004   Vector v = new Vector(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] = (String)v.elementAt(i);
    011   }
    012   return ret;
    013 }
    014
    015 public static String[] vtest2(String el[], int retsize)
    016 {
    017   //Verwendet LinkedList
    018   LinkedList l = new LinkedList();
    019   for (int i = 0; i < el.length; ++i) {
    020     l.add(el[i]);
    021   }
    022   String[] ret = new String[retsize];
    023   Iterator it = l.iterator();
    024   for (int i = 0; i < retsize; ++i) {
    025     ret[i] = (String)it.next();
    026   }
    027   return ret;
    028 }
    029
    030 public static String[] vtest3(String el[], int retsize)
    031 {
    032   //Verwendet ArrayList
    033   ArrayList l = new ArrayList(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] = (String)l.get(i);
    040   }
    041   return ret;
    042 }

Listing 49.6: Vergleich von Listen und Vektoren

Ist es dagegen erforderlich, viele Einfügungen und Löschungen innerhalb der Liste vorzunehmen, sollte im allgemeinen 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.

### 49.2.4 Dateizugriffe

#### Schreiben von Streams

Seit dem JDK 1.1 gibt es die `Writer`-Klassen, mit denen Character-Streams verarbeitet werden können. 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. Um die Performance zu erhöhen, kann der `FileWriter` in einen `BufferedWriter` gekapselt werden, der mit Hilfe eines internen Zeichenpuffers die Anzahl der Schreibzugriffe reduziert. Im Test ergab sich dadurch ein Geschwindigkeitszuwachs um den Faktor drei bis vier gegenüber dem ungepufferten Zugriff. Die von `BufferedWriter` verwendete Standard-Puffergröße von 8 kByte ist in aller Regel ausreichend, weitere Vergrößerungen bringen keine nennenswerten Beschleunigungen.

Das Dilemma der `Writer`-Klassen besteht darin, daß 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 `CharToByteConverter` aus dem Paket `sun.io` kostet natürlich Zeit und vermindert die Performance der `Writer`-Klasse. Wesentlich schneller sind die (älteren) `OutputStream`-Klassen, die nicht mit Zeichen, sondern mit Bytes arbeiten. Sie führen keine aufwendige Konvertierung durch, sondern geben je Zeichen einfach dessen niederwertige 8 Bit aus. Das spart viel Zeit und führte im Test zu einer nochmals um den Faktor drei bis vier beschleunigten Ausgabe (wenn auch der `FileOutputStream` in einen `BufferedOutputStream` eingeschlossen wurde).

Die `OutputStream`-Klassen sind also immer dann den `Writer`-Klassen vorzuziehen, wenn entweder sowieso Binärdaten ausgegeben werden sollen oder wenn sichergestellt ist, daß 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 49.7 zeigt das Erzeugen einer etwa 300 kByte großen Datei, bei der zunächst die `Writer`- und dann die `OutputStream`-Klassen verwendet wurden. Im Test lag die Ausführungsgeschwindigkeit der zweiten Variante um etwa eine Zehnerpotenz über der ersten.

    001 public static void createfile1()
    002 throws IOException
    003 {
    004   Writer writer = new FileWriter(FILENAME);
    005   for (int i = 0; i < LINES; ++i) {
    006     for (int j = 0; j < 60; ++j) {
    007       writer.write('x');
    008     }
    009     writer.write(NL);
    010   }
    011   writer.close();
    012 }
    013
    014 public static void createfile4()
    015 throws IOException
    016 {
    017   OutputStream os = new BufferedOutputStream(
    018     new FileOutputStream(FILENAME)
    019   );
    020   for (int i = 0; i < LINES; ++i) {
    021     for (int j = 0; j < 60; ++j) {
    022       os.write('x');
    023     }
    024     os.write('\r');
    025     os.write('\n');
    026   }
    027   os.close();
    028 }

Listing 49.7: Performance von Writer und OutputStream

#### Lesen von Streams

Die Performance des sequentiellen Lesens von Zeichen- oder Byte-Streams zeigt ein ähnliches Verhalten wie die des sequentiellen Schreibens. Am langsamsten war der ungepufferte Zugriff mit der Klasse `FileReader`. Die größten Geschwindigkeitsgewinne ergaben sich durch das Kapseln des `FileReader` in einen `BufferedReader`, die Performance lag um etwa eine Zehnerpotenz höher als im ungepufferten Fall. Der Umstieg auf das byte-orientierte Einlesen mit den Klassen `FileInputStream` und `BufferedInputStream` brachte dagegen nur noch geringe Vorteile. Möglicherweise muß 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 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 49.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 (Pentium II, 266 MHz, 128 MB, UW-SCSI) bei etwa 5 MByte pro Sekunde, was für ein Java-Programm sicherlich ein respektabler Wert ist. Ein wesentlicher Grund ist darin zu suchen, daß durch den programmeigenen Puffer ein Großteil der Methodenaufrufe zum Lesen einzelner Bytes vermieden werden (in diesem Fall sind es um den Faktor 100 weniger). 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.

### 49.2.5 Speicher-Optimierung

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

- Jede Allozierung von Speicher kostet Rechenzeit. Der Speicher muß entweder vom Betriebssystem oder vom Laufzeitsystem der VM beschafft werden. Auch das (automatische) Initialisieren des Speichers kostet Zeit. Das Anlegen eines Arrays mit 1000 Elementen dauert wesentlich länger als das eines mit 10 Elementen.
- Objekte mit aufwendigen Konstruktoren benötigen möglicherweise viel Zeit zur Initialisierung. Bei ihnen kann es sinnvoll sein, sie zu »recyceln«. Dazu werden sie nach Gebrauch in einer geeigneten Datenstruktur gesammelt und können dem nächsten Interessenten (alternativ zur Erzeugung eines neuen Objekts) zur Verfügung gestellt werden. Vor der Verwendung muß dieser das Objekt natürlich geeignet initialisieren.
- Nicht mehr referenzierter Speicher belastet den Garbage Collector und erfordert CPU-Zeit, um dem Programm wieder zugeführt werden zu können.
- In ungünstigen Fällen kann es sein, daß die VM den benötigten Speicher schrittweise in relativ kleinen Stücken beim Betriebssystem anfordert. Das kostet unter Umständen sehr viel Zeit. In diesem Fall kann es sinnvoll sein, mit Hilfe des Schalters `-Xms` den beim Start der VM anzufordernden Speicher auf einen höheren Wert einzustellen.
- Große Mengen an temporären, kurzlebigen Objekten belasten die VM ebenfalls. Derartige Allokationsszenarien entstehen beispielsweise beim Modifizieren von Strings oder wenn primitive Typen mit Hilfe ihrer Wrapperklassen in Collections gespeichert werden sollen. In Abschnitt 49.3 zeigen wir ein harmlos aussehendes Programm, das beim Anlegen von 10 kByte Nutzdaten 75 MByte Datenmüll erzeugt.
- Werden Referenzen auf Objekte nicht gelöscht, bleibt der zugeordnete Speicher belegt und der Garbage Collector kann ihn nicht wieder freigeben. Das belastet nicht nur die VM, die zunehmend neuen Speicher beim Betriebssystem anfordern muß, sondern führt früher oder später zum Absturz des Programms mit einem `OutOfMemoryError`.
- Derartige Speicherlecks entstehen, wenn eigentlich nicht mehr benötigte Objekte an »lebenden« Referenzen hängen (also an Variablen, die im Programm noch benötigt werden). Lebende Referenzen sind die lokalen Variablen auf den Stacks aller laufenden Threads plus alle statischen Variablen des Programms. Zudem natürlich alle Variablen, die indirekt daran hängen. Als Programmierer sollte man diesbezüglich den statischen Variablen (insbesondere wenn sie auf Collections verweisen) besonderes Augenmerk schenken.
- Um dem Garbage Collector die Arbeit zu erleichtern, kann es sinnvoll sein, ihn in Programmpausen von Zeit zu Zeit durch Aufruf der Methode `gc` der Klasse `System` explizit aufzurufen. Das Programm kann ihm auch dadurch helfen, daß nicht mehr benötigten Objektvariablen explizit der Wert `null` zugewiesen wird.

Titel | Inhalt | Suchen | Index | DOC | Handbuch der Java-Programmierung, 3. Auflage, Addison Wesley, Version 3.0
<< | < | > | >> | API | © 1998-2002 Guido Krüger, http://www.javabuch.de