Titel   Inhalt   Suchen   Index   API  Go To Java 2, Zweite Auflage, Handbuch der Java-Programmierung
 <<    <     >    >>  Kapitel 48 - Performance-Tuning

48.2 Tuning-Tips



48.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 48.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 48.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 48.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 48.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 48.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 48.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.3 

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).

48.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 48.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 48.1: Geschwindigkeit von Methodenaufrufen

Dabei fallen einige Dinge auf:

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.

48.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 48.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.3 

Listing 48.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 48.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.

48.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 48.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 48.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 48.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.

48.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:


 Titel   Inhalt   Suchen   Index   API  Go To Java 2, Zweite Auflage, Addison Wesley, Version 2.0
 <<    <     >    >>  © 2000 Guido Krüger, http://www.gkrueger.com