In Java 8 wurden die sogenannten Streams eingeführt, die Operationen im funktionalen Stile auf einen Strom von Elementen ermöglichen. Dadurch werden beispielsweise Map- und Reduce Algorithmen auf Java-Collections möglich.

Da unter anderem parallele Streams unterstützt werden - hierbei werden Operationen theoretisch gleichmäßig und transparent auf alle Prozessoren bzw. Kerne verteilt - wollte ich in einem kleinen Benchmark die tatsächliche Geschwindigkeit der Streams ermitteln.

Benchmarks in Java

Aussagekräftige Benchmarks bzw. Microbenchmarks, also diejenigen, die sich im Millisekundenbereich abspielen, sind in Java schwierig. Sehr schwierig. Viele, vor allem ältere Benchmarks basieren in der Regel auf folgenden drei Schritten:

  1. Die Ausgabe von System.currentTimeMillis bzw. System.nanoTime merken
  2. Methode ausführen
  3. Die aktuelle Ausgabe von System.currentTimeMillis bzw. System.nanoTime merken und die Differenz aus dem ersten Schritt berechnen.

Und schon weiß man, wie lange der Aufruf tatsächlich dauerte, um ihn beispielsweise mit anderen Implementierungen zu vergleichen. Tja, wenn alles doch so einfach wäre. Ist es leider nicht. Auf Grund folgender Umstände könnte der nächste Aufruf ganz andere Ergebnisse liefern:

  • Abhängig vom Betriebssystem und der verwendeten Hardware können System.currentTimeMillis und System.nanoTime unterschiedliche Ergebnisse liefern.
  • Die JVM ist intelligent und kann sehr viele Dinge während der Laufzeit optimieren. Inlining, nur um ein Stichwort zu nennen.
  • Warmup der JVM: Zunächst interpretiert die JVM den Bytecode einer Class-Datei und übersetzt ihn (durch den JIT-Compiler) irgendwann in Maschinencode. Somit misst man mit etwas Pech die Laufzeit des interpretierten Codes, der selbstverständlich um ein Vielfaches langsamer ist.
  • Nicht unterschätzen sollte man zudem den Garbage Collector. Dieser kann durchaus während eines Benchmarks seine Aufräumaktion starten und dadurch die Laufzeit eines Benchmarks signifikant beeinflussen.

Und um genau diese Probleme in Angriff zu nehmen und um aussagekräftige und vergleichbare Microbenchmarks zu erstellen, wurde JMH ins Leben gerufen.

Benchmarks mit JMH

Bei JMH oder Java Microbenchmark Harness handelt es sich um ein Framework, um korrekte, aussagekräftige und vergleichbare Microbenchmarks in Java zu erstellen.

Am einfachsten lernt man JMH-Benchmarks zu schreiben, in dem man sich die zahlreichen und ausführlich kommentierten Beispiele anschaut. Ein Blick in diese zeigt, dass ein Benchmark generell mit Hilfe von folgenden drei Annotationen beschrieben werden kann:

  • @Benchmark: Wird an eine Methode geschrieben, aus der JMH während der Kompilierung automatisch entsprechenden Benchmarkcode generiert.
  • @BenchmarkMode: Der Benchmarkmodus legt die Art der Ergebnisse fest. Beispielsweise kann die Durchsatzrate (Throughput) oder die durchschnittliche Dauer (AverageTime) einer Operation berechnet werden.
  • @OutputTimeUnit: Die standardmäßige Zeiteinheit, wie Sekunden, Millisekunden oder Nanosekunden.

Um unter anderem die oben genannten Probleme beim Erstellen und Ausführen von Microbenchmarks unter Java in Angriff zu nehmen, stellt JMH Warmup- und Messphasen bereit, die in ihrer Iterationsanzahl konfigurierbar sind. Als Beispiel passen wir den Aufruf des HelloWorld Benchmarks wie folgt an: java -jar target/benchmarks.jar JMHSample_01_HelloWorld -wi 5 -i 5 -f 1

Hierbei durchläuft der Benchmark erst fünf Warmup-Phasen (-wi 5) bevor fünf Messphasen (-i 5) nacheinander in einer separaten JVM (-f 1) ausgeführt werden. Die Ergebnisse aller Messphasen addiert JMH und bildet automatisch Durchschnittsergebnisse inklusive statistischer Abweichungen.

Streams, Parallele Streams und klassische Programmierung

Nach etwas Theorie kommen wir zum spannenden Teil des Artikels. Wie zu Beginn geschrieben, wollte ich herausfinden, ob Streams einen performancetechnischen Vorteil gegenüber einer klassischen Programmierung in Java bringen. Dazu habe ich folgenden Anwendungsfall auf drei verschiedene Arten entwickelt, klassisches Java, Streams und ParallelStreams:

Voraussetzung: Eine Liste mit fünf Millionen zufälligen und 32 Zeichen langen Strings

  1. Konvertierung der Strings in die Klein-Schreibweise (toLowerCase)
  2. Suche nach allen Strings die mit a beginnen
  3. Ersetze in diesen Strings alle as durch z
  4. Sortiere die Liste alphabetisch
  5. Erstelle eine Zeichenkette, in der alle Elemente aus der Liste durch ein Komma getrennt sind
@Benchmark
public String stream() {
    return strings.stream()
            .map(String::toLowerCase)
            .filter(string -> string.startsWith("a"))
            .map(string -> string.replaceAll("a", "z"))
            .sorted()
            .collect(Collectors.joining(","));
}

@Benchmark
public String parallelStream() {
    return strings.parallelStream()
            .map(String::toLowerCase)
            .filter(string -> string.startsWith("a"))
            .map(string -> string.replaceAll("a", "z"))
            .sorted()
            .collect(Collectors.joining(","));
}

@Benchmark
public String classic() {
    List<String> filtered = new ArrayList<>(strings.size());

    for (String string : strings) {
        if (string.toLowerCase().startsWith("a")) {
            filtered.add(string.replaceAll("a", "z"));
        }
    }

    Collections.sort(filtered);

    StringBuilder builder = new StringBuilder();
    builder.append(filtered.get(0));

    for (int i = 1; i < filtered.size(); i++) {
        builder.append(",");
        builder.append(filtered.get(i));
    }

    return builder.toString();
}

Beim Anschauen der drei Implementierungen fällt deutlich die Komplexität der klassischen Lösung auf. Mit Hilfe der funktionalen Programmierung kann der gleiche Algorithmus viel einfacher und lesbarer erstellt werden. Man benötigt keine speziellen Hilfsklassen, sondern kann alleine auf Basis der Stream-Methoden arbeiten.

Um nun die Performance der drei Implementierungen zu überprüfen, habe ich die Benchmarks in fünf Warmup- und zehn Messphasen in einer geforkten JVM ausgeführt. Folgende relevante Soft- und Hardware kam dabei zum Einsatz:

  • Intel(R) Xeon(R) CPU E3-1230 v3 @ 3.30GHz (Acht Kerne)
  • Linux 3.17.1-1-ARCH #1 SMP PREEMPT Wed Oct 15 15:04:35 CEST 2014 x86_64 GNU/Linux
  • Java HotSpot(TM) 64-Bit Server VM (build 25.25-b02, mixed mode) java version "1.8.0_25"

Nun zu den Ergebnissen:

# Run complete. Total time: 00:01:31

Benchmark                             Mode  Samples    Score    Error  Units
d.w.StreamBenchmark.classic           avgt       20  521,227 ±  7,928  ms/op
d.w.StreamBenchmark.parallelStream    avgt       20  144,543 ±  1,800  ms/op
d.w.StreamBenchmark.stream            avgt       20  536,981 ± 15,805  ms/op

Die klassische sowie die Stream-Lösung liegen mit knapp über 500ms pro Operation in etwa gleich auf, wenn man die höhere Abweichung der letzteren berücksichtigt. Zwar ist die Implementierung, die auf parallelStream aufsetzt, mit Abstand die Schnellste, allerdings auf den ersten Blick bei weitem nicht so schnell wie erwartet: Etwa 3,6 Mal schneller als die beiden anderen Lösungen. Und das bei einem Prozessor mit acht Kernen.

Fazit

Ob man nun klassisch entwickelt oder bei Java 8 auf Streams setzt, ist aus performancetechnischer Sicht nicht unbedingt relevant. Geht es um die Lesbarkeit des Codes, sollte man sich in Zukunft natürlich ausführlicher mit Streams befassen.

Selbstverständlich kann man bei einer parallelen Lösung, die auf acht Kernen ausgeführt wird, keinen achtfachen Geschwindigkeitszuwachs erwarten. Dieser ist auch generell viel geringer als erwartet. Wenn man das Amdahlsches Gesetz hinzuzieht, stellt man fest, dass der Geschwindigkeitszuwachs im großen Maße vom sequentiellen Anteil des Problems beschränkt wird. Vergleicht man den durch den Benchmark ermittelten SpeedUp von 3,65 mit der im Artikel verlinkten Grafik, kommt man auf einen parallelen Anteil von etwas über 83%.

Der Wert ist sicherlich nicht so schlecht, allerdings verbesserungswürdig. Mit einer eigenen nebenläufigen Implementierung des Anwendungsfalls, ist ein paralleler Anteil von über 90% nicht unwahrscheinlich. Allerdings zu welchem Preis? Möchte ich mehrere Stunden oder Tage an einer hochkomplexen Implementierung schrauben, die möglicherweise performanter, aber auf der anderen Seite für andere nicht unbedingt nachvollziehbar ist? Oder möchte ich einfach stream durch parallelStream ersetzen? Möglicherweise ist das Letzere die Antwort, möglicherweise aber auch die berühmte Aussage "Kommt darauf an". Das muss jeder für sich selbst entscheiden.

GitHub

Wie immer, findet man den vollständigen Quellcode in meinem GitHub-Repository.

blogroll
tags