Java Perfomance Tuning

0. Abstrakt

Es scheint eines der unlösbaren Probleme der Informatik zu sein: Egal wie schnell der Rechner ist, dem Benutzer geht alles zu langsam. Ich habe auch keine Lösung zu diesem Problem allerdings versuche ich in hier zu zeigen wie aus "viel zu langsam" "erträglich" wird ;-).

Inhalt


0. Abstrakt
1. Responsability
2. Tuningschritte
3. "Optimierungen" die von den meisten Compilern durchgeführt werden
3.1 Übersetzung von einfachen Ausdrücken
3.2 Inlining
4. Einfache Optimierungen
4.1 Optimierung von Standardanweisungen
4.2 Schleifen
4.3 Exceptions
4.4 Rekursionen
5. Laden beschleunigen
6. Objekte
6.1 Objekte vermeiden
6.2 Strings
6.3 Synchronisierte Objekte und Threads
7. Selber implementieren?
8. Quellen


1. Responsability

Bevor wir wirklich mit dem Tunen anfangen zuerst mal ein paar Tricks für Programmteile in denen sich die Wartezeit nicht verkürzen lässt.

Wir versuchen also die Wartezeit zu verkürzen, die schlechte Performanz zu kaschieren. Dies kann zB durch graphische Animationen passieren. Als Beispiel lässt sich der Startvorgang von Windows XP anführen, für mich eine Meisterleistung bezüglich "scheinbar" schnellem Starten. Dies wurde erreicht durch Performance Tuning aber auch indem man geschickt dem Benutzer Fading-Effekte vorspielt.

Fading Effekte brauchen kaum Rechenzeit, zeigen dem Benutzer aber das etwas vorwärts geht.

Des weiteren sollten lange Vorgänge in kürzere Abschnitte unterteilt werden um dem Benutzer zu zeigen das das Programm weiterarbeitet.

Wichtig ist das das Programm responsable ist, d.h. der Benutzer muss das Gefühl haben das das Programm "sofort" auf seine Eingaben reagiert. Tödlich ist es wenn ein Programm zB seinen GUI nicht neu zeichnet nachdem es von einem Fenster verdeckt war.

Meist erreicht man Responsability durch Trennung des Programms in einen Arbeitsthread, der rechenintensive Aufgaben übernimmt, und eine GUI-Thread, der die GUI zeichnet und auf Benutzerereignisse reagiert.

Des weitern lässt sich Rechenzeit in der das Programm nichts tut für Vorarbeiten oder Aufräumarbeiten nutzen. Es ließe sich zB bei einer Suchfunktion während der Benutzer den Suchbegriff eintippt bereits eine Vorbereitung auf die auszuführende Suche durchführen. Wenn der Benutzer die Eingabe dann bestätigt kann blitzschnell das Ergebnis angezeigt werden, des Programm erscheint blitzschnell, hat die eigentliche Arbeit aber einfach vorher erledigt.

2. Tuningschritte

Im Allgemeinen sollte das Tuning erst an der fertigen Anwendung durchgeführt werden, jedoch beim Entwurf nicht außer acht gelassen werden, denn Designfehler lassen sich meist auch durch noch so intensives Tuning nicht beheben.

Warum sollte man erst die fertige Anwendung tunen?

Dafür gibt es mehrere Gründe:

Grundsätzlich sollte man nur das Tunen was wirklich bremst.

Des weiteren ist die Verwendung der neusten Compiler zu empfehlen, da diese viele Optimierungen selbstständig vornehmen. Allerdings sollte man bei JIT-Compilern auch die Performanz auf älteren Systemen kontollieren.

"Performance Tuning is similar to playing a strategy game but happily you usually get paid for it"

Dieser Satz beschreib ziemlich gut die Vorgehensweise beim Tunen. Man muss Schrittweise und mit einer gewissen Strategie Vorgehen um das gewollte Ergebnis zu erzielen.

Folgende Schritte sind dazu notwendig:

  1. Benchmark einbauen und Tests ausführen
  2. Bottlenecks identifizieren
  3. Eines aus den Top 5 Bottelnecks wählen (abhängig von bremsender Wirkung und Behebbarkeit)
  4. Ausgewählte(s) Methode/Datenstruktur/Objekt/… analysieren
  5. Eine Änderung durchführen
  6. Performance-Messung durchführen
  7. Falls die Änderung nicht den gewollten Effekt erzielte vielleicht einen Schritt zurück
  8. Solange noch Verbesserungen möglich sind weiter bei Schritt 4
  9. Messung der Gesamtverbesserung
  10. Wieder mit Schritt 1 beginnen da sich meist das gesamte Performance-Profil geändert hat

Bei der Wahl des Bottelnecks ist klarerweise ein Programmstück zu wählen das viel Rechenzeit benötigt, denn wenn eine Methode 1% der Gesamtrechenzeit verbraucht kann durch Tuning dieser Methode das Programm maximal um 1% beschleunigt werden, wenn die Methode allerdings 10% der Rechenzeit braucht ist das Programm bei einer Einsparung von 50% um 5% schneller.

3. "Optimierungen" die von den meisten Compilern durchgeführt werden
3.1 Übersetzung von einfachen Ausdrücken

arr[1]=arr[1]+5; sollte vom Compiler so übersetzt werden das die Adresse der Speicherzelle nur einmal berechnet wird (also gleich wie die Anweisung arr[1]+=5;)

y=x/2; sollte in eine shift-Operation übersetzt werden (also gleich wie die Anweisung y=x>>1;)

y=x*4; sollte in eine shift-Operation übersetzt werden (also gleich wie die Anweisung y=x<<2;)

T y = new T(5); T x = (T)y; sollte keine dynamische Typprüfung oder einen Typcast bei der zweiten Anweisung ergeben

int x = 0; sollte nicht in eine Anweisungsfolge führen, die Speicherplatz anlegt und ihn automatisch mit Null überschreibt und dann nochmals den Wert 0 zuweist.

int x = 1; x = 2; sollte x nicht zweimal einen Wert zuweisen. Die kommt öfters in verschachtelten Konstruktoren vor.

int duration = 24*60*60; sollte nicht zur Laufzeit den Initialisierungswert von duration berechnen.

String s = „“ + 24 + „hours “ + „online“; sollte nicht dazu führen das zur Laufzeit Zeichenketten verkettet werden.

Unter Java sollten wenn möglich switch Anweisungen in Sprungtabellen und nicht wie aufeinanderfolgende If-Anweisungen übersetzt werden. Dies kann nur erfolgen wenn die Case-Zweige aufeinanderfolgende Werte haben. Um dies zu errechen werden teilweise auch Dummy-Zweige eingefügt.

3.2 Inlining

final, private und static Methoden können mittels Inlineing automatisch eingefügt werden. Dies macht nur bei kurzen Methoden Sinn (kurz ist je nach Complier verschieden zB solange keine lokalen Variablen verwendet werden)

private int m1() { return 5; }
private int m2() { return m1(); }

Inlining lässt sich sehr gut für Debug-Ausgaben verwenden

public interface A {
  public static final boolean DEBUG = false;
}
public class B {
  public static int foo() { 
    if (A.DEBUG) System.out.println(„Check“);
    return 0;
  }
}

Der Compiler aus dem Sun SDK führt Inlining nur bei Konstanten aus Interfaces aus. Beim Compilieren wird deshalb die komplette If-Anweisung entfernt.

4. Einfache Optimierungen
4.1 Optimierung von Standardanweisungen
4.2 Schleifen

Anweisungen in Schleifen werden meist oft ausgeführt und tragen daher einen Großen Anteil an der benötigten Rechenzeit. Deshalb kann man hier besonders viel Zeit einsparen. Die wichtigsten Punkte:

Folgendes Beispiel könnte aus einem ganz normalen Programm stammen (Ok, es ist absichtlich einiges reingepackt ;-)

for(long i=0; i<collection.size(); i++) {
  countArr[0]=countArr[0] + 5 * points;
}

Optimiert sieht das dann so aus:

int count=countArr[0];
int addpoints=5*points;
for(int i=collection.size(); --i>=0; ){
  count+=addpoints;
}
countArr[0]=count;

Dadurch lässt sich die Ausführungszeit je nach Compiler und JIT auf zB 3% bei der Java 1.4.1 Compiler/ Java 1.4.1 Server VM senken.

Die Zwischenschritte finden sie hier und die vollständigen Testergebnisse hier.

4.3 Exceptions

Schleifen ließen sich auch durch Exceptions terminieren. Dies bringt aber eine sehr großen Anzahl an Schleifendurchläufen einen Geschwindigkeitsgewinn, ist aber ein absolut scheußlicher Programmierstil und sollte deshalb nicht gemacht werden.

try {
  for(int i=0;;i++) {
    …
  }
} catch (Exception e) {}

Bei JDK 1.1.x haben Try-Blocks ohne geworfen Exceptions die Ausführung gebrenmst, bei neuren Java-Versionen sollte dies aber nach meinen Tests der Vergangenheit angehören. Auf jeden Fall bremsen Try-Blocks wenn einen Exception auftritt deutlich und sollten im normalen Programmablauf nicht vorkommen.

4.4 Rekursionen

Rekursionen lassen sich immer in Iterationen umwandeln und damit beschleunigen.

public static long fact_rec (int n) {
  return n*fact_rec(n-1);
}
public static long fact_iter (int n) { 
  long result = 1;
  while (n>1) {
    result *= n--;
  }
  return result;
}

Die iterative Version ist 88% schneller. Eine weitere deutlichere Beschleunigung wäre möglich wenn sich Zwischenergebnisse zwischenspeichern und wiederverwenden lassen.

5. Laden beschleunigen

Der erste Eindruck zählt. Auch bei Programmen gilt das und wenn sich das Laden des Programms ewig hinzieht lässt sich der Eindruck vom langsamen Programm kaum mehr beseitigen. Auch hier gilt: Täuschen und Tarnen!

Ein Splash Screen gibt dem Benutzer die Rückmeldung das im Hintergrund was geladen wird und das es hoffentlich bald weitergeht.

Um die Ladezeit von Java-Programmen zu minimieren lassen sich unkompremierte JAR-Archive verwenden. Dadurch muss nur ein File vom Filesystem oder Netzwerk geladen werden aber es muss auch nichts dekompremiert werden. Auch ein Preload von Klassen im Hintergrund kann die Systembelastung zu zeitkritischen Zeitpunkten deutlich verringern. Allerdings sollte man den Erfolg dieser beiden Methoden immer überprüfen, denn es kann dabei auch das Caching der VM behindert werden.

6. Objekte
6.1 Objekte vermeiden

Objekte sind die Grundlage von Java, trotzdem oder gerade deshalb lässt sich gerade in diesem Bereich die Geschwindigkeit der Applikation stark verbessern oder verschlechtern. Grund dafür ist das sowohl das Erzeugen wie auch das Garbage Collection von Objekten rechenintensiv ist.  Je weniger Objekte man verwendet umso weniger Objekte müssen erzeugt und umso weniger Speicherfreigaben müssen gemacht werden.

Eine Möglichkeit ist es statt mehrer kleiner Objekte ein großes zu verwenden. Dies widerspricht meist dem objektorientierten Design, kann aber deutliche Leistungssteigerungen bringen. Dies lässt sich aber nur bei 1:1 bzw 1:s (s ist einen kleine fixe Zahl) verwirklichen.

class user1 {
  public String fname;
  public String lname;
  public EMailAddr[] emails = new EMailAddr[3];
}
class EMailAddr {
  public String addr;
} 

Diese 5 Objekte lassen sich durch ein einziges ersetzten

class user2 {
  public String fname;
  public String lname;
  public String email1;
  public String email2;
  public String email3;
}

Dies macht erst richtig Sinn wenn das Programm hunderte von Usern verwalten soll.

Auch Verwendung von einfachen Datentypen statt Objekten hilft dem Garbage Collector arbeit zu sparen.

TypeCasts sind rechenintensive Anweisungen und sollten deshalb wenn möglich reduziert werden. Des weiteren sollte man statt die Exception von einem TypeCast abzufangen lieber zuerst instanceof() abfregen.

Eine weitere Möglichkeit das Erzeugen von Objekten und die Arbeit des Garbage Collectors zu minimieren ist die Verwaltung eines Objektpools. Objekte werden nach ihrer Verwendung nicht freigegeben sondern in den Objektpool zurückgegeben und dort recycelt. Besonders einfach geht das mit einem Factory-Pattern zur Erzeugung von Objekten. Des weiteren lassen sich so Objekte im vorhinein (wenn der Rechner grad mal nix zu tun hat) erzeugen und sind bei Bedarf dann in großer Menge verfügbar.

Mit dem Factory Pattern lässt sich noch eine weitere Beschleunigung erzielen, indem man nicht mit new neue Objekte anlegt sondern ein vorhandenes Objekt klont. Vor allem bei umfangreichen Konstruktoren bringt das einiges an Rechenzeit.

private static Something MASTER = new Something();
public static Something getNewSomething() {
  return (Something) MASTER.clone();
}

Ein Beispiel wie man das unnötige Erzeugen von Objekten minimieren kann:

Nehmen wir an wie haben ein Steuerelement das seine Größe möglichst einem übergebenen Größe-Objekt anpassen soll. Zurückggeben wird ein Größe-Object das der tatsächlichen Größe entspricht. In der Entwurfsphase der Bibliothek lässt sich das unnötige Erzeugen eines Objektes leicht verhindern indem man festlegt das das Objekt übergeben wird und nach dem Aufruf mit den tatsächlichen Werten belegt ist.

Wurde aber bei der ersten Entwicklung eine andere Lösung gewählt (nämlich das ein Größen-Objekt mit der tatsächlichen Größe zurückgeliefert wird) so läßt sich das nicht mehr so einfach ändern.

class Dimension {
  public int height;
  public int width;
  public Dimension(int height, int width) {
    this.height = height;
    this.width = width;
  }
}
public Dimension setSize(Dimension d) {
  … 
  d.width=x; d.height=y; 
  return d;
}

Diese Lösung ergibt nämlich ein Problem und zwar in folgendem Fall:

realSize = O.setSize(wantedSize);
wantedSize.height=7;  //realSize.height = ?

Wie lässt sich das anderes Lösen? Eine Lösung (die aber auch die aber Änderungen an der Schnittstelle nötig macht):

class FixedDimension {
  public final int height;
  public final int width;
  public FixedDimension(int height, int width) {
    this.height = height;
    this.width = width;
  }
}
public FixedDimension setSize(FixedDimension d) {
  …
  if (changed) d = new FixedDimension(x,y);
  return d;
}

Die Idee dabei ist das sich die final Variablen nur einmal einen Wert zuweisen lassen. Keine sehr elegante Lösung, es geht auch schöner:

public Dimension setSize(Dimension d) {
  Dimension newd = (Dimension)d.clone();
  setSizeFast(newd);
  return newd;
}
public void setSizeFast(Dimension d) {
  …
  d.width = x; 
  d.height = y;
}

Alle bisherigen Programme funktionieren weiter, wenn auf Geschwindigkeit geachtet werden soll kann o.setSizeFast(d); verwendet werden!

6.2 Strings

Insert, Append, Delete funktionieren auf StringBuffer viel schneller als auf Strings. Werden zB zwei Strings konkateniert so wird der erste String in einen StringBuffer umgewandelt, der zweite String angehängt und das ganze wieder in einen String umgewandelt. Hier lassen sich bei der Verwendung von Stringbuffers die Umwandlungen einsparen.

Eine weitere schnelle Operation ist Substring auf Strings weil hier nichts kopiert sondern nur eine neue Sicht auf die Zeichenkette angelegt wird.

Auch toString auf StringBuffer kopiert nichts, allerdings wird dabei der vom StringBuffer zusätzlich reservierte Speicher nicht wieder freigegeben. Wurden zuerst 1000 Byte für den StringBuffer reserviert so bleiben die auch belegt wenn der Stringbuffer mit toString in einen String umgewandelt wird.

6.3 Synchronisierte Objekte und Threads

Falsch eingesetzte Threads und jedes Syncronized bremsen die Ausführung. Allerdings sind meist mehrere Threads für eine optimierte Anwendung erforderlich (siehe Responsability). Allerdings ist ein sinnvoller Einsatz nötig, ein Thread der die Aufgaben an einen anderen übergibt und dann auf deren beendigung wartet ist sinnlos.

Syncronized Methoden sind 10 mal (beim Einsatz von guten JIT Compilern) bis zu 100 mal auf älteren JRE langsamer. Allerdings führt gleichzeitiger Zugriff auf (non-statische) unsyncronisierte Methoden zu schwer auffindbaren und behebbaren Fehlern. Aus diesem Grund sollte man wie schon früher grundsätzlich angesprochen Syncronisierte Objekte verwenden aber bei Performance-Problemen auf unsyncronisierte Versionen ausweichen.

Bei eigenen Klassen sollte man deshalb syncronisierte Wrapper für die unsyncronisierten Methoden anbieten.

public interface Adder {
  public void add(int aNumber);
}
public class UnsyncedAdder implements Adder {
  int total;
  int numAdditions;  
  public void add(int add){ 
    total+=add; numAdditions++;
  }
}
public class SyncedAdder implements Adder {  
  Adder a;
  public SyncedAdder(Adder a) { this.a = a; }  
  public synchronized void add( int add) { a.add(add); }
}

Hier noch eine Liste der synchronisierten und unsychronisierten Objekte der Java Biblothek

Interface Class Synchronisiert Eigenschaften
Map HashMap Nein Schnellstes Map
  HashTable Ja Langsamer als HashMap aber schneller als sync. HashMap
  TreeMap Nein Langsamer als HashTable; Geordnete Iteration der Keys möglich
Set HashSet Nein Schnellstes Set; Langsamer als HashMap aber Set
  TreeSet Nein Langsamer als HashSet; Geordnete Iteration der Keys möglich
List ArrayList Nein Schnellste Liste
  Vector Ja Langsamer als ArrayList aber schneller als sync ArrayList
  Stack Ja Gleich schnell wie Vector; LIFO Queue
  LinkedList Nein Langsamer als andere List-Klassen; für Spezial-Fälle schneller

7. Selber implementieren?

Ich hab mich schon oft gefragt ob ich etwas selber Implementieren soll oder lieber eine fertige Funktion aus einer Java-Biblothek nehmen soll. Die Biblotheksfunktionen sind oft nicht optimal weil sie allgemeinere Fälle behandeln und auch sonst können die Biblothekfunktionen manchmal nicht optimal implementiert sein. Wird also in einem Programmteil viel Wert auf Geschwindigkeit gelegt, kann dies oft durch Ersetzten der Biblotheksfunktion durch eine optimierte Eigenentwicklung erreicht werden. So ist zum Beispiel eine selbstimplementierte verkettete IntegerList deutlich schneller als die Liste aus der Java-Biblothek mit Integer Objekten.

8. Quellen