Angelika Langer - Training & Consulting
HOME | COURSES | TALKS | ARTICLES | GENERICS | LAMBDAS | IOSTREAMS | ABOUT | CONTACT | Twitter | Lanyrd | Linkedin
 
HOME 

  OVERVIEW

  BY TOPIC
    JAVA
    C++

  BY COLUMN
    EFFECTIVE JAVA
    EFFECTIVE STDLIB

  BY MAGAZINE
    JAVA MAGAZIN
    JAVA SPEKTRUM
    JAVA WORLD
    JAVA SOLUTIONS
    JAVA PRO
    C++ REPORT
    CUJ
    OTHER
 

GENERICS 
LAMBDAS 
IOSTREAMS 
ABOUT 
CONTACT 
Effective Java

Effective Java
Java 8
Das Performance-Modell der Streams
 

Java Magazin, September 2015
Klaus Kreft & Angelika Langer

Dies ist die Überarbeitung eines Manuskripts für einen Artikel, der im Rahmen einer Kolumne mit dem Titel "Effective Java" im Java Magazin erschienen ist.  Die übrigen Artikel dieser Serie sind ebenfalls verfügbar ( click here ).

 

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

Performance von sequentiellen Streams

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 Performance-Vergleich 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 sequentiellen  Int Stream :
 
 

int m = Arrays.stream(ints)

              .reduce(Integer.MIN_VALUE, Math::max);
 
 

Wir haben hier bewusst die  reduce() Methode anstelle der  max() Methode benutzt, 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 es eine Lösung mit  max() wäre. 
 
 

Das Ergebnis unserer Performance-Messung 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 sequentielle Stream.  Bevor wir dieses Ergebnis zu einem Streams-sind-total-langsam 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 sequentielle Stream Lösung ist:
 
 

int m = myList.stream()

              .reduce(Integer.MIN_VALUE, Math::max);
 
 

Das Ergebnis ist:

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 Performance-Vorteil der  for -Schleife gegenüber dem sequentiellen 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-Compilieren 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 (/ ACML /).  Die Methode berechnet den Sinuswert zu ihrem jeweiligen Inputparameter.  Das macht sie mit einer Taylor-Approximation, die in diesem Spezialfall auch als Kleinwinkelnäherung (/ WKKW /) 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 Sinus-Werten benutzt (siehe Klasse:  FastMathCalc ).  Da die  slowSin() Methode eine CPU-aufwändige Approximation durchführt, ist sie für unseren Benchmark interessant.  Wir haben den Source Code der Library deshalb so geändert, dass wir die  slowSin() Methode  public gemacht haben, damit wir sie direkt nutzten können.
 
 

Unser Beispiel sieht nun so aus, dass wir für jedes Element des  int -Arrays den Sinus-Wert mit Hilfe 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 sequentielle Stream-Lö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 Performance-Vorteil ist nur noch minimal.  Der Grund dafür ist, dass nun die Performance wesentlich durch die Taylor-Approximation bestimmt wird.  Der Overhead des sequentiellen Streams  im Vergleich zur  for -Schleife ist im Verhältnis dazu sehr gering.
 
 

Zusammenfassend lässt sich sagen, sequentielle Streams sind langsamer bzw. bestenfalls genauso schnell wie  for -Schleifen.  Der Performance-Vorteil der  for -Schleife hängt vom Verarbeitungsaufwand pro Element ab.  Ist der Aufwand pro Element gering, so ist der Performance-Vorteil der  for -Schleife groß (in unserem Beispiel knapp 15-mal schneller).  Ist der Aufwand pro Element groß, so wird der Performance-Vorteil der  for -Schleife insignifikant klein (Beispiel  Arr a yList 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 benutzten sollte.  Nun waren aber sequentielle Streams, wie wir sie in den oben gezeigten Benchmarks verwendet haben, auch gar nicht der alleinige Grund dafür, dass man überhaupt einen Stream-Framework für den 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 sequentiellen Streams nur einen minimalen Programmier-Mehraufwand, 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 Streams und sequentiellen 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 sequentielle Verarbeitung von Streams unterscheidet.  Wir haben uns bereits im vorhergehenden Artikel / KLPA / angesehen, wie die Verarbeitung von parallelen Streams grundsätzlich funktioniert.

 
 

Dort haben wir gesehen, dass die Verarbeitung eines parallelen Streams immer einen gewissen algorithmischen Overhead gegenüber der eines sequentiellen Streams hat.  Dieser Overhead besteht im wesentlich 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  ForkJoinTask s müssen auf die Threads des  CommonPool s 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 paralleler Threads auf weiteren CPU-Cores der unterliegende Rechnerplattform so stark überkompensiert wird, dass die Performance des parallelen Streams deutlich besser ist als die des sequentiellen Streams.  Dies impliziert, dass die Anzahl der CPU-Cores eine entscheidende Rolle für die Performance von parallelen Streams spielt.  Wir verwenden für den Benchmarks dieses Artikels eine Dual-Core-CPU (siehe auch Text-Box an Ende des Artikels: Details zu den Benchmarks).  Bei Plattformen mit mehr CPU-Cores wird die Performancesteigerung beim Vergleich sequentieller Stream / paralleler Stream in der Regel größer ausfallen, als in unseren Benchmarks. 
 
 

Eine Sonderrolle nimmt die Benutzung paralleler Streams auf Single-Core-Plattformen ein.  Auf Grund des algorithmischen Overheads dürfte die Performance von parallelen Streams hier im Allgemeinen schlechter sein als die Performance von sequentiellen Streams.  Wir haben es aber nicht getestet, da uns keine Single-Core-Plattform zur Verfügung stand.

Einfache Funktionalität

Schauen wir uns an, welche Benchmark-Ergebnisse wir für unser Ausgangsproblem, die Suche nach der größten Zahl aus  500 . 000 ganzzahligen Zufallszahlen, erhalten.  Für ein  int -Array  ints sieht der Code für den sequentiellen Stream so aus:

 
 

int m = Arrays.stream(ints)

              .reduce(Integer.MIN_VALUE, Math::max);
 
 

und für den parallelen Stream so:
 
 

int m = Arrays.stream(ints).parallel()

              .reduce(Integer.MIN_VALUE, Math::max);
 
 

Und bei einer Collection sieht der Code so aus:
 
 

int m = myCollection.stream()

                    .reduce(Integer.MIN_VALUE, Math::max);
 
 

int m = myCollection.parallelStream()

                    .reduce(Integer.MIN_VALUE, Math::max);
 
 

Wir haben diesmal nicht nur die  Array List sondern auch weitere Collection-Typen mit in den Test aufgenommen.
 
 

Die Ergebnisse sind:
 
 

               sequentiell      parallel       seq./par.

int-Array        5.35 ms        3.35 ms         1.60

ArrayList        8.33 ms         6. 33 ms        1.32

LinkedList      12.74 ms       19.57 ms         0.65

HashSet         20.76 ms        16.01 ms        1.30

T reeSet           19.79 ms        15.49  ms        1.28
 
 
 
 

Die Ergebnisse lassen sich, was die Performancesteigerung angeht, grob in drei Kategorien einteilen:

ganz okay ( int -Array),

katastrophal ( LinkedList ),

nicht wirklich katastrophal, aber auch nicht unbedingt das, was wir uns erhofft haben (alle anderen Collections).
 
 

Fangen wir mit dem Punkt "katastrophal" an.  Warum ist das Ergebnis für die  LinkedList so schlecht?  Statt einer Steigerung haben wir beim Wechsel vom sequentiellen Stream auf einen parallelen Stream eine Verschlechterung bekommen.  Das heißt, da arbeiten jetzt zwei Cores an dem Problem und es dauert 1,46-mal länger.  Das klingt wie ein schlechter Witz aus dem Handbuch für Software-Tuning.  Schauen wir noch mal auf den weiter oben aufgelisteten algorithmischen Overhead bei parallelen Streams gegenüber sequentiellen Streams.  Ein Punkt ist das Splitting , also das sukzessive Teilen der unterliegenden Stream-Source in halbwegs gleichgroße Teile.  Für die meisten Datenstrukturen ist das nicht besonders aufwändig, aber bei der  LinkedList kostet es ziemlich viel.  Um die Mitte der  LinkedList zu finden und den ersten Split machen zu können, muss in unserem Fall (bei einer Sequenz mit 500.000 Elementen) über 250.000  Node s der  LinkedList iteriert werden.  Das dauert eine Weile.  Im Vergleich dazu lässt sich für die  ArrayList ohne großen Aufwand ausrechnen, dass bei Index 250.000 die zweite Hälfte beginnt.  Das heißt, im Vergleich zu anderen Collections ist die  LinkedList schecht teilbar ( splittable ).  Das führt zu ihrer schlechten Performance in parallelen Streams.
 
 

Kommen wir nun zu Punkt "nicht-unbedingt-das-was-wir-uns-erhofft-haben".  Steigerungen von rund 130% beim Wechsel vom sequentiellen Stream zum parallelen Stream (auf einer Dual-Core-Plattform) sind ein bisschen wenig und lassen sich auch nicht allein durch den algorithmischen Overhead des parallelen Streams erklären.  Der Grund ist vielmehr, dass die Funktionalität, die auf jedes Element angewendet wird, in unserem Benchmark nicht besonders CPU-intensiv ist.  Wir haben es oben schon einmal erwähnt: nach der JIT-Compilation bleibt von  Math::max nicht viel mehr als ein Compare-Befehl im Assembler übrig.  Nun ist der Vorteil eines parallelen Streams im Vergleich zum sequentiellen Stream, dass er mehr CPU-Power nutzen kann.  Dies hilft aber nicht viel, wenn die CPU nicht der alleinige oder wesentliche Engpass ist.  Vielmehr wird beim parallelen Stream nun der Speicherzugriff auf die Collection-Elemente zum Bottleneck, denn es müssen (auf einer Dual-Core-Plattform) doppelt soviel Daten aus dem Speicher gelesen werden, um beide Cores zu versorgen.  Das heißt, wenn die Verarbeitung der Elemente wenig CPU-intensiv ist, dominieren die Speicherzugriffszeiten die Performance.
 
 

Warum ist die Performance-Steigerung dann beim  int -Array besser?  Das liegt einfach daran, dass die Datenorganisation besser passt.  Ein Teil des  int -Arrays passt in den CPU-Cache und die Daten können der Reihe nach vom Cache direkt zum CPU-Core wandern.  Das ist bei den Collections anders, da sie nur boxed-Typen, also in unserem Fall  Integer s, speichern können.  Es hilft wenig, wenn zum Beispiel bei einer  ArrayList ein Teil des unterliegenden Arrays im CPU-Cache liegt.  Das Array enthält ja nur die Referenzen auf die  Integer Objekte.  Es ist deshalb stets ein weiterer Speicherzugriff nötig, um den  int -Wert aus dem  Integer Objekt zu lesen.  Dieser aufwändige Speicherzugriff führt dazu, dass die Performance-Steigerung bei den Collections nicht so hoch ausfällt wie bei dem  int -Array.
 
 

Wie gut Speicherzugriffe bei parallelen Streams skalieren, ist übrigens stark plattform-abhängig.  Belastbare Ergebnisse kann man nur durch Nachmessen mit einem Benchmark bekommen.  Grundsätzlich kann man aber sagen, dass Speicherzugriffe bei parallelen Streams schnell zum Bottleneck werden können, wenn die Funktionalität, die auf jedes Element angewendet wird, nicht besonders CPU-intensiv ist.  Was uns zu unserem nächsten Thema bringt.

Aufwändige Funktionalität

Doug Lea hat bei einer Diskussion im OpenJDK-Lambda-Libs-Form erläutert, wann man eine Performance-Steigerungen durch die Benutzung paralleler Stream erreichen kann (/ CAPS /):  „…, the easy guidance is: If you have a lot of data, or very costly per-element computations, the best practice is to use  parallel() . Otherwise, feel free to experiment with it, but don't expect any miracles.

 
 

"A lot of data" lässt sich mit hinreichend großer Stream-Source übersetzten.  Diese Anforderung ergibt sich einfach daraus, dass der algorithmische Overhead der parallelen Verarbeitung bei einer zu kleinen Stream-Source gar nicht kompensiert werden kann.

Wenn man sich unseren vorhergehenden Benchmark ansieht, so hatten wird dort „a lot of data”; immerhin war die unterliegende Stream-Source 500.000 Elemente groß.  Was wir nicht hatten, waren „very costly per-element computations“;  der Vergleich von zwei  int -Werten kostet halt nicht viel.  Deshalb wollen wir unseren Test jetzt noch mal mit  slowSin() und 10.000 Elementen in der Stream-Source durchführen.   slowSin() ist eine solche „costly per-element computation“, denn der zugrunde liegende Algorithmus führt CPU-intensive Berechnungen aus, ohne weitere Daten aus dem Speicher zu benötigen.  Während der Berechungen sind die Daten idealerweise in Registern des CPU-Cores, so dass der Core im Wesentlichen ohne Zugriffe auf Speicher oder Caches arbeiten kann.
 
 

Der Code für den Benchmark sieht im Falle des  int -Array so aus:
 
 

Arrays.stream(ints)

      .mapToDouble(Sine::slowSin)

      .reduce(Double.MIN_VALUE, (i, j) -> Math.max(i, j);
 
 

Arrays.stream(ints). parallel()

      .mapToDouble(Sine::slowSin)

      .reduce(Double.MIN_VALUE, (i, j) -> Math.max(i, j);
 
 

und für die Collections so:

myCollection. stream()

            .mapToDouble(Sine::slowSin)

            .reduce(Double.MIN_VALUE, (i, j) -> Math.max(i, j);
 
 

myCollection. parallelStream()

            .mapToDouble(Sine::slowSin)

            .reduce(Double.MIN_VALUE, (i, j) -> Math.max(i, j);
 
 
 
 

Die Ergebnisse auf unserer Plattform sind:
 
 

               sequentiell       parallel       seq./par.

int-Array       1 0 . 8 1 ms        6. 0 3 ms         1. 79

ArrayList       10.97 ms         6. 1 0 ms        1. 80

LinkedList      11.15 ms       6 . 2 5 ms          1 . 78

HashSet         11.15 ms       6. 15 ms        1. 81

TreeSet         11.14 ms       6.3 0 ms        1. 7 7
 
 
 
 

Nach dem oben Gesagten sollten die Zahlen nun nicht überraschen. Die Steigerung beim Übergang vom sequentiellen Stream zum parallelen Stream beträgt unabhängig vom Typ der unterliegenden Stream-Source rund 180% (auf unserer Dual-Core-Plattform).  Die in  slowSin() durchzuführende CPU-intensive Berechung ist jetzt der Aspekt, der das Benchmark-Ergebnis dominiert.
 
 

Die 20%, die zu den maximal möglichen 200% fehlen (also zur Verdopplung der Performance bei Dual-Core), sind dem algorithmischen Overhead der parallelen Stream-Verarbeitung und möglichen plattform-spezifischen Ressourcen-Engpässen geschuldet.  Mit anderen Worten, auf unserer Plattform haben wir die Performance-Steigerung mit diesem Testfall ausgereizt.   

Funktionalität mit veränderlichem Zustand

Bekanntermaßen ist veränderlicher Zustand ( mutable state ) bei paralleler Verarbeitung eine Herausforderung, die sich meist nur mit gewissen Performance-Einschränkungen meistern lässt.  Dies gilt auch für parallele Streams. 

 
 

Bevor wir die konkreten Performance-Einschränkungen diskutieren wollen, schauen wir uns an, welche Stream-Operationen überhaupt veränderlichen Zustand zulassen.  Diese Operationen lassen sich grob folgendermaßen kategorisieren:

zustandsbehaftete, intermediäre Operationen ( stateful intermediate operations ), z.B.:  distinct() ,

terminale Operationen ( terminal operations ), die zustandsbehaftete Operationen als Parameter akzeptieren, z.B.:  forEach() ,

und je nachdem, ob man sie als einen Spezialfall der vorhergehenden Kategorie oder als eigene Kategorie für sich betrachtet, die Methode  collect() .
 
 

Für unsere Performance-Betrachtungen wollen wir uns im Detail nur mit der zustandsbehaftete, intermediäre Operation  distinct() befassen.  Alles Weitere würde den Rahmen des Artikels sprengen.  Aber vieles von dem, was wir uns bei  distinct() ansehen werden, lässt sich auf die anderen Operationen übertragen.

Implementierung von  distinct()

Rekapitulieren wir kurz, was  distinct() überhaupt macht.  Es eliminiert alle Element aus dem Input-Stream, die (im Sinne von  equals() ) mehrfach vorkommen.  Der veränderliche Zustand der Operation sind all diejenigen Elemente, die bereits im Output-Stream gelandet sind.  Mit diesen Elementen muss das aktuelle Element auf Gleichheit geprüft werden.

 
 

Die Implementierung von  distinct() für den sequentiellen Stream erledigt diese Prüfung auf Gleichheit mit den vorhergehenden Elementen, indem sie einen  HashSet anlegt, in den sie jedes Element des Input-Streams per  add() -Methode einfügt.   Der Return-Wert der  add() -Methode spiegelt das Ergebnis der Prüfung wider, denn sie fügt das Element nur ein, wenn es noch nicht im HashSet enthalten ist; sonst gibt sie  false zurück. 
 
 

Man kann sich die Implementierung von  distinct() im sequentiellen Fall in etwa so vorstellen: 
 
 

// Vorsicht!  Pseudocode! Nicht zur Nachahmung empfohlen!

Set<Integer> dSet = new HashSet<>();

myStream.filter(dSet::add)

        . ... // weitere Stream-Operationen
 
 

Aber Vorsicht, es wäre falsch, solchen Code tatsächlich hinzuschreiben.  Wir haben den Code gezeigt, um das Prinzip der internen Implementierung von  distinct() zu illustrieren.  Die  filter() Operation verlangt in ihrer Javadoc, dass die Funktionalität, die ihr als Parameter übergeben wird, nicht zustandsbehaftet sein darf (siehe / FILT /).  Gegen diese Anforderung wird hier verstoßen. 
 
 

Bevor wir uns die  distinct() Implementierung für den parallelen Fall ansehen, werfen wir einen kurzen Blick in die Javadoc (/ DIST /):

Preserving stability for distinct() in parallel pipelines is relatively expensive (requires that the operation act as a full barrier, with substantial buffering overhead), and stability is often not needed. (…) removing the ordering constraint with unordered() may result in significantly more efficient execution for distinct() in parallel pipelines, if the semantics of your situation permit.
 
 

Was bedeutet das?  Für den parallelen Fall hat  distinct() zwei Implementierungen.  Die eine funktioniert so, dass sie einen geordneten ( ordered ) Stream erzeugt; bei der anderen Implementierung ist der Output-Stream ungeordnet.  "Geordneter Stream" bedeutet im Fall von  distinct() , dass die Element im Output-Stream die gleiche Reihenfolge wie im Input-Stream haben; von den mehrfach vorkommenden Elementen ist aber nur das erste in den Output-Stream gewandert, weil  distinct() die Duplikate wegfiltert.  Auf diese Weise sind die Elemente im Output-Stream genauso angeordnet wie bei der sequentiellen Ausführung, obwohl das ganze parallel abgearbeitet worden ist. 
 
 

Man kann sich vorstellen, dass ein Algorithmus, der die Reihenfolgen erhält, weniger performant ist, als ein Algorithmus, der dies nicht tut.  Deshalb gibt es für  distinct() beide Möglichkeiten.  Wenn es auf die Reihenfolge nicht ankommt, kann man auf dem Stream  unordered() aufrufen; dann wird der performantere Algorithmus verwendet.
 
 

Den Algorithmus für den geordneten, parallelen Stream kann man sich vom Prinzip her so vorstellen:
 
 

// Vorsicht!  Pseudocode! Nicht zur Nachahmung empfohlen!

myParallelStream.collect(Collectors.toCollection(LinkedHashSet::new))

                .parallelStream()

                . ... // weitere Stream-Operationen
 
 

Auch diesen Code sollte man nicht hinschreiben.  Er dient hier nur zur Illustration, um den Overhead der parallelen Ausführung von  distinct() im Vergleich zur sequentiellen Ausführung zu diskutieren. 
 
 

Die Elemente des Input-Streams werden parallel in einem  LinkedHashSet gesammelt.  Dabei werden mehrfache vorkommende Elemente eliminiert (der  HashSet -Aspekt von LinkedHashSet ) und die Reihenfolge bleibt erhalten (der  Linked -Aspekt von LinkedHashSet ).  Danach wird aus dem resultierenden  LinkedHashSet wieder ein paralleler Stream gemacht (Aufruf von  parallelStream() ).  Auf diesem können dann weitere Stream-Operationen aufgerufen werden.  Der Overhead der parallelen Ausführung von  distinct() hat mehrere Gründe:

Die  add() Methode des  LinkedHashSet ist etwas teuerer als die des einfachen  HashSets .

Der parallele  collect() mit dem  toCollection() -Collector hat einen erheblichen algorithmischen Overhead (sehen wir in einem zukünftigen Artikel).

Die Verarbeitung wird dadurch unterbrochen, dass  collect() eine terminale Operation ist, und muss danach in einem neuen Stream auf dem Ergebnis des  collect() s (Aufruf:  parallelStream() ) wieder neu gestartet werden.  In der Javadoc wird dies als „full barrier with substantial buffering overhead“ beschrieben.
 
 

Sehen wir uns nun noch den Algorithmus für den ungeordneten, parallelen Stream an:
 
 

// Vorsicht!  Pseudocode! Nicht zur Nachahmung empfohlen!

myParallelStream.collect(Collectors.toConcurrentMap(i->i,

                                                    i->Boolean.TRUE,

                                                   (oldV,newV)->oldV))

                                   .keySet()

                                   .parallelStream()

                                   . ... // weitere Stream-Operationen
 
 

Hier fungiert eine  ConcurrentHashMap als Full Barrier.  Dabei wird nur ihr  keySet() benutzt wird; die assoziierten Werte sind nicht relevant; sie sind alle  Boolean.TRUE .  Das haben wir so gemacht, weil man hier eigentlich einen  ConcurrentHashSet bräuchte, aber den gibt es im JDK nicht.
 
 

Im Unterschied zum vorhergehenden Algorithmus hat der  collect() mit dem  toConcurrentMap() -Collector keinen deterministischen algorithmischen Overhead.  Der Overhead entsteht hier vielmehr durch die Kollisionen der konkurrierend auf die  ConcurrentHashMap zugreifenden Threads des parallelen Streams.  Je mehr Threads konkurrierend zugreifen, desto öfter scheitern die Compare-and-Swap(CAS)-Operationen in den Methoden der  ConcurrentHashMap .  Das wiederum erhöht die Zahl der Retry-Versuche und führt zu noch mehr Compare-and-Swap(CAS)-Operationen.
 
 

Auch hier dient der oben gezeigt Code nur zur Illustration.  Der echte Code in der Stream-Implementierung ist nämlich noch etwas komplizierter.  Da die  ConcurrentHashMap nicht mit  null als Key umgehen kann, Streams aber im Allgemeinen  null enthalten können, muss dieser Sonderfall auch noch behandelt werden.  Wir haben diesen Aspekt hier nicht betrachtet, weil er nicht performance-relevant ist.

Benchmark Tests

Fangen wir mit einem Benchmark-Test mit einfacher Funktionalität an.  Auf ein  Integer -Array mit 100.000 Elementen, in dem jede Zahl von 0 bis 49.000 zweimal vorkommt, wenden wir die folgende Funktionalität an:

 
 

// sequentiell

Arrays.stream(integers).distinct().count();
 
 

// parallel geordnet

Arrays.stream(integers). parallel() .distinct().count();
 
 

// parallel ungeordnet

Arrays.stream(integers). parallel().unordered() .distinct().count();
 
 
 
 

Die Ergebnisse sind:
 
 

         sequentiell       parallel geordnet       parallel ungeordnet

           6.39 ms               34.09 ms                     9.1 ms
 
 

Nach dem bisher Gesagten sind die Ergebnisse vielleicht nicht so überraschend;  distinct() ist zustandsbehaftet und das ist schlecht für die Performance im parallelen Fall. In der Tat sind dann auch beide parallelen Lösungen auf unserem Test-System langsamer als die sequentielle Lösung.  Die Lösung für parallel und geordnet ist sogar 5,33-mal langsamer als die sequentielle.
 
 

Jetzt sollte man aber die Kombination "zustandsbehafte Operationen + paralleler Stream" nicht gleich abschreiben, was die Performance angeht.  Auch hier gilt wieder: aufwändige Verarbeitungsfunktionalität, wie unser CPU-intensives  slowSin() , liefert ganz andere Ergebnisse.  Als Test wollen wir auf ein  Integer -Array mit 10.000 Elementen, das die Zahlen 0 bis 9.999 enthält, die folgende Funktionalität anwenden:
 
 

Arrays.stream(newIntegers) //.parallel().unordered()

      .map(i -> new Double(2200* Sine.slowSin(i * 0.001)).intValue())

      .distinct()

      .count();
 
 
 
 

Die Ergebnisse sind nun:
 
 

         sequentiell       parallel geordnet       parallel ungeordnet

           11 . 5 9 ms                6 . 8 3 ms                      6 . 8 1 ms
 
 

Jetzt haben wir wieder eine deutliche Performance-Steigerung (~170%) bei der parallelen Verarbeitung, unabhängig davon, ob der parallele Stream geordnet oder ungeordnet ist. Die Performance-Steigerung ist aber nicht ganz so groß wie weiter oben im Test der aufwändigen Verarbeitungsfunktionalität bei einer zustandslosen Stream-Operation.  Dort war die Steigerung rund 180%.  Die zustandsbehafteten Operationen haben halt bei der parallelen Verarbeitung spürbare Performance-Einschränkungen.

distinct() von primitiven Streams

Bei einem Vortrag auf der JAX zu diesem Thema kam die Frage auf, ob der Code oben nicht folgendermaßen geändert werden sollte, um eine bessere Performance zu erzielen:

 
 

Arrays.stream(newIntegers) //.parallel().unordered()

      . mapToInt (i -> new Double(2200* Sine.slowSin(i * 0.001)).intValue())

      .distinct()

      .count();
 
 

Die Frage ist: wäre es nicht besser,  mapToInt() anstelle von  map() zu verwenden?  Die allgemeine Regel ist ja, dass im Fall von  int / Integer die Performance des   IntStream s besser ist als die des  Stream<Integer> .  Aber dieses Beispiel ist eine Ausnahme von der Regel, wegen der inperformanten Implementierung der  distinct() Operation des  IntStream s.  Die Implementierung findet sich in der (package protected) Klasse  java.util.stream.IntPipeline .   IntStream ist nur das Interface.  Die Implementierung (einschließlich Kommentar) sieht so aus:
 
 

public final IntStream distinct() {

   // While functional and quick to implement, this approach is not very efficient.

   // An efficient version requires an int-specific map/set implementation.

   return boxed().distinct().mapToInt(i -> i);

}
 
 

Das heißt, es wird die  distinct() Operation des  Stream<Integer> benutzt.  Vorher findet ein  mapToObj() statt (das ist das  boxed() ) und danach ein  mapToInt() .  Wenn wir die Implementierung selbst inlinen, steht da nun also:
 
 

Arrays.stream(newIntegers) //.parallel().unordered()

      .mapToInt(i -> new Double(2200* Sine.slowSin(i * 0.001)).intValue())

      .mapToObj(Integer::valueOf)

      .distinct()

      .mapToInt(i -> i)

      .count();
 
 

Das erste  mapToInt() und das nachfolgende  mapToObj() (also unboxing mit anschließendem boxing ) heben sich funktional auf.  Trotzdem werden sie ausgeführt und kosten CPU-Zeit.
 
 

Der Performanceverlust gegenüber der Version mit einem einfachen  map() hält sich aber in unserem Beispiel in Grenzen (2-5% je nach Testfall).  Das liegt einfach daran, dass  Sine.slowSin() den Performancetest dominiert.
 
 

Der zweite Satz im Kommentar der  distinct() Implementierung der  IntPipeline ( An efficient version requires an  int- specific map/set implementation. ) legt die Hoffnung nahe, dass die  distinct() Operation für primitive Streams mit der Einführung von Value Typen performanter wird.  Die Vorstellung ist, dass sich dann generische Typen auch mit primitiven Typen bzw. Value Typen parametrisieren lassen und damit effiziente Versionen von  HashSet<int> LinkedHashSet<int> und  ConcurrentHashSet<int> zur Verfügung stehen (siehe / PRVA /).

Fazit

Wir haben uns angesehen, ob Streams schneller sind als  for -Schleifen.  Die Erkenntnis ist, dass sequentielle Streams langsamer sind als  for -Schleifen und dass parallele Streams durchaus schneller sein können als  for -Schleifen.  Ob sie tatsächlich schneller sind, hängt von den Umständen ab.  Als Faustregel gilt: parallele Streams sind schneller als sequenzielle Stream und sogar schneller als  for -Schleifen, wenn a) die Sequenz groß ist, b) die Verarbeitung auf den Elementen CPU-intensiv ist und c) die Stream-Operation zustandslos ist.  Ob die Faustregel in einem bestimmten Kontext zutrifft oder nicht, bekommt man nur durch Benchmarking raus.  Measure, don't guess!

 
 
 
Details zu den Benchmark s
Jeder Benchmark ist plattform-spezifisch.  Verschiedene plattform-spezifische Eigenschaften (Anzahl der CPU-Cores, Art und Größe der CPU-Caches, usw.) haben einen Einfluss auf die Performance.
 
 

Das bedeutet für die in diesem Artikel gezeigten Ergebnisse, dass sie auf anderen Plattformen anders ausfallen werden.  Wir haben deshalb im beschreibenden Text die Hintergründe für die Benchmark-Ergebnisse erläutert.  Damit sollten sich plattform-spezifische Variationen erklären lassen.  Wir haben ein älteres Dual-Core-Desktopsystem (Intel E8500 mit 2x3,17GHz und 4 MByte RAM) verwendet.  Das Betriebssystem war Windows 7 und der JDK war 1.8.0_05 in der 32bit-Version.  
 
 

Für die Benchmarks der parallelen Stream-Performance ist es vielleicht noch nötig zu erwähnen, dass wir den CommonPool in Default-Konfiguration genutzt haben (also Parallelism: 1).
 
 

Die Benchmark Messungen erfolgten nach intensivem Warm-Up.  
 
 

Was lässt sich zu den Benchmarks noch sagen?  Wir haben vor zehn Jahren zwei Artikel zu dem Thema geschrieben (/ KLBE /).  Das dort Gesagt stimmt so im Wesentlichen heute noch.  In den technischen Details hat sich die Welt natürlich weiterbewegt.  Mittlerweile gibt es im OpenJDK das Tool JMH (/ OJMH /).  JMH ist ein Benchmark-Harness, der Hilfe beim Benchmarking bietet.  JMH wird ein Thema für einen eigenen, zukünftigen Beitrag in dieser Artikelreihe sein.   


 

Literaturverweise

/ACML/   Commons Math: The Apache Commons Mathematics Library
URL: http://commons.apache.org/proper/commons-math/
/CAPS/       Doug Lea: Concerns About Parallel Streams
URL: http://mail.openjdk.java.net/pipermail/lambda-libs-spec-observers/2013-July/002319.html
/DIST/   Javadoc der Stream-Operation distinct()
URL: https://docs.oracle.com/javase/8/docs/api/java/util/stream/Stream.html#distinct--
/FILT/ Javadoc der Stream-Operation filter()
URL: https://docs.oracle.com/javase/8/docs/api/java/util/stream/Stream.html#filter-java.util.function.Predicate-
/OJMH/
OpenJDK Code Tools: JMH
URL: http://openjdk.java.net/projects/code-tools/jmh/
/PRVA/    OpenJDK: Project Valhalla
/WKKW/   Wikipedia: Kleinwinkelnäherung
/KLBE/ Klaus Kreft & Angelika Langer: Micro-Benchmarking
URL: http://www.angelikalanger.com/Articles/Topics.html#PerformanceTuning
/KLPA/ Klaus Kreft & Angelika Langer: Parallele Streams
URL: http://www.angelikalanger.com/Articles/EffectiveJava.html#Java8

Die gesamte  Serie über Java 8:

/JAV8-0/ Neue Features in Java 8 - Überblick
Klaus Kreft & Angelika Langer, Java Magazin, März 2014
URL: http://www.angelikalanger.com/Articles/EffectiveJava/73.Java8.Overview/73.Java8.Overview.html
/JAV8-1/ Funktionale Programmierung in Java
Klaus Kreft & Angelika Langer, Java Magazin, September 2013
URL: http://www.angelikalanger.com/Articles/EffectiveJava/70.Java8.FunctionalProg/70.Java8.FunctionalProg.html
/JAV8-2/ Lambda-Ausdrücke und Methoden-Referenzen
Klaus Kreft & Angelika Langer, Java Magazin, Dezember 2013
URL: http://www.angelikalanger.com/Articles/EffectiveJava/71.Java8.Lambdas/71.Java8.Lambdas.html
/JAV8-3/ Default-Methoden und statische Methoden in Interfaces
Klaus Kreft & Angelika Langer, Java Magazin, Februar 2014
URL: http://www.angelikalanger.com/Articles/EffectiveJava/72.Java8.DefaultMethods/72.Java8.DefaultMethods.html
/JAV8-4/ Übersicht über das Stream API in Java 8
Klaus Kreft & Angelika Langer, Java Magazin, Mai 2014
URL: http://www.angelikalanger.com/Articles/EffectiveJava/74.Java8.Streams-Overview/74.Java8.Streams-Overview.html
/JAV8-5/ Stream-Erzeugung und Stream-Operationen
Klaus Kreft & Angelika Langer, Java Magazin, Juli 2014
URL: http://www.angelikalanger.com/Articles/EffectiveJava/75.Java8.Fundamental-Stream-Operations/75.Java8.Fundamental-Stream-Operations.html
/JAV8-6/ Stream-Kollektoren und die Stream-Operation collect()
Klaus Kreft & Angelika Langer, Java Magazin, September 2014
URL: http://www.angelikalanger.com/Articles/EffectiveJava/76.Java8.Stream-Collectors/76.Java8.Stream-Collectors.html
/JAV8-7/ Stateful Lambdas - Regeln für die Seiteneffekte in Lambda-Ausdrücken, die an Stream-Operationen übergeben werden
Klaus Kreft & Angelika Langer, Java Magazin, November 2014
URL: http://www.angelikalanger.com/Articles/EffectiveJava/77.Java8.Streams-and-Statefulness/77.Java8.Streams-and-Statefulness.html
/JAV8-8/ Das Date/Time API
Klaus Kreft & Angelika Langer, Java Magazin, Januar 2015
URL: http://www.angelikalanger.com/Articles/EffectiveJava/78.Java8.Date-Time-API/78.Java8.Date-Time-API.html
/JAV8-9/ CompletableFuture
Klaus Kreft & Angelika Langer, Java Magazin, März 2015
URL: http://www.angelikalanger.com/Articles/EffectiveJava/79.Java8.CompletableFuture/79.Java8.CompletableFuture.html
/JAV8-10/ Optional<T>
Klaus Kreft & Angelika Langer, Java Magazin, Mai 2015
URL: http://www.angelikalanger.com/Articles/EffectiveJava/80.Java8.Optional-Result/80.Java8.Optional-Result.html
/JAV8-11/ Parallel Streams
Klaus Kreft & Angelika Langer, Java Magazin, Juli 2015
URL: http://www.angelikalanger.com/Articles/EffectiveJava/81.Java8.Parallel-Streams/81.Java8.Parallel-Streams.html
/JAV8-12/ Das Performance-Modell der Streams
Klaus Kreft & Angelika Langer, Java Magazin, September 2015
URL: http://www.angelikalanger.com/Articles/EffectiveJava/82.Java8.Performance-Model-of-Streams/82.Java8.Performance-Model-of-Streams.html
/JAV8-13/ reduce() vs. collect()
Klaus Kreft & Angelika Langer, Java Magazin, November 2015
URL: http://www.angelikalanger.com/Articles/EffectiveJava/83.Java8.Reduce-vs-Collect-Stream-Operations/83.Java8.Reduce-vs-Collect-Stream-Operations.html
/JAV8-14/ User-Defined Collectors
Klaus Kreft & Angelika Langer, Java Magazin, Januar 2016
URL: http://www.angelikalanger.com/Articles/EffectiveJava/84.Java8.User-Defined-Stream-Collectors/84.Java8.User-Defined-Stream-Collectors.html
/JAV8-15/ Parallele Streams und Blockierende Funktionalität
Klaus Kreft & Angelika Langer, Java Magazin, März 2016
URL: http://www.angelikalanger.com/Articles/EffectiveJava/85.Java8.Streams-and-Blocking-Functionality/85.Java8.Streams-and-Blocking-Functionality.html
/JAV8-16/ API-Design mit Lambdas
Klaus Kreft & Angelika Langer, Java Magazin, Mai 2016
URL: http://www.angelikalanger.com/Articles/EffectiveJava/86.Java8.API-Design-With-Lambdas/86.Java8.API-Design-With-Lambdas.html
/JAV8-17/ Low-Level-Aspekte beim API Design mit Lambdas
Klaus Kreft & Angelika Langer, Java Magazin, Juli 2016
URL: http://www.angelikalanger.com/Articles/EffectiveJava/87.Java8.Programming-With-Lambdas/87.Java8.Programming-With-Lambdas.html
/JAV8-18/ Benutzer-definierte Stream-Sourcen und Spliteratoren
Klaus Kreft & Angelika Langer, Java Magazin, September 2016
URL: http://www.angelikalanger.com/Articles/EffectiveJava/88.Java8.User-Defined-Stream-Sources-And-Spliterators/88.Java8.User-Defined-Stream-Sources-And-Spliterators.html

 
 

If you are interested to hear more about this and related topics you might want to check out the following seminar:
Seminar
Lambdas & Streams - Java 8 Language Features and Stream API & Internals
3 day seminar ( open enrollment and on-site)
Java 8 - Lambdas & Stream, New Concurrency Utilities, Date/Time API
4 day seminar ( open enrollment and on-site)
Effective Java - Advanced Java Programming Idioms 
4 day seminar ( open enrollment and on-site)
 
Related Reading
Lambda & Streams Tutorial & Reference
In-Depth Coverage of all aspects of lambdas & streams
Lambdas in Java 8
Conference Presentation at JFokus 2012 (slides)
Lambdas in Java 8
Conference Presentation at JavaZone 2012 (video)
 

 
 
  © Copyright 1995-2018 by Angelika Langer.  All Rights Reserved.    URL: < http://www.AngelikaLanger.com/Articles/EffectiveJava/82.Java8.Performance-Model-of-Streams/82.Java8.Performance-Model-of-Streams.html  last update: 26 Oct 2018