Angelika Langer - Training & Consulting
HOME | COURSES | TALKS | ARTICLES | GENERICS | LAMBDAS | IOSTREAMS | ABOUT | NEWSLETTER | 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 
NEWSLETTER 
CONTACT 
Effective Java

Effective Java
Java 8
Stream-Kollektoren und die Stream-Operation collect()
 

Java Magazin, September 2014
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 ).

 

In unserer kleinen Reihe über das Stream API von Java 8 haben wir bereits erläutert, was Streams überhaupt sind (siehe / KRE4 /), wie man sie erzeugt und welche Operationen sie haben (siehe / KRE5 /).  Wir haben gesehen, dass jeder Stream-Operation in der Regel eine Funktion übergeben wird, die wir üblicherweise als Lambda-Ausdruck oder Methoden-Referenz ausdrücken.  Diesmal wollen wir uns mit der Stream-Operation  collect() befassen, die auf den ersten Blick recht unscheinbar aussieht, jedoch zusammen mit ihrem Parameter vom Typ  Collector umfangreiche Funktionalität bietet.

Collectors und collect()

Die  collec t() Methode dient dazu, die Elemente aus einem Stream aufzusammeln, z.B. in einer Collection.  Die  collect -Operation ist eine terminale Operation.  Ihre Vielseitigkeit liegt in der Fülle von Kollektoren, die an sie übergeben werden können. Das heißt, im Wesentlichen verrichten die Kollektoren die Arbeit, nicht so sehr die  collect() Methode selbst.  Prinzipiell kann man Kollektoren selber bauen, indem man das  Collector -Interface implementiert.  Es gibt aber bereits mehr als dreißig vordefinierten Kollektoren.  Die Factory-Methoden für diese vordefinierten Kollektoren findet man in der Klasse  Collectors .  Im Folgenden wollen wir sie uns genauer ansehen.

String-Kollektor

So gibt es zum Beispiel Kollektoren, die Zeichenketten (d.h. Subtypen von  CharSequence wie  String StringBuilder StringBuffer , etc.) zu einem String konkatenieren.  Die Ergebnis-Collection ist ein String.  Den betreffenden Kollektor erhält man über die Factory-Methode  joining der Klasse  Collectors .  Hier ist ein Beispiel:

 
 

List<String> streamSource = …;

String resultString =

            streamSource.stream()

                        .collect(Collectors. joining() );
 
 

Wir haben mit Hilfe von der  collect -Operation und dem  joining -Kollektor alle Elemente aus einer String-Liste zu einem einzigen String zusammengesetzt. Eine überladene Version der  joining -Factory-Methode erlaubt es, als Parameter noch einen Separator mitgeben.  Damit kann man zum Beispiel eine kommagetrennte Auflistung der Streamelemente als String produzieren.  Eine weitere überladene Version ermöglicht es, zusätzlich zum Separator ein Präfix und ein Suffix mitzugeben.
 
 

Dies ist die einfachste und performanteste Möglichkeit, Streams von Zeichenketten zu konkatenieren, auch wenn es Alternativen gibt, die zum gleichen Resultat führen.  Schauen wir uns deshalb der Vollständigkeit halber auch noch kurz die Alternativen an.  Die erste Alternative basiert auf der  reduce –Stream-Operation auf Basis des Operators+ für  String s:
 
 

List<String> streamSource = …;

String resultString =

            streamSource.stream()

                        .reduce("",  (s1,s2) -> s1+s2 );
 
 

Diese Lösung sieht recht elegant aus und liefert auch das richtige Ergebnis. Sie ist aber sehr unperformant, weil beim  reduce für jedes Streamelement ein temporäres  String -Objekt erzeugt werden muss. Bei einem Benchmark mit einem Stream, der 20.000 Elemente enthielt, brauchte diese Lösung etwas 1.200 mal länger als die Lösung mit dem  joining -Collector.  Man sieht: die alte Regel, den Operator+ für  String s aus Performancegründen besser nicht zu verwenden, gilt hier auch.
 
 

Eine weitere Alternative sieht so aus:
 
 

List<String> streamSource = …;

StringBuilder sb = new StringBuilder();

streamSource.stream().forEach( s -> sb.append(s) );

String resultString = sb.toString();
 
 

Kritisch an dieser Lösung ist, dass das Lambda, das wir als Parameter an  fo r Each() übergeben, stateful ist.  In dem obigen Beispiel ist es noch kein wirkliches Problem, weil wir einen sequentiellen (und keinen parallelen) Stream verwenden.  Grundsätzlich sollte man sich bemühen, Stream-Operationen so zu nutzen, dass sie für sequentielle und parallele Streams gleichermaßen funktionieren.  Immerhin könnten Kollegen auf die Idee kommen,  parallelStream() statt  stream() auf der  streamSource aufzurufen, weil sie sich von der Parallelisierung eine Performancesteigerung versprechen.  Das ist dann natürlich ein kapitaler Fehler, da der  StringBuilder sb , in dem wir die Strings aufsammeln, nicht thread-sicher ist und somit der konkurrierende Zugriff nicht problemlos funktioniert.
 
 

Es bleibt dann nur die Möglichkeit, ein  sb vom Type  StringBuffer zu verwenden, bei dem die  append() Methoden  synchronized sind:
 
 

List<String> streamSource = …;

StringBuffer sb = new  StringBuffer() ;

streamSource. parallelStream() .forEach (s -> sb.append(s));

String resultString = sb.toString();
 
 

Bei dieser Lösung ist wegen der Synchronisation die Performance nicht so gut.  Bei einem Benchmark auf einem Zwei-Core-Prozessor mit einem parallelen Stream, der 20.000 Elemente enthielt, brauchte diese Lösung etwa 5,2 mal länger als die Lösung mit einem  joining -Collector.  Zusätzlich muss man bei dieser Lösung damit leben, dass die  String s aus dem Stream nicht in ihrer Originalreihenfolgen im  resultString stehen, da sie konkurrierend von mehreren Threads an den  StringBuffer angehängt werden. 
 
 

Noch eine letzte Anmerkungen zu dem Beispiel:  Grundsätzlich ist es angebracht, eine gesunde Skepsis gegenüber Lösungen zu haben, die stateful Lambdas nutzten.  Wir werden dieses Thema ausführlicher im nächsten Artikel diskutieren. Da geht es dann um die Problematik von Seiteneffekte bei Lambdas, die man an Stream-Operationen übergibt.

Collection-Kollektoren

Wie in der Einleitung schon kurz erwähnt, gibt es Kollektoren, die die Stream-Elemente in einer Collection ablegen.  Hier ist ein Beispiel:

 
 

List<String> streamSource = …;

Set<String> resultSet =

    streamSource.parallelStream()

                .filter(w->w.length() > 0)

                .filter(w->Character.isUpperCase(w.charAt(0)))

                .collect(Collectors. toSet() );
 
 

In diesem Beispiel werden alle Strings, die mit einem Großbuchstaben beginnen, in einer Ergebnis-Collection aufgesammelt. Den Kollektor haben wir mit der Factory-Method  Collectors . toSet besorgt.  Er legt die Elemente in einem Set ab.  Wenn man das Programm ablaufen lässt, stellt man fest, dass der Set in diesem Fall ein  HashSet ist, das heißt, die String sind unsortiert.  Wenn man wegen der Sortierung einen  TreeSet haben will, dann muss einen anderen Kollektor verwenden, nämlich  toCollection .  Hier ist die Alternative:
 
 

List<String> streamSource = …;

Set<String> resultSet =

    streamSource.parallelStream()

                .filter(w->w.length() > 0)

                .filter(w->Character.isUpperCase(w.charAt(0)))

                .collect(Collectors. toCollection(TreeSet::new) );
 
 

Die Factory-Methode  toCollection braucht eine Funktion, die die zu verwendende Collection liefert.  Wir haben die Konstruktor-Referenz  TreeSet::new mitgegeben, das heißt, die Stream-Elemente werden in einem neu erzeugten  TreeSet abgelegt.
 
 

Neben den oben gezeigten Kollektoren gehört zu den Collection-Kollektoren noch der von  Collectors.toList() erzeugte Kollektor, der die Elemente in eine List aufsammelt.
 
 

Wir wollen an dieser Stelle explizit darauf hinweisen, dass die Collection-Kollektoren (wie alle anderen Kollektoren) natürlich auch mit parallelen Streams funktionieren, obwohl die JDK Collections (z.B.  TreeSet ) bekanntermaßen nicht thread-sicher sind.  Auf die Hintergründe wollen wir jetzt nicht weiter eingehen.  Wir schauen sie uns in einem zukünftigen Artikel über die Interna von parallelen Streams genauer an.  Hier machen wir mit den vordefinierten Kollektoren aus  Collections weiter.

Map-Kollektor

Etwas komplizierter ist das Aufsammeln von Stream-Elementen in Maps.  Um eine Map als Ergebnis-Collection zu erzeugen, werden im einfachsten Fall ein Key-Generator und ein Value-Generator gebraucht.  Hier ist ein Beispiel:

 
 

Collection<Person> people = …;

Map<Integer, String> idToName =

    people.stream()

          .collect(Collectors. toMap(Person::getTIN , Person::getName) );
 
 

Aus einer Sequenz von  Person -Objekten wird eine Map erzeugt, die als Schlüssel einen Identifikator (z.B. die TIN = Taxpayer Identification Number) und als assoziierten Wert den Namen der Person enthält.  Als Key- und Value-Generator dienen Methoden der Klasse  Person .  Hier ist noch ein Beispiel:
 
 
 
 

Collection<Person> people = …;

Map<Integer, Person> idToPerson =

    people.stream()

          .collect(Collectors. toMap(Person::get TIN , Function.identity()) );

             
 
 

Als Schlüssel verwenden wir wieder die Steuernummer und als assoziierter Wert wird das  Person -Objekt selbst eingetragen.  Als Value-Generator benötigen wir deshalb eine Funktion, die ein  Person -Objekt aus der Sequenz bekommt und es 1:1 wieder zurückgibt.  Diese Funktionalität der Selbstabbildung könnten wir als Lambda-Ausdruck  p->p ausdrücken.  Alternativ kann man die Selbstabbildung über die Factory-Methode  identity im  Function -Interface bekommen: die  identity -Methode gibt eine Funktion zurück, die ihr Input-Argument als Returnwert zurückgibt.  Das entspricht genau unserem Lambda-Ausdruck  p->p .
 
 

Bei den obigen Beispielen haben wir unterstellt, dass der Schlüssel eindeutig ist.  Sollte einer der Schlüssel mehrfach auftreten, so wird eine  IllegalStateException mit der Information "Duplicate key" geworfen.  In solchen Fällen muss der  toMap -Kollektor wissen, was er mit den mehreren Werten pro Schlüssel machen soll.  Er könnte sie in irgendeiner Form hintereinander hängen, z.B. könnte er sie in einer Liste aufsammeln.  Hier ist ein Beispiel:
 
 

Collection<Person> people = …;

Map<Address, List<Person>> addressToPerson =

    people.stream()

          .collect(Collectors.toMap(Person::getAddress,

                   p->{List<Person> tmp = new ArrayList<>(); tmp.add(p); return tmp;} ,  // 1

                   (l1,l2)->{l1.addAll(l2); return l1;}                                   // 2

                  ));
 
 

Wir verwenden die Adresse als Schlüssel. Da es zu jeder Adresse mehrere Personen geben kann, wollen wir zu jeder Adresse eine Liste von dort wohnenden Personen anlegen.  Unser assoziierter Wert ist deshalb nicht mehr die Person selbst, sondern wir verpacken die Person in eine Liste (siehe Zeile //1).  Wenn nun derselbe Schüssel erneut auftritt, dann wird die weitere Person zu dieser Adresse ebenfalls in eine Liste verpackt und die beiden Listen werden konkateniert (siehe Zeile //2).
 
 

Es gibt noch eine Variante des  toMap -Kollektors, bei der wir als viertes Argument mitgeben können, wie die Map erzeugt wird.  Wir könnten dafür eine Konstruktor- Referenz wie z.B.  TreeMap::new mitgeben.
 
 

Neben den oben gezeigten  toMap -Kollektoren, gibt es in  Collections auch noch  toConcurrentMap -Kollektoren.  Sie sammeln, wie der Name schon sagt, in eine  ConcurrentMap .  Was die Aufruf-Parameter angeht haben sie die gleichen Signaturen wie die  toMap -Kollektoren.

Gruppierungen

Das Aufsammeln aller Personen zu einer Adresse geht auch einfacher.  Wir haben im obigen Beispiel händisch für jedes Element eine Liste erzeugt und die Listen konkateniert.  Dafür gibt es bereits vorgefertigte Kollektoren.  Sie werden über Factory-Methoden mit dem Namen  groupingBy geliefert.  Für eine Gruppierung muss man im einfachsten Fall nur spezifizieren, wie der Schlüssel ermittelt wird.  Dafür muss ein sogenannter Klassifizierer übergeben werden.  Hier ist das obige Beispiel mit einem  groupingBy -Kollektor anstelle eines  toMap -Kollektors:

 
 

Collection<Person> people = …;

Map< Address ,List<Person>> addressToPerson =

    people.stream()

          .collect(Collectors.collect(Collectors. groupingBy ( Person::getAddress ));
 
 

Die Klassifizierung erfolgt über die Adresse, d.h. es wird eine Map angelegt mit der Adresse als Schlüssel und einer Liste von Personen als assoziierter Wert.  Hier noch ein anderes Beispiel:
 
 

List<String> streamSource = …;

Map< Character ,List<String>> resultMap =

    streamSource.stream()

                .filter(w->w.length()>0)

                .distinct()

                .collect(Collectors. groupingBy ( w->w.charAt(0) );
 
 

Hier werden aus einer Liste von Worten alle Strings der Länge 0 (mit  filter ) und alle Duplikate (mit  distinct ) entfernt.  Danach werden die Strings gemäß ihrem Anfangsbuchstaben gruppiert (mit  collect und  groupingBy -Kollektor).  Heraus kommt eine Map mit dem Anfangsbuchstaben als Schlüssel und einer Liste von Worten mit diesem Anfangsbuchstaben als assoziierter Wert.
 
 

Wenn man die Variante  Collectors.groupingByConcurrent() nutzt, bekommt man statt einer  Map eine  ConcurrentMap zurück.
 
 

Eine spezielle Form der Gruppierung ist die Partitionierung .  Dabei ist der Klassifizierer ein Prädikat, d.h. eine Funktion, die die Sequenz-Elemente bewertet und  true oder  false zurückgibt.  Heraus kommt eine Map mit  true oder  false als Schlüssel und einer Liste von Elementen mit der jeweiligen Eigenschaft als assoziierter Wert.  Hier ist ein Beispiel:
 
 

List<String> streamSource = …;

Map< Boolean ,List<String>> resultMap =

    streamSource.stream()

                .filter(w->w.length()>0)

                .distinct()

                .collect(Collectors. partitioningBy (w->Character. isUpperCase(w.charAt(0)) ));
 
 

Wir partitionieren die Worte in unserer Sequenz danach, ob ihr Anfangsbuchstabe ein Klein- oder ein Großbuchstabe ist. Wir erhalten eine Map mit dem Schlüssel  false und der Liste der Worte in Kleinschreibung und dem Schlüssel  true und der Liste Worte mit großem Anfangsbuchstaben.

Kollektoren mit Downstream-Kollektoren

Bisher haben wir uns den  groupingBy -Collector mit einem Parameter (dem  classifier ) angesehen.  Die Signatur der Factory-Methode für diesen  groupingBy -Collector sieht so aus:

 
 

static <T,K>  Collector <T,?, Map <K, List <T>>>

        groupingBy( Function <? super T,? extends K>  classifier)
 
 

Es gibt eine weitere überladene Version, die folgendermaßen aussieht:
 
 

static <T,K,A,D>  Collector <T,?, Map <K,D>>

        g roupingBy ( Function <? super T,? extends K>  classifier,  Collector <? super T,A,D>  downstream)
 
 

Zusätzlich zu dem  classifier hat die Factory-Methode einen zweiten Parameter, den  downstream Kollektor.  Intuitiv erkennt man nicht sogleich, worum es hierbei geht.  Schauen wir uns deshalb ein Beispiel an.  Wir knüpfen dabei an das Beispiel oben an, bei dem wir eine Map mit dem Anfangsbuchstaben als Schlüssel und einer Liste von Worten mit diesem Anfangsbuchstaben als assoziierter Wert erzeugt haben.  Nehmen wir an, uns interessiert nicht die gesamte Liste, sondern nur der größte String zu jedem Anfangsbuchstaben (im Sinne der  St r ing.compareTo() -Methode).  Wir können dann das Beispiel von oben folgendermaßen abwandeln:
 
 

List<String> streamSource = …;

Map<Character, Optional<String> > resultMap =

    streamSource.stream()

                .filter(w->w.length()>0)

                .distinct()

                .collect(Collectors.groupingBy(w->w.charAt(0),  Collectors.maxBy(String::compareTo ));
 
 

Der Downstream-Collector  Collectors.maxBy() mit  String::compareTo als Parameter wird nun auf jede Liste angewandt, um den jeweils größten String in der Liste zu bestimmen.  Wenn man sich den Ergebnistyp der  resultMap genau ansieht, erkennt man, dass der größte String nicht vom Typ  String , sondern vom Typ  Optional<String> ist.  Dazu später mehr. 
 
 

Rekapitulieren wir hier noch mal ganz kurz die Idee des Downstream-Kollektors: er wird nachgelagert (also downstream ) auf das Ergebnis des Haupt-Kollektors ( groupingBy() ) angewandt.  In gewisser Weise kann man es als eine Fortführung des  Fluent Programmings ansehen.  Nach den intermediären Operationen  filter() destinct() und der terminalen Operation  collect() möchte man noch eine weitere Operation anwenden.  Da das  collect() aber schon die terminale Operation ist, geht es nur, indem man dem Haupt-Kollektor des  collect() den Downstream-Kollektor als Parameter übergibt. 
 
 

Es gibt übrigens noch mehr Kollektoren in  Collectors , die Downstream-Kollektoren akzeptieren, nämlich  groupingByConcurrent() partitioningBy() und  mapping()
 
 

Im Beispiel oben haben wir den  maxBy -Kollektor als Downstream-Kollektor benutzt.  Es stellt sich die Frage: Könnte man den  maxBy -Kollektor auch als eigenständigen Haupt-Kollektor benutzen?  Das geht tatsächlich.  Der  maxBy -Kollektor kann verwendet werden, um den größten String in unserem Stream zu ermitteln, nämlich so:
 
 

List<String> streamSource = …;

Optional<String> largestElem =

    streamSource.stream()

                .filter(w->w.length()>0)

                .distinct()

                .collect( Collectors.maxBy(String::compareTo ));
 
 

Das geht aber auch genauso gut ohne  collect() :
 
 

List<String> streamSource = …;

Optional<String> largestElem =

    streamSource.stream()

                .filter(w->w.length()>0)

                .distinct()

                .max( String::compareTo );
 
 

Mit anderen Worten: einige Kollektoren aus  Collections (wie z.B. der  maxBy -Kollektor) werden fast ausschließlich als Downstream-Kollektoren verwendet, denn nur dort sind sie wirklich benötigt.  Für die Verwendung als Haupt-Kollektor gibt es eine einfachere Alternative ohne Kollektoren.

Optional

Kommen wir noch mal zu  Optional .  Warum ist in den letzten beiden Beispielen das größte Element eines  Stream<String> kein  String , sondern ein  Optional<String> ?  Es hängt damit zusammen, dass der Stream leer sein könnte.  Was wäre denn das größte Element in einem leeren Stream?   In diesem Falle gibt es kein Ergebnis.  Was soll dann eine Methode wie  max() zurück geben?

 
 

Optional löst dieses Dilemma mit den leeren Streams.  Es sagt, ob es überhaupt ein Ergebnis gibt ( isPresent() ), und wenn ja, was das Ergebnis ist ( get() ).  Dies ist erst einmal eine ganz knappe Erklärung.  Wir werden auf den neuen Typ  Optional in einem zukünftigen Artikel noch einmal ausführlicher eingehen.

Zusammenfassung

Wir haben uns in diesem Beitrag angesehen, welche Kollektoren in der Klasse  Collectors vordefiniert sind und wie sie zusammen mit der Stream-Operation  collect() genutzt werden können.  In einem zukünftigen Artikel werden wir noch mal auf das Thema zurückkommen und schauen wie Kollektoren intern funktionieren.  Die genaue Kenntnis der Interna ist eine unerlässliche Voraussetzung, um eigene Kollektoren implementieren zu können.
Im nächsten Artikel schauen wir uns aber erst mal an, welche Anforderungen die Funktionalität erfüllen muss, die wir als Lambda-Ausdruck oder Methodenreferenz an eine Stream-Operation übergeben.

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

 
 

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-2016 by Angelika Langer.  All Rights Reserved.    URL: < http://www.AngelikaLanger.com/Articles/EffectiveJava/76.Java8.Stream-Collectors/76.Java8.Stream-Collectors.html  last update: 2 Aug 2016