Kolumne

Aus der Java-Trickkiste: Performancemythen

Arno Haase

Performance ist ein beliebtes Thema für Entwicklerstammtische. Echte und vermeintliche Best Practices werden – oft als Teil persönlicher Erlebnisse – von Programmierergeneration zu Programmierergeneration weitergegeben. Diesen Monat nehme ich eine Reihe solcher Meinungen unter die Lupe, die mir in Projekten und bei Reviews immer wieder begegnen.

„String-Konkatenation ist langsam“

Wenn ein String-Literal im Quelltext sehr lang wird, ist es oft besser lesbar, wenn man es auf mehrere Zeilen verteilt (Listing 1). Eine Meinung, die mir immer wieder begegnet, ist, dass diese Art der Konkatenation von Strings langsam ist und man stattdessen besser einen StringBuilder verwendet (Listing 2).

String sql = "select p.firstname, p.lastname, f.firstname as father " +
  "from Person p left join Person f on f.oid = p.fk_father " +
  "where p.birthday > ?";
StringBuilder sb = new StringBuider();
sql.append ("select p.firstname, p.lastname, f.firstname as father ");
sql.append ("from Person p left join Person f on f.oid = p.fk_father ");
sql.append ("where p.birthday > ?");
String sql = sb.toString();

Abgesehen davon, dass der Code in Listing 1 kompakter und besser lesbar ist – ist Listing 2 wenigstens schneller? Nehmen wir den Code aus Listing 1, stecken ihn in eine Klasse Demo und kompilieren diese. Dann untersuchen wir die class-Datei mit dem Disassembler javap, der als Teil jedes JDK ausgeliefert wird: javap -v Demo. Dieser Aufruf gibt unter anderem alle String-Konstanten aus, die in der Klasse verwendet werden. Und siehe da, der gesamte SQL-String steht dort als eine einzige String-Konstante. Der Compiler hat die Teil-Strings zusammengefügt, sodass es zur Laufzeit überhaupt keinen Overhead mehr gibt. Aber wie stehen die Dinge, wenn ein String variable Anteile enthält (Listing 3)? Wäre da ein StringBuilder schneller?

String greet (String name) {
  return "Hallo, " + name;
}

Die Antwort ist wieder nein, und zwar weil der Java-Compiler aus Listing 3 Code produziert, der einen StringBuilder erzeugt und anschließend append() aufruft. Ein Aufruf von javap -c Demo zeigt den entsprechenden Bytecode, und zwar mit so vielen Kommentaren, dass man ihn auch ohne tiefere Kenntnisse der JVM zumindest grob versteht. Es macht also für den kompilierten Code keinen Unterschied, ob man selbst im Quellcode den StringBuilder verwendet oder das dem Compiler überlässt. Wenn das Zusammenbauen von Strings allerdings komplizierter wird und Schleifen oder Bedingungen enthält, dann stoßen die Optimierungen des Java-Compilers zurzeit an Grenzen, und das explizite Verwenden eines StringBuilder kann schneller sein. Ob der Unterschied wichtig ist, hängt natürlich von der Anwendung ab. Und bei sehr String-lastigen Anwendungen, die ihre Ergebnisse in Dateien oder über das Netzwerk streamen, kann die Performance erheblich steigen, wenn man die String-Konkatenation komplett umgeht und die Bestandteile direkt in einen OutputStream schreibt.

„final ist schnell“

Ein Mythos, der sich hartnäckig hält, besagt, dass die Verwendung von final für Parameter und lokale Variablen den Code schneller macht. Das stimmt nicht und stimmte auch noch nie, einfach weil schon die class-Dateien die entsprechende Information nicht mehr enthalten. Man kann das leicht überprüfen, indem man den Code in Listing 4 einmal mit und einmal ohne das Schlüsselwort final kompiliert. Die erzeugten class-Dateien gleichen sich bis zum letzten Bit.

String hallo (final String name) {
  final String text = "Hallo, " + name;
  return text;
}

Es gibt andere Gründe, lokale Variablen final zu deklarieren. So finde ich zum Beispiel, dass es oft die Lesbarkeit des Codes verbessert. Diese Stilfrage ist allerdings immer wieder Gegenstand hitziger Diskussionen, auf die ich hier verzichte – und Auswirkungen auf die Performance hat sie keine.

„Vererbung ist langsam“

Früher – vor der Einführung von Hotspot mit Java 1.5 – gab es die Regel, dass nicht private, nicht finale Methoden niemals inlined (d. h. der Code aus einer Methode an die Aufrufstelle kopiert) wurden. Der Grund war die Polymorphie: Es konnte ja sein, dass Subklassen die Methode anders implementierten. Inlining kann gerade bei kleinen Methoden, die in einer Schleife sehr häufig aufgerufen werden, Programme sehr viel schneller machen. Deshalb wurde mit Hotspot eine Technik namens „spekulative inlining“ eingeführt: Die JVM kann Methoden „auf Verdacht“ inlinen, auch wenn sie weder private noch final sind. Und wenn sich später zeigt, dass Subklassen andere Implementierungen haben, macht Hotspot diese Optimierung wieder rückgängig. Um zu illustrieren, wie gut Hotspot optimiert, selbst wenn Vererbung im Spiel ist, vergleiche ich zwei Versionen einer Counter-Klasse, die Additionen auf einem int-Wert kapselt. Listing 5 zeigt eine minimale Implementierung in einer Klasse, die als final markiert ist.

final class Counter {
  private int value=0;
  void add (int i) {
    value += i;
  }
  int getValue() {
    return value;
  }
}

Listing 6 erzeugt eine Counter-Instanz und addiert in zwei verschachtelten Schleifen Werte. Wenn man (unter Vermeidung der Fallen von Micro-Benchmarks, siehe dazu meine Kolumne im Java Magazin 4.14) die benötigte Zeit misst, kommt man (auf meiner Maschine) auf ungefähr einen Prozessortaktzyklus je Schleifendurchlauf – und zwar unabhängig davon, welche halbwegs aktuelle JVM man verwendet. Das ist ein beeindruckendes Ergebnis, weil jeder Methodenaufruf mehr als einen Taktzyklus benötigt. Hotspot hat also offenbar den Aufruf von counter.add() inlined.

final Counter counter = new Counter();

void doIt() {
  for (int i=0; i<20_000; i++) {
    for (int j=0; j<1_000_000; j++) {
      counter.add (j);
    }
  }
}

Aber es kommt noch besser. Listing 7 zeigt eine deutlich komplexere Counter-Implementierung mit einer abstrakten Basisklasse und einem Interface darüber. Die Basisklasse delegiert jeden Aufruf von add() sogar noch einmal. Und keine der Klassen oder Methoden ist final.

interface ICounter {
  void add (int i);
  int getValue ();
}

abstract class AbstractCounter implements ICounter {
  public void add (int i) {
    _add (I);
  }
  abstract void _add (int i);
}

class Counter2 extends AbstractCounter {
  private int value=0;

  void _add (int i) {
    value += i;
  }
  public int getValue() {
    return value;
  }
}

Zur Performancemessung dieser Implementierung kann man in Listing 6 die erste Zeile ersetzen durch final ICounter counter = new Counter2(). Und das verblüffende Ergebnis ist, dass Hotspot diesen komplexen Code genau so gut optimiert wie den Code aus Listing 5. Auch hier benötigt jeder Schleifendurchlauf etwa einen Prozessortaktzyklus, Hotspot hat also über mehrere Ebenen einer Vererbungshierarchiecode inlined – ohne dass irgendein Teil des Codes als final markiert wäre.

Fazit

Donald Knuth schrieb schon 1974: „Vorschnelle Optimierung ist die Wurzel allen Übels“. Und das ist heute, wo Compiler und JVM sehr gut optimieren, definitiv immer noch wahr. Die Java-Tools optimieren inzwischen extrem wirksam und gezielt dort, wo es Auswirkungen auf die Performance hat. Es ist fast immer eine gute Idee, Code zunächst auf Lesbarkeit zu optimieren. Und wenn man später merkt, dass er zu langsam läuft, und das durch Messungen bestätigt hat, kann man gezielt optimieren – und wieder messen, wie sich die Performance verändert hat. Oft sind dabei „große“, algorithmische Optimierungen wirksamer als „kleine“ an einzelnen Codezeilen. Natürlich sollte man sich nicht blind darauf verlassen, dass Hotspot beliebigen Code schon irgendwie schnell macht. Ich persönlich finde es eine gute Faustregel, nur dann zu optimieren, wenn sich Performancemessungen lohnen. Erstaunlich oft erweisen sich scheinbar nützliche Optimierungen als „doch nicht so wichtig“, wenn man solide Messungen zur Voraussetzung macht.

Geschrieben von
Arno Haase
Arno Haase
Arno Haase ist freiberuflicher Softwareentwickler. Er programmiert Java aus Leidenschaft, arbeitet aber auch als Architekt, Coach und Berater. Seine Schwerpunkte sind modellgetriebene Softwareentwicklung, Persistenzlösungen mit oder ohne relationaler Datenbank und nebenläufige und verteilte Systeme. Arno spricht regelmäßig auf Konferenzen und ist Autor von Fachartikeln und Büchern. Er lebt mit seiner Frau und seinen drei Kindern in Braunschweig.
Kommentare
  1. Gast2015-01-14 10:58:59

    Lieber Arno,

    leider sind deine Aussagen hier nur mit trivialen Beispielen belegt und wer sich darauf zurück zieht muss diese als richtig empfinden:

    String-Konkatenation ist langsam? Doch!
    Es geht nicht selten darum Zeichenketten die erst zur Laufzeit "entstehen" zusammen zu fügen und dann ist String-Konkatenation. Das uns der Compiler die Arbeit abnimmt ist schön aber nur für hart verdrahtete Zeichenketten möglich.

    Vererbung ist langsam? Doch!
    Inlining ist nur ein Aspekt, den man betrachten muss. Ein schönes Beispiel ist im frei erhältlichen Performance Handbuch (über iBooks kostenlos erhältlich) zu sehen - das Problem der Instanziierung von Objekten. Wenn wir eine komplexe Klassenhierarchie haben, so wird jeder Konstruktor jeder Superklasse aufgerufen. Allein die Instanziierung kostet also schon Zeit.
    Nicht ohne Grund gibt es auch Design-Pattern, die sich genau diesem Thema annehmen.

    Schade!

  2. Arno Haase2015-02-19 16:01:17

    Lieber Gast,

    danke für das Feedback! Selbstverständlich kann man Anwendungen schreiben, die viel Zeit mit String-Konkatenation verbringen, besonders mit dynamischen Inhalten. Und wenn Konstruktoren teure Dinge tun, dauert das natürlich umso länger, je tiefer die Vererbungshierarchie ist.

    Mir ging es um konkrete "Performance-Ängste" - eben dass man Konkatenation von String-Literalen mit "+" vermeiden sollte, auch auf Kosten der Lesbarkeit, und dass ein Methodenaufruf durch ein Interface oder eine Basisklasse signifikanten Overhead erzeugt. Ich erlebe häufiger in Projekten, dass diese beiden Punkte als Grund für weniger lesbaren Code angeführt werden, und wollte deshalb mit ihnen aufräumen.

    Bei anderen Performance-Fragen sind wir uns sicher einig, dass man im Einzelfall messen muss, bevor man eine belastbare Aussage bekommt.

    Herzliche Grüße

  3. Bastian Seehaus2015-03-03 10:59:44

    Hi

    Vielen Dank für den Artikel, der hat bei uns im Team ein paar Sachen aufgeklärt und auch eine konstruktive Diskussion angeregt. Zwei Mythen bleiben aber auch für uns in einigen Fällen als Tatsachen bestehen.

    Bzgl. der String-Konkatenation mit variablen Anteilen in Schleifen würde ich unsere Ergebnisse gerne teilen:

    public String useStringConcatenation() {
    String string = "";
    for (Integer i = 0; i < 100000; i++) {
    string += i;
    }
    return string;
    }

    Hier wird 100.000 Mal eine StringBuilder-Instanz erzeugt, dem GC übergeben und je ein zusätzliches toString() aufgerufen gegenüber folgender Variante:

    public String useStringBuilder() {
    final StringBuilder sb = new StringBuilder();
    for (Integer i = 0; i < 100000; i++) {
    sb.append(i);
    }
    return sb.toString();
    }

    Ersteres brauchte bei einem kurzen Test 16 Sekunden, letzteres ca. 10 Millisekunden.

    Da optimiert der Compiler nichts wegoptimiert, das zeigt der Bytecode. Zur Laufzeit wurde anscheinend auch nicht annähernd so gut optimiert, wie der sinnvolle Einsatz des StringBuilders.

    viele Grüße
    Bastian

  4. Rüdiger Möller2017-07-26 22:24:33

    Runtime Polymorphie ist natürlich langsam. Hotspot ist allerdings so clever, daß er rausfindet wenn es z.B. nur eine Implementierung eines interfaces gibt.
    http://insightfullogic.com/2014/May/12/fast-and-megamorphic-what-influences-method-invoca/

Schreibe einen Kommentar

Deine E-Mail-Adresse wird nicht veröffentlicht.