Titel | Inhalt | Suchen | Index | DOC | Handbuch der Java-Programmierung, 7. Auflage |
<< | < | > | >> | API | Kapitel 52 - Performance-Tuning |
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 } |
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 } |
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(); |
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. |
|
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 } |
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(); |
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();
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.
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.
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).
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.
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.
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 } |
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.
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 } |
Signatur/Attribute | Laufzeit (in msec) |
FileWriter | 798 |
Buffered-FileWriter | 160 |
FileOutputStream | 33034 |
Buffered-FileOutputStream | 71 |
Tabelle 52.2: Geschwindigkeit beim Schreiben 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.
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 } |
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.
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.
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. |
|
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 } |
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 } |
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 |
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.
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 |