© Excellent backgrounds/Shutterstock.com
Effective Java: Teil 13

Java 8: Stream Performance


Wir wollen uns in diesem Artikel dem Thema Stream Performance zuwenden. Es geht zum einen darum, ob sequenzielle Streams schneller oder langsamer sind als for-Schleifen. Außerdem schauen wir uns an, um wie viel parallele Streams schneller sind als sequenzielle Streams und von welchen Kriterien der Geschwindigkeitsunterschied abhängt.

Nachdem wir uns jetzt schon in einigen Artikeln mit den Streams beschäftigt haben, stellen wir nun die Frage: Wie sieht es eigentlich mit der Performance von Streams aus? Anfangen wollen wir mit dem Performancevergleich von Stream und for-Schleife.

Einfache Funktionalität

Beginnen wir mit einem einfachen Beispiel. Wir wollen in einem int-Array mit 500 000 ganzzahligen Zufallszahlen die größte Zahl suchen. Dies ist der Code für die Lösung mit einer for-Schleife:

int[] a = ints; int e = ints.length; int m = Integer.MIN_VALUE; for (int i = 0; i < e; i++) if (a[i] > m) m = a[i];

und dies der Code für die Lösung mit einem sequenziellen IntStream:

int m = Arrays.stream(ints) .reduce(Integer.MIN_VALUE, Math::max);

Wir haben hier bewusst die reduce()-Methode anstelle der max()-Methode genutzt, weil max() ein OptionalInt zurückliefert. Das erfordert die Konstruktion eines OptionalInt-Objekts, die bei Verwendung von reduce() entfallen kann. Die Lösung mit reduce() ist deshalb eher vergleichbar mit dem Code der for-Schleife als mit einer Lösung via max(). Das Ergebnis unserer Performancemessung ist:

for-Schleife: 0.36 ms seq. Stream: 5.35 ms

Das Ergebnis sieht ziemlich eindeutig aus: Die for-Schleife ist knapp 15-mal schneller als der sequenzielle Stream. Bevor wir dieses Ergebnis zu einem „Streams-sind-total-langsam-Statement“ verallgemeinern wollen, schauen wir uns an, wie das Ganze aussieht, wenn wir als unterliegende Sequenz anstelle eines int-Arrays eine ArrayList<Integer> verwenden. Der Code für die for-Schleife ist nun:

int m = Integer.MIN_VALUE; for (int i : myList) if (i > m) m = i;

und der Code für die sequenzielle Streamlösung ist:

int m = myList.stream() .reduce(Integer.MIN_VALUE, Math::max);

Mit folgendem Ergebnis:

ArrayList, for-Schleife: 6.55 ms ArrayList, seq. Stream: 8.33 ms

Auch hier ist wieder die for-Schleife schneller, aber die Unterschiede sind nicht mehr so signifikant. Der Geschwindigkeitsvorteil beträgt nur noch das 1,27-fache. Woran liegt es? Der Speicherzugriff bei der Iteration über die ArrayList<Integer> ist deutlich aufwändiger als bei einem int-Array. Dies verursacht hohe Grundkosten. Deshalb fällt der Performancevorteil der for-Schleife gegenüber dem sequenziellen Stream geringer aus, denn die teuren Speicherzugriffe überdecken den Unterschied zwischen for-Schleife und Stream.

Neben den Kosten der Iteration spielen aber auch die Kosten der auf die Sequenzelemente anzuwendenden Funktionalität eine Rolle. In unseren vorhergehenden Beispielen haben wir für alle Elemente der unterliegenden Sequenz die Methode Math::max für int aufgerufen. Das ist eine relativ preiswerte Funktionalität. Nach dem JIT-Kompilieren bleibt davon kaum mehr als ein Compare-Befehl im Assembler übrig.

Aufwändige Funktionalität

Wie sieht es aber aus, wenn wir stattdessen eine aufwändige Funktionalität auf jedes Element der unterliegenden Datenstruktur anwenden? Als Beispiel für eine CPU-aufwändige Funktionalität werden wir im Folgenden die Methode slowSin() verwenden. Sie stammt aus der Apache Commons Mathematics Library [1]. Die Methode berechnet den Sinuswert zu ihrem jeweiligen Inputparameter. Das macht sie mit einer Taylor-Approximation, die in diesem Spezialfall auch als Kleinwinkelnäherung [2] bekannt ist. Die Methode steht nicht im Public-API der Commons Math Lib zur Verfügung; sie wird nur intern benutzt, um eine Tabelle mit Sinuswerten zu füllen. Diese Tabelle wird dann von der public-Methode sin() für die Interpolation von Sinuswerten benutzt (Klasse FastMathCalc). Da die slowSin()-Methode eine CPU-aufwändige Approximation durchführt, ist sie für unseren Benchmark interessant. Wir haben den Sourcecode der Library deshalb so geändert, dass wir die slowSin()-Methode public gemacht haben, damit wir sie direkt nutzen können.

Unser Beispiel sieht nun so aus, dass wir für jedes Element des int-Arrays den Sinuswert mithilfe von slowSin() bestimmen und dann das Maximum der Sinuswerte suchen. Die Länge des Arrays haben wir auf 10 000 verkürzt, damit der Test nicht so lange läuft. Der Code für die for-Schleife sieht nun so aus:

int[] a = ints; int e = a.length; double m = Double.MIN_VALUE; for (int i = 0; i < e; i++) { double d = Sine.slowSin(a[i]); if (d > m) m = d; }

Und der Code für die sequenzielle Streamlösung ist:

Arrays.stream(ints) .mapToDouble(Sine::slowSin) .reduce(Double.MIN_VALUE, (i, j) -> Math.max(i, j));

Der Code für den Test mit einer ArrayList<Integer> wurde auch entsprechend angepasst. Die Ergebnisse sind nun:

for-Schleife: 11.82 ms seq. Stream: 12.15 ms ArrayList, for-Schleife: 11.84 ms ArrayList, seq. Stream: 11.85 ms

Zwar ist die for-Schleife immer noch schneller als die Stream​operationen, aber ihr Performancevorteil ist nur noch minimal. Der Grund dafür ist, dass nun die Performance wesentlich durch die Taylor-Approximation bestimmt wird. Der Overhead des sequenziellen Streams im Vergleich zur for-Schleife ist im Verhältnis dazu sehr gering. Zusammenfassend lässt sich sagen, sequenzielle Streams sind langsamer bzw. bestenfalls genauso schnell wie for-Schleifen. Der Performancevorteil der for-Schleife hängt vom Verarbeitungsaufwand pro Element ab. Ist der Aufwand pro Element gering, so ist der Performancevorteil der for-Schleife groß (in unserem Beispiel knapp 15-mal schneller). Ist der Aufwand pro Element groß, so wird der Performancevorteil der for-Schleife insignifikant klein (Beispiel ArrayList mit slowSin(): nur noch 1,0008446-mal schneller, d. h. praktisch genauso schnell). Zum Verarbeitungsaufwand pro Element zählen sowohl die Iterationskosten als auch die Kosten der angewandten Funktionalität, die pro Element anfallen.

Performance von parallelen Streams

Das Ergebnis des vorhergehenden Abschnitts mag erst einmal ernüchternd sein. Eventuell kommt sogar das Gefühl auf, dass man Streams aus Performancegründen besser gar nicht benutzen sollte. Nun waren aber sequenzielle Streams, wie wir sie in den oben gezeigten Benchmarks verwendet haben, auch gar nicht der alleinige Grund dafür, dass man überhaupt ein Stream-Framework für das JDK gebaut hat. Das Stream API wurde auch entwickelt, weil man damit Funktionalität parallel auf die unterliegende Stream Source anwenden kann. Interessant für die Performance von Streams sind also in erster Linie die parallelen Streams. Die Benutzung paralleler Streams erfordert im Vergleich zu sequenziellen Streams nur einen minimalen Programmiermehraufwand, nämlich ein parallel an der richtigen Stelle. Es besteht also die berechtigte Hoffnung, dass die parallelen Streams eine performante Alternative zu for-Schleifen bieten und damit helfen können, Java-Programme zu beschleunigen. Deshalb wollen wir uns nun anschauen, wie sich die Performance von parallelen und sequenziellen Streams unterscheidet.

Algorithmischer Overhead

Ehe wir zu den Benchmarks kommen, wollen wir erst einmal ein paar theoretische Vorüberlegungen darüber anstellen, wie sich die parallele und sequenzielle Verarbeitung von Streams unterscheidet. Wir haben uns bereits im vorhergehenden Artikel [3] angesehen, wie die Verarbeitung von parallelen Streams grundsätzlich funktioniert. Wir haben gesehen, dass die Verarbeitung eines parallelen Streams immer einen gewissen algorithmischen Overhead gegenüber der Verarbeitung eines sequenziellen Streams hat. Dieser Overhead besteht im Wesentlichen aus den folgenden zusätzlichen Aktivitäten:

  • Die ForkJoinTask-Objekte müssen konstruiert werden.

  • Dabei muss die unterliegende Stream Source sukzessive geteilt werden (Splitting).

  • Die ForkJoinTasks müssen auf die Threads des CommonPools verteilt werden (Scheduling).

  • Wenn die ForkJoinTask-Objekte nach der Verarbeitung nicht mehr referenziert werden, muss ihr Speicherplatz vom Garbage Collector wieder freigegeben werden.

Die Idee bei der Benutzung eines parallelen Streams ist nun, dass der algorithmische Overhead durch die Benutzung parall...

Neugierig geworden? Wir haben diese Angebote für dich:

Angebote für Teams

Für Firmen haben wir individuelle Teamlizenzen. Wir erstellen Ihnen gerne ein passendes Angebot.

Das Library-Modell:
IP-Zugang

Das Company-Modell:
Domain-Zugang