
Collectors.toMap() ist ein Collector der Java-Stream-API, der die Elemente eines Streams in einer Map sammelt. Für jedes Element berechnet er mit einer Funktion den Schlüssel und mit einer zweiten Funktion den Wert.
So baust du aus einer Liste von Büchern eine Map von Buchtitel auf Erscheinungsjahr:
Map<String, Integer> yearByTitle = BOOKS.stream()
.collect(toMap(Book::title, Book::year));
System.out.println(yearByTitle.get("Moby-Dick"));
1851
toMap() gibt es seit Java 8, und zwar in drei Varianten. Welche du wann brauchst, was bei doppelten Schlüsseln passiert und warum toMap() keine null-Werte annimmt, zeige ich dir in diesem Artikel.
In diesem Artikel erfährst du,
- wie
toMap()aus jedem Element einen Eintrag der Map macht, - welche drei Varianten von
toMap()es gibt, - wie du doppelte Schlüssel mit einer Merge-Funktion zusammenführst,
- wie du den Typ und damit die Reihenfolge der Map bestimmst,
- warum
toMap()annull-Werten scheitert und was du dagegen tust, - wann
toUnmodifiableMap(),groupingBy()odertoConcurrentMap()das bessere Werkzeug ist, - welche Fehler du vermeiden solltest.
Die Beispiele in diesem Artikel
Die Beispiele verwenden das Datenmodell des Artikels über Java Streams – ein Enum Genre, einen Record Book und eine kleine Bibliothek mit elf Klassikern:
public enum Genre {
NOVEL,
GOTHIC,
ADVENTURE,
FANTASY,
SCIENCE_FICTION
}
public record Book(String title, String author, int year, Genre genre) {}
public class Library {
public static final List<Book> BOOKS = List.of(
new Book("Pride and Prejudice", "Jane Austen", 1813, NOVEL),
new Book("Frankenstein", "Mary Shelley", 1818, GOTHIC),
new Book("Moby-Dick", "Herman Melville", 1851, ADVENTURE),
new Book("From the Earth to the Moon", "Jules Verne", 1865, SCIENCE_FICTION),
new Book("Alice's Adventures in Wonderland", "Lewis Carroll", 1865, FANTASY),
new Book("Around the World in Eighty Days", "Jules Verne", 1873, ADVENTURE),
new Book("Treasure Island", "Robert Louis Stevenson", 1883, ADVENTURE),
new Book("Kidnapped", "Robert Louis Stevenson", 1886, ADVENTURE),
new Book("The Time Machine", "H. G. Wells", 1895, SCIENCE_FICTION),
new Book("Dracula", "Bram Stoker", 1897, GOTHIC),
new Book("The War of the Worlds", "H. G. Wells", 1898, SCIENCE_FICTION));
}
Drei Autoren haben zwei Bücher in der Bibliothek: Jules Verne, Robert Louis Stevenson und H. G. Wells. Sie sorgen in den folgenden Abschnitten für doppelte Schlüssel.
Die Beispiele importieren die Collectors statisch, z. B. mit import static java.util.stream.Collectors.toMap;. Den vollständigen Code aller Beispiele findest du im GitHub-Repository java-streams-examples, im Package eu.happycoders.tomap. Lange Maps gibt das Programm in einer Zeile aus; im Artikel sind sie nach jedem Eintrag umbrochen, damit du nicht zur Seite scrollen musst.
Wie funktioniert toMap()?
Hier noch einmal das Beispiel aus der Einleitung:
Map<String, Integer> yearByTitle = BOOKS.stream()
.collect(toMap(Book::title, Book::year));
toMap() bekommt in der einfachsten Form zwei Funktionen:
- Das erste Argument,
keyMapper, berechnet aus einem Element den Schlüssel – im BeispielBook::title. - Das zweite Argument,
valueMapper, berechnet aus einem Element den Wert – im BeispielBook::year.
collect() ruft für jedes Buch beide Funktionen auf, sammelt die Ergebnisse als Einträge in einer neuen Map und liefert diese zurück. Die folgende Grafik zeigt das für drei der elf Bücher: oben die Bücher mit allen vier Feldern, unten die Einträge der Map. Titel und Erscheinungsjahr sind hervorgehoben, Autor und Genre gehen nicht in die Map ein.
Das entspricht der folgenden Schleife:
Map<String, Integer> yearByTitle = new HashMap<>();
for (Book book : BOOKS) {
yearByTitle.put(book.title(), book.year());
}
Mit einem Unterschied: put() überschreibt einen vorhandenen Eintrag stillschweigend, toMap() wirft dagegen eine Exception, wenn zwei Elemente denselben Schlüssel liefern. Was du dann tust, zeigt der nächste Abschnitt.
Die beiden Argumente der toMap()-Methode sind vom Typ Function – du kannst also jede Funktion übergeben, die aus einem Element einen Schlüssel oder Wert macht, nicht nur eine Methodenreferenz auf ein Feld.
Häufig soll der Wert das Element selbst sein, z. B. für eine Map, in der du Bücher über ihren Titel nachschlägst. Dafür gibt es Function.identity(), eine Funktion, die ihr Argument unverändert zurückgibt:
Map<String, Book> bookByTitle = BOOKS.stream()
.collect(toMap(Book::title, Function.identity()));
System.out.println(bookByTitle.get("Dracula"));
Book[title=Dracula, author=Bram Stoker, year=1897, genre=GOTHIC]
Statt Function.identity() kannst du auch das Lambda book -> book schreiben. Beides tut dasselbe; Function.identity() sagt mit seinem Namen, was gemeint ist.
Die drei Varianten von toMap()
Die Klasse Collectors deklariert toMap() in drei Varianten:
<T, K, U> Collector<T, ?, Map<K, U>> toMap(
Function<? super T, ? extends K> keyMapper,
Function<? super T, ? extends U> valueMapper)
<T, K, U> Collector<T, ?, Map<K, U>> toMap(
Function<? super T, ? extends K> keyMapper,
Function<? super T, ? extends U> valueMapper,
BinaryOperator<U> mergeFunction)
<T, K, U, M extends Map<K, U>> Collector<T, ?, M> toMap(
Function<? super T, ? extends K> keyMapper,
Function<? super T, ? extends U> valueMapper,
BinaryOperator<U> mergeFunction,
Supplier<M> mapFactory)
Jede Variante erweitert die vorige um ein Argument. Was die Wildcards ? super T und ? extends K bedeuten, erklärt der Artikel über funktionale Interfaces; für die Benutzung von toMap() brauchst du sie nicht.
toMap(keyMapper, valueMapper) – nur eindeutige Schlüssel
Die erste Variante kennst du aus dem vorigen Abschnitt. Sie setzt voraus, dass jedes Element einen anderen Schlüssel liefert.
Mit dem Autor als Schlüssel ist das nicht der Fall, denn Jules Verne hat zwei Bücher in der Bibliothek:
Map<String, Integer> yearByAuthor = BOOKS.stream()
.collect(toMap(Book::author, Book::year));
java.lang.IllegalStateException: Duplicate key Jules Verne (attempted merging values 1865 and 1873)
Die Meldung nennt den Schlüssel und die beiden Werte, die unter ihm landen sollten. toMap() bricht beim ersten doppelten Schlüssel ab; dass auch Robert Louis Stevenson und H. G. Wells zwei Bücher haben, erfährst du aus der Meldung nicht.
Ich empfehle dir die erste Variante für Schlüssel, die garantiert eindeutig sind – eine ID, eine Artikelnummer, der Name einer Enum-Konstante. Dann ist ein doppelter Schlüssel ein Fehler in den Daten, und die Exception macht ihn sichtbar.
toMap(keyMapper, valueMapper, mergeFunction) – mit Merge-Funktion
Können zwei Elemente denselben Schlüssel liefern, übergibst du als drittes Argument eine Merge-Funktion. Sie bekommt den Wert, der schon in der Map steht, und den Wert des neuen Elements, und sie liefert den Wert, der danach in der Map stehen soll.
Das folgende Beispiel behält für alle Autor:innen jeweils den Titel des späteren Buchs:
Map<String, String> latestTitleByAuthor = BOOKS.stream()
.collect(toMap(Book::author, Book::title, (first, second) -> second));
System.out.println(latestTitleByAuthor.get("Jules Verne"));
Around the World in Eighty Days
Der Parameter first ist der Titel, der schon in der Map steht, second der Titel des neuen Elements. „Später“ heißt hier: später in der Liste. Die Bücher stehen in der Bibliothek nach Erscheinungsjahr sortiert, deshalb ist das auch das jüngere Buch.
Mit (first, second) -> first behältst du entsprechend das erste Buch:
Map<String, String> firstTitleByAuthor = BOOKS.stream()
.collect(toMap(Book::author, Book::title, (first, second) -> first));
System.out.println(firstTitleByAuthor.get("Jules Verne"));
From the Earth to the Moon
Die Merge-Funktion ist ein BinaryOperator<U>: Sie verbindet zwei Werte vom Typ der Map-Werte zu einem dritten desselben Typs. Sie kann also nicht nur einen der beiden Werte auswählen, sondern auch beide verrechnen. Wie das geht, zeigt der Abschnitt Doppelte Schlüssel zusammenführen.
toMap(keyMapper, valueMapper, mergeFunction, mapFactory) – mit eigenem Map-Typ
Das vierte Argument, mapFactory, ist ein Supplier, der eine neue, leere Map erzeugt. Damit bestimmst du, in welche Map-Implementierung toMap() sammelt.
Das folgende Beispiel sammelt in eine TreeMap, die ihre Einträge nach dem Schlüssel sortiert, hier also alphabetisch nach dem Namen der Autor:innen:
Map<String, String> latestTitleByAuthor = BOOKS.stream()
.collect(toMap(
Book::author,
Book::title,
(first, second) -> second,
TreeMap::new));
{Bram Stoker=Dracula,
H. G. Wells=The War of the Worlds,
Herman Melville=Moby-Dick,
Jane Austen=Pride and Prejudice,
Jules Verne=Around the World in Eighty Days,
Lewis Carroll=Alice's Adventures in Wonderland,
Mary Shelley=Frankenstein,
Robert Louis Stevenson=Kidnapped}
Eine Variante mit Map-Typ, aber ohne Merge-Funktion gibt es nicht. Brauchst du eine bestimmte Map, aber keine Merge-Funktion, weil die Schlüssel eindeutig sein müssen, dann schreibe eine Merge-Funktion, die eine Exception wirft:
Map<String, Integer> yearByAuthor = BOOKS.stream()
.collect(toMap(
Book::author,
Book::year,
(first, second) -> {
throw new IllegalStateException("Duplicate key");
},
TreeMap::new));
java.lang.IllegalStateException: Duplicate key
Die Meldung nennt den Schlüssel nicht, denn die Merge-Funktion bekommt ihn nicht – sie sieht nur die beiden Werte. Genau so war toMap() mit zwei Argumenten in Java 8 implementiert: als Aufruf der vierten Variante mit HashMap::new und einer Merge-Funktion, die immer eine Exception wirft. Deshalb konnte die Meldung dort nur einen Wert nennen.
Seit Java 9 prüft die toMap()-Variante mit zwei Argumenten die Schlüssel selbst. Diese Prüfung nutzt du für einen eigenen Map-Typ, indem du toMap() mit zwei Argumenten in collectingAndThen() einpackst und die fertige Map in den gewünschten Typ kopierst:
Map<String, Integer> yearByTitle = BOOKS.stream()
.collect(collectingAndThen(
toMap(Book::title, Book::year),
TreeMap::new));
collectingAndThen() wendet eine Funktion auf das Ergebnis eines anderen Collectors an, hier den Konstruktor TreeMap(Map). Doppelte Schlüssel meldet dann wieder toMap(), mit Schlüssel und Werten. Der Preis ist eine zweite Map: toMap() sammelt erst in eine HashMap, und TreeMap::new kopiert alle Einträge.
Doppelte Schlüssel zusammenführen
Eine Merge-Funktion, die einen der beiden Werte auswählt, wirft den anderen weg. Häufiger willst du beide Werte verrechnen. Die folgenden drei Beispiele zeigen, wie.
Zählen
Das folgende Beispiel zählt die Bücher pro Autor:in. Jedes Buch liefert als Wert eine 1, und die Merge-Funktion Integer::sum addiert die Werte bei doppeltem Schlüssel:
Map<String, Integer> bookCountByAuthor = BOOKS.stream()
.collect(toMap(Book::author, book -> 1, Integer::sum));
{Bram Stoker=1,
Robert Louis Stevenson=2,
Mary Shelley=1,
Jane Austen=1,
Lewis Carroll=1,
Herman Melville=1,
Jules Verne=2,
H. G. Wells=2}
Die folgende Grafik zeigt, was beim sechsten Buch passiert, „Around the World in Eighty Days“: Links steht die Map, wie die ersten fünf Bücher sie hinterlassen haben, rechts die Map nach dem sechsten Buch. Der Schlüssel Jules Verne ist schon vorhanden, deshalb ruft toMap() die Merge-Funktion mit dem vorhandenen Wert 1 und dem neuen Wert 1 auf und legt ihr Ergebnis 2 unter dem Schlüssel ab.
Intern ruft die toMap()-Variante mit Merge-Funktion für jedes Element die Methode merge() der Map auf. Steht der Schlüssel noch nicht in der Map, legt merge() den Wert direkt ab; die Merge-Funktion wird nur bei einem doppelten Schlüssel aufgerufen.
Für das Zählen gibt es auch den Collector groupingBy() mit counting(). Worin sich die beiden Lösungen unterscheiden, zeigt der Abschnitt toMap() vs. groupingBy().
Werte verketten
Die Merge-Funktion darf auch Strings verbinden. Das folgende Beispiel sammelt pro Autor:in alle Titel, getrennt durch Komma und Leerzeichen:
Map<String, String> titlesByAuthor = BOOKS.stream()
.collect(toMap(
Book::author,
Book::title,
(first, second) -> first + ", " + second));
System.out.println(titlesByAuthor.get("Jules Verne"));
From the Earth to the Moon, Around the World in Eighty Days
Jeder Aufruf der Merge-Funktion kopiert allerdings den ganzen bisherigen String. Hat ein Schlüssel viele Werte, wächst der Aufwand deshalb quadratisch – derselbe Effekt, den der Artikel über reduce() beim Verbinden von Strings zeigt.
groupingBy() mit joining() als Downstream-Collector sammelt die Titel jedes Schlüssels dagegen in einem StringJoiner, und der Aufwand wächst linear. Ich empfehle dir diese Lösung, sobald ein Schlüssel viele Werte haben kann:
Map<String, String> titlesByAuthor = BOOKS.stream()
.collect(groupingBy(Book::author, mapping(Book::title, joining(", "))));
Das älteste Buch pro Autor:in
Die Merge-Funktion kann auch zwei Elemente vergleichen. BinaryOperator.minBy() liefert eine Funktion, die von zwei Werten den kleineren nach einem Comparator zurückgibt – bei Gleichstand den ersten.
Das folgende Beispiel sucht für alle Autor:innen jeweils das älteste Buch. Der Wert ist das Buch selbst, deshalb Function.identity():
Map<String, Book> oldestBookByAuthor = BOOKS.stream()
.collect(toMap(
Book::author,
Function.identity(),
BinaryOperator.minBy(Comparator.comparingInt(Book::year))));
System.out.println(oldestBookByAuthor.get("H. G. Wells").title());
The Time Machine
minBy() liefert das älteste Buch unabhängig davon, in welcher Reihenfolge die Bücher im Stream stehen – anders als (first, second) -> first, das nur dann das älteste Buch liefert, wenn der Stream nach Erscheinungsjahr sortiert ist. Mit BinaryOperator.maxBy() bekommst du entsprechend das jüngste Buch.
Reihenfolge und Map-Typ
Das Javadoc der toMap()-Varianten mit zwei und drei Argumenten macht keine Zusage über die Map, die du bekommst: weder über ihren Typ noch darüber, ob sie veränderbar, serialisierbar oder threadsicher ist.
In Java 8 wie in Java 27 ist es eine HashMap. Darauf verlassen solltest du dich nicht – was das für dich bedeutet, zeigen die folgenden Abschnitte.
HashMap: keine feste Reihenfolge
Eine HashMap legt ihre Einträge nach dem Hashcode des Schlüssels ab. Die Reihenfolge, in der du sie durchläufst, hat deshalb nichts mit der Reihenfolge der Elemente im Stream zu tun.
Das folgende Beispiel gibt die Schlüssel der Map von Buchtitel auf Erscheinungsjahr aus:
Map<String, Integer> yearByTitle = BOOKS.stream()
.collect(toMap(Book::title, Book::year));
yearByTitle.keySet().forEach(System.out::println);
Pride and Prejudice
From the Earth to the Moon
Alice's Adventures in Wonderland
Around the World in Eighty Days
Frankenstein
Treasure Island
The War of the Worlds
Moby-Dick
The Time Machine
Kidnapped
Dracula
Du siehst: „Frankenstein“ steht in der Bibliothek an zweiter Stelle, in der Map an fünfter. Bei String-Schlüsseln ist die Reihenfolge von Lauf zu Lauf dieselbe, denn der Hashcode eines Strings hängt nur von seinen Zeichen ab. Sie kann sich aber ändern, sobald Einträge dazukommen und die HashMap ihre interne Tabelle vergrößert.
LinkedHashMap: die Reihenfolge des Streams
Eine LinkedHashMap merkt sich, in welcher Reihenfolge ihre Einträge eingefügt wurden. Mit LinkedHashMap::new als viertem Argument behält die Map deshalb die Reihenfolge des Streams:
Map<String, Integer> yearByTitle = BOOKS.stream()
.collect(toMap(
Book::title,
Book::year,
(first, second) -> first,
LinkedHashMap::new));
yearByTitle.keySet().forEach(System.out::println);
Pride and Prejudice
Frankenstein
Moby-Dick
From the Earth to the Moon
Alice's Adventures in Wonderland
Around the World in Eighty Days
Treasure Island
Kidnapped
The Time Machine
Dracula
The War of the Worlds
Die Merge-Funktion (first, second) -> first wird hier nie aufgerufen, denn die Titel sind eindeutig. Sie steht nur da, weil die Variante mit Map-Typ eine verlangt. Willst du doppelte Titel als Fehler melden, nimm collectingAndThen() mit LinkedHashMap::new wie im Abschnitt über die vierte Variante.
TreeMap und EnumMap: sortiert nach dem Schlüssel
Eine TreeMap sortiert ihre Einträge nach dem Schlüssel – nach dessen natürlicher Ordnung oder nach einem Comparator, den du ihrem Konstruktor übergibst. Ein Beispiel hat der Abschnitt über die vierte Variante gezeigt.
Ist der Schlüssel eine Enum-Konstante, ist eine EnumMap die passende Wahl. Sie speichert ihre Werte in einem Array, das sie über die Ordinalzahl der Konstanten adressiert, und gibt die Einträge in der Reihenfolge aus, in der die Konstanten deklariert sind.
EnumMap hat keinen Konstruktor ohne Argumente, denn sie muss den Enum-Typ kennen. Die Methodenreferenz EnumMap::new passt deshalb nicht als viertes Argument von toMap(). Stattdessen übergibst du dort ein Lambda, das den EnumMap-Konstruktor mit Genre.class aufruft. Das folgende Beispiel liefert für jedes Genre den Titel des ersten Buchs:
Map<Genre, String> firstTitleByGenre = BOOKS.stream()
.collect(toMap(
Book::genre,
Book::title,
(first, second) -> first,
() -> new EnumMap<>(Genre.class)));
{NOVEL=Pride and Prejudice,
GOTHIC=Frankenstein,
ADVENTURE=Moby-Dick,
FANTASY=Alice's Adventures in Wonderland,
SCIENCE_FICTION=From the Earth to the Moon}
Veränderbar oder nicht?
Die HashMap, die toMap() heute liefert, kannst du verändern – put() und remove() funktionieren. Weil das Javadoc das nicht zusagt, empfehle ich dir:
- Willst du die Map verändern, übergib
HashMap::newals viertes Argument. Dann ist die Veränderbarkeit nicht Zufall der Implementierung, sondern Teil deines Codes. - Soll die Map unveränderlich sein, nimm
toUnmodifiableMap(), das der AbschnitttoUnmodifiableMap()zeigt.
null-Werte und null-Schlüssel
Eine HashMap darf null als Wert enthalten. toMap() lehnt null-Werte trotzdem ab – in jeder Variante.
Angenommen, du hast die Todesjahre nur für vier der acht Autor:innen erfasst:
Map<String, Integer> deathYearByAuthor = Map.of(
"Jane Austen", 1817,
"Mary Shelley", 1851,
"Herman Melville", 1891,
"Jules Verne", 1905);
Das folgende Beispiel versucht, den Titeln die Todesjahre ihrer Autor:innen zuzuordnen. Für Lewis Carroll liefert get() den Wert null, und toMap() wirft eine NullPointerException:
Map<String, Integer> deathYearByTitle = BOOKS.stream()
.collect(toMap(Book::title, book -> deathYearByAuthor.get(book.author())));
java.lang.NullPointerException
at java.base/java.util.Objects.requireNonNull(Objects.java:220)
at java.base/java.util.stream.Collectors.lambda$uniqKeysMapAccumulator$0(Collectors.java:182)
Die Exception hat keine Meldung – sie verrät dir also nicht, für welches Element der Wert fehlt. Die beiden Zeilen des Stack-Traces zeigen aber, woher sie kommt:
- Die
toMap()-Variante mit zwei Argumenten prüft jeden Wert mitObjects.requireNonNull(), bevor sie ihn mitputIfAbsent()in die Map einfügt. - Die
toMap()-Varianten mit drei oder vier Argumenten rufen für jedes Element die Methodemerge()der Map auf. Das Javadoc vonMap.merge()schreibt vor, dass diese Methode bei einemnull-Wert eineNullPointerExceptionwirft. Die Exception kommt hier also aus der Map selbst – nicht ausCollectors.toMap().
Mit einem null-Wert könnte merge() auch nichts Eindeutiges anfangen, denn die Methode verwendet null als Zeichen für „kein Wert“. Steht unter einem Schlüssel null, behandelt merge() ihn wie einen fehlenden Schlüssel und legt den neuen Wert ab, ohne die Merge-Funktion aufzurufen. Und liefert die Merge-Funktion null, entfernt merge() den Eintrag.
In Java 8 rief auch die toMap()-Variante mit zwei Argumenten merge() auf. Seit Java 9 fügt sie mit putIfAbsent() ein, und die explizite Prüfung mit requireNonNull() erhält das Verhalten von Java 8.
Wie du damit umgehst, hängt davon ab, was ein fehlender Wert bedeutet:
- Bedeutet er, dass das Element nicht in die Map gehört, filterst du es vorher heraus.
- Soll die Map den fehlenden Wert als
nullenthalten, sammelst du mitcollect()mit drei Argumenten. Gemeint ist die Variante vonStream.collect(), die statt eines Collectors drei Funktionen bekommt – nicht zu verwechseln mittoMap()mit drei Argumenten.
Das folgende Beispiel lässt die Bücher weg, deren Autor:innen kein Todesjahr in der Map haben:
Map<String, Integer> deathYearByTitle = BOOKS.stream()
.filter(book -> deathYearByAuthor.containsKey(book.author()))
.collect(toMap(Book::title, book -> deathYearByAuthor.get(book.author())));
{Pride and Prejudice=1817,
From the Earth to the Moon=1905,
Around the World in Eighty Days=1905,
Frankenstein=1851,
Moby-Dick=1891}
collect() mit drei Argumenten bekommt einen Supplier für die Map, einen Akkumulator, der ein Element in die Map einfügt, und einen Combiner, der zwei Maps zusammenführt. Der Akkumulator ruft put() auf, und put() nimmt auch null an:
Map<String, Integer> deathYearByTitle = BOOKS.stream()
.collect(
HashMap::new,
(map, book) -> map.put(book.title(), deathYearByAuthor.get(book.author())),
Map::putAll);
{Pride and Prejudice=1817,
From the Earth to the Moon=1905,
Alice's Adventures in Wonderland=null,
Around the World in Eighty Days=1905,
Frankenstein=1851,
Treasure Island=null,
The War of the Worlds=null,
Moby-Dick=1891,
The Time Machine=null,
Kidnapped=null,
Dracula=null}
Diese Lösung prüft allerdings keine doppelten Schlüssel mehr: put() überschreibt einen vorhandenen Eintrag stillschweigend. Wie collect() mit drei Argumenten im Einzelnen funktioniert, zeigt der Artikel über reduce() im Abschnitt Eine Liste mit reduce() aufbauen.
Ein null-Schlüssel ist dagegen erlaubt, sofern die Map ihn erlaubt. Eine HashMap nimmt einen null-Schlüssel an. Eine TreeMap mit natürlicher Ordnung wirft eine NullPointerException, denn sie kann null nicht mit anderen Schlüsseln vergleichen. Dasselbe gilt für toUnmodifiableMap().
toUnmodifiableMap()
Seit Java 10 gibt es Collectors.toUnmodifiableMap(). Es liefert eine Map, die sich nicht verändern lässt:
Map<String, Integer> yearByTitle = BOOKS.stream()
.collect(toUnmodifiableMap(Book::title, Book::year));
yearByTitle.put("The Invisible Man", 1897);
java.lang.UnsupportedOperationException
toUnmodifiableMap() gibt es in zwei Varianten, entsprechend den ersten beiden Varianten von toMap(): mit zwei Argumenten, die doppelte Schlüssel mit derselben IllegalStateException meldet, und mit Merge-Funktion. Eine Variante mit Map-Typ gibt es nicht, denn den Typ der Map bestimmt toUnmodifiableMap() selbst.
Intern sammelt toUnmodifiableMap() mit toMap() in eine HashMap und kopiert deren Einträge danach mit Map.ofEntries() in eine unveränderliche Map. Daraus folgen zwei Eigenschaften, die die Map von Map.ofEntries() erbt:
- Sie lehnt
null-Schlüssel ab, nicht nurnull-Werte. - Ihre Reihenfolge ändert sich von Programmlauf zu Programmlauf. Die unveränderlichen Maps von
Map.of()undMap.ofEntries()mischen ihre Reihenfolge mit einem Zufallswert, der beim Start der JVM festgelegt wird. Das ist Absicht: Laut Kommentar im JDK-Quellcode soll die Reihenfolge zwischen zwei Programmläufen variieren.
Ich empfehle dir toUnmodifiableMap() für jede Map, die nach dem Sammeln nur noch gelesen wird. Sie schützt dich davor, dass ein anderer Teil des Programms die Map verändert, und sie macht im Code sichtbar, dass das nicht vorgesehen ist.
Anwendungsbeispiele
Eine Map filtern und umbauen
Eine Map hat keine Methode stream(), aber ihr entrySet() hat eine. Damit filterst du die Einträge einer Map oder rechnest ihre Werte um, und toMap() mit Map.Entry::getKey und Map.Entry::getValue baut daraus eine neue Map.
Das folgende Beispiel kopiert aus der Map von Buchtitel auf Erscheinungsjahr nur diejenigen Bücher, die vor 1850 erschienen sind:
Map<String, Integer> before1850 = yearByTitle.entrySet().stream()
.filter(entry -> entry.getValue() < 1850)
.collect(toMap(Map.Entry::getKey, Map.Entry::getValue));
{Pride and Prejudice=1813, Frankenstein=1818}
Willst du die Map nur filtern und nicht kopieren, geht es auch ohne Stream: yearByTitle.values().removeIf(year -> year >= 1850) entfernt die Einträge direkt aus der Map – sofern sie veränderbar ist.
Das folgende Beispiel rechnet die Werte um und macht aus dem Erscheinungsjahr das Jahrzehnt:
Map<String, Integer> decadeByTitle = yearByTitle.entrySet().stream()
.collect(toMap(Map.Entry::getKey, entry -> entry.getValue() / 10 * 10));
System.out.println(decadeByTitle.get("Dracula"));
1890
Eine Map umkehren
Eine Map umkehren heißt, Schlüssel und Werte zu tauschen. Dabei können doppelte Schlüssel entstehen, auch wenn die ursprüngliche Map keine hatte: In der Bibliothek sind zwei Bücher 1865 erschienen.
Das folgende Beispiel versucht, die Map von Buchtitel auf Erscheinungsjahr umzukehren:
Map<Integer, String> titleByYear = yearByTitle.entrySet().stream()
.collect(toMap(Map.Entry::getValue, Map.Entry::getKey));
java.lang.IllegalStateException: Duplicate key 1865 (attempted merging values From the Earth to the Moon and Alice's Adventures in Wonderland)
Mit einer Merge-Funktion, die die Titel verbindet, und einer TreeMap, die die Jahre sortiert, gelingt es:
Map<Integer, String> titlesByYear = yearByTitle.entrySet().stream()
.collect(toMap(
Map.Entry::getValue,
Map.Entry::getKey,
(first, second) -> first + ", " + second,
TreeMap::new));
System.out.println(titlesByYear.get(1865));
From the Earth to the Moon, Alice's Adventures in Wonderland
In welcher Reihenfolge die beiden Titel stehen, hängt hier von der Reihenfolge der ursprünglichen HashMap ab, nicht von der Bibliothek.
Zwei Listen zu einer Map verbinden
Liegen Schlüssel und Werte in zwei Listen gleicher Länge, verbindest du sie über einen Stream der Indizes. IntStream.range() liefert die Indizes von 0 bis zur Länge minus eins, boxed() macht daraus einen Stream<Integer>, und die Methodenreferenzen titles::get und years::get holen für jeden Index den Schlüssel und den Wert:
List<String> titles = List.of("Frankenstein", "Dracula", "Kidnapped");
List<Integer> years = List.of(1818, 1897, 1886);
Map<String, Integer> yearByTitle = IntStream.range(0, titles.size())
.boxed()
.collect(toMap(titles::get, years::get));
{Frankenstein=1818, Dracula=1897, Kidnapped=1886}
boxed() ist nötig, denn IntStream hat kein collect(), das einen Collector annimmt. Sind die Listen verschieden lang, wirft years::get eine IndexOutOfBoundsException – oder, wenn years die längere Liste ist, fehlen ihre letzten Werte stillschweigend in der Map. Prüf die Längen deshalb vorher.
toMap() vs. groupingBy()
toMap() speichert pro Schlüssel einen Wert. groupingBy() speichert pro Schlüssel eine Gruppe von Elementen – standardmäßig als Liste, mit einem Downstream-Collector als beliebiges anderes Ergebnis.
Beide lösen manche Aufgaben gleichermaßen. Die folgenden Abschnitte zeigen, welcher Collector wann besser passt.
Mehrere Werte pro Schlüssel
Mit einer Merge-Funktion kann auch toMap() mehrere Werte pro Schlüssel sammeln. Das folgende Beispiel legt für jedes Buch eine Liste mit einem Titel an, und die Merge-Funktion hängt zwei Listen aneinander:
Map<String, List<String>> titlesByAuthor = BOOKS.stream()
.collect(toMap(
Book::author,
book -> List.of(book.title()),
(first, second) -> Stream.concat(first.stream(), second.stream()).toList()));
System.out.println(titlesByAuthor.get("Jules Verne"));
[From the Earth to the Moon, Around the World in Eighty Days]
groupingBy() mit mapping() als Downstream-Collector liefert dasselbe in nur einer Zeile:
Map<String, List<String>> titlesByAuthor = BOOKS.stream()
.collect(groupingBy(Book::author, mapping(Book::title, toList())));
[From the Earth to the Moon, Around the World in Eighty Days]
groupingBy() legt pro Schlüssel eine ArrayList an und fügt jeden Titel hinzu. Die toMap()-Lösung erzeugt dagegen für jedes Buch eine neue Liste und bei jedem doppelten Schlüssel eine weitere, in die sie beide kopiert.
Ich empfehle dir deshalb groupingBy(), sobald ein Schlüssel mehrere Werte haben kann, und toMap(), wenn er genau einen hat – auch dann, wenn dieser eine Wert aus mehreren Elementen berechnet wird, wie die Summe oder das älteste Buch.
Zählen: Integer oder Long
Im Abschnitt Zählen hat toMap(Book::author, book -> 1, Integer::sum) die Bücher pro Autor:in gezählt. groupingBy() mit counting() liefert dieselben Zahlen:
Map<String, Long> bookCountByAuthor = BOOKS.stream()
.collect(groupingBy(Book::author, counting()));
Der Unterschied liegt im Typ: counting() zählt immer in Long, die toMap()-Lösung in dem Typ, den du für die 1 wählst. Ich empfehle dir groupingBy() mit counting(), denn es sagt mit seinem Namen, was es tut.
Verschachtelte Maps: groupingBy() mit toMap()
Eine Map, deren Werte selbst Maps sind, baust du mit groupingBy() und toMap() als Downstream-Collector. groupingBy() teilt die Elemente in Gruppen auf, und toMap() sammelt jede Gruppe in eine eigene Map.
Das folgende Beispiel gruppiert die Bücher nach Genre und baut für jedes Genre eine Map von Buchtitel auf Erscheinungsjahr. TreeMap::new als zweites Argument von groupingBy() sortiert die äußere Map nach Genre:
Map<Genre, Map<String, Integer>> yearByTitleByGenre = BOOKS.stream()
.collect(groupingBy(Book::genre, TreeMap::new, toMap(Book::title, Book::year)));
yearByTitleByGenre.forEach((genre, yearByTitle) ->
System.out.println(genre + ": " + yearByTitle));
NOVEL: {Pride and Prejudice=1813}
GOTHIC: {Frankenstein=1818,
Dracula=1897}
ADVENTURE: {Around the World in Eighty Days=1873,
Treasure Island=1883,
Moby-Dick=1851,
Kidnapped=1886}
FANTASY: {Alice's Adventures in Wonderland=1865}
SCIENCE_FICTION: {From the Earth to the Moon=1865,
The War of the Worlds=1898,
The Time Machine=1895}
groupingBy() und seine Downstream-Collectors werden Thema eines eigenen Artikels sein.
toMap() in parallelen Streams und toConcurrentMap()
In einem parallelen Stream sammelt toMap() jedes Teilstück in eine eigene HashMap und führt die Maps danach paarweise zusammen. Das Javadoc warnt, dass dieses Zusammenführen teuer sein kann – denn toMap() fügt dabei jeden Eintrag der einen Map einzeln in die andere ein, bei doppelten Schlüsseln mit der Merge-Funktion.
Für die Merge-Funktion bleibt die Reihenfolge der Elemente dabei erhalten: toMap() führt die Map des vorderen Teilstücks mit der des hinteren zusammen, sodass first auch parallel der Wert des früheren Elements ist.
Collectors.toConcurrentMap() geht einen anderen Weg. Es hat dieselben drei Varianten wie toMap() und sammelt die Einträge in eine ConcurrentHashMap. Alle Threads fügen ihre Elemente in diese eine Map ein. Das Javadoc stellt dafür eine bessere Performance in parallelen Streams in Aussicht – denn das Zusammenführen der Teil-Maps entfällt, und eine ConcurrentHashMap lässt mehrere Threads gleichzeitig einfügen, solange sie verschiedene Plätze ihrer internen Tabelle treffen.
Der Preis ist die Reihenfolge: Welcher Thread einen Schlüssel zuerst einfügt, ist Zufall. Eine Merge-Funktion wie (first, second) -> second behält dann nicht den Wert des späteren Elements, sondern den des Elements, das zuletzt eingefügt wurde.
Das folgende Beispiel ordnet die Zahlen von 0 bis 99.999 nach ihrem Rest bei der Division durch drei und behält für jeden Rest die spätere Zahl – zuerst mit toMap(), dann mit toConcurrentMap():
Map<Integer, Integer> lastByRemainder = IntStream.range(0, 100_000)
.boxed()
.parallel()
.collect(toMap(i -> i % 3, i -> i, (first, second) -> second));
Map<Integer, Integer> lastByRemainderConcurrent = IntStream.range(0, 100_000)
.boxed()
.parallel()
.collect(toConcurrentMap(i -> i % 3, i -> i, (first, second) -> second));
In fünf Läufen liefert toMap() fünfmal dasselbe Ergebnis, die drei größten Zahlen:
{0=99999, 1=99997, 2=99998}
{0=99999, 1=99997, 2=99998}
{0=99999, 1=99997, 2=99998}
{0=99999, 1=99997, 2=99998}
{0=99999, 1=99997, 2=99998}
toConcurrentMap() liefert in fünf Läufen fünf verschiedene:
{0=70311, 1=70309, 2=70310}
{0=21873, 1=21874, 2=21872}
{0=85935, 1=85936, 2=85934}
{0=5466, 1=5467, 2=5465}
{0=94530, 1=94528, 2=94529}
Ich empfehle dir toConcurrentMap() deshalb nur für parallele Streams, deren Ergebnis nicht von der Reihenfolge abhängt – etwa, wenn die Schlüssel eindeutig sind oder die Merge-Funktion wie Integer::sum in jeder Reihenfolge dasselbe liefert. Ob sich ein paralleler Stream überhaupt lohnt, ist eine andere Frage – sie wird Thema eines eigenen Artikels sein.
Häufige Fehler
Doppelte Schlüssel mit der Merge-Funktion verschlucken
Wenn toMap() eine IllegalStateException wirft, lässt sich (first, second) -> first schnell hinschreiben. Damit verschwindet aber nicht nur die Exception, sondern auch jedes zweite Element mit demselben Schlüssel – ohne Meldung.
Für toMap(Book::author, Book::year, (first, second) -> first) fehlen in der Map drei der elf Bücher. Ob das Absicht ist oder ein Fehler in den Daten, sieht im Ergebnis niemand mehr.
Ich empfehle dir eine Merge-Funktion nur dort, wo doppelte Schlüssel erwartet sind und die Funktion eine Regel ausdrückt: das spätere Buch, die Summe, das älteste Buch. Sind doppelte Schlüssel ein Fehler, nimm toMap() mit zwei Argumenten – dann meldet die Exception den Fehler.
Einträge mit null aus der Merge-Funktion entfernen
Liefert die Merge-Funktion null, entfernt die Methode merge() der Map den Eintrag. Das verleitet zu einem Trick: Wer nur Autor:innen mit genau einem Buch in der Map haben will, könnte die Merge-Funktion bei einem doppelten Schlüssel null liefern lassen:
Map<String, String> onlyTitleByAuthor = BOOKS.stream()
.collect(toMap(Book::author, Book::title, (first, second) -> null));
{Bram Stoker=Dracula,
Mary Shelley=Frankenstein,
Jane Austen=Pride and Prejudice,
Lewis Carroll=Alice's Adventures in Wonderland,
Herman Melville=Moby-Dick}
Das Ergebnis stimmt – aber nur, weil niemand mehr als zwei Bücher in der Bibliothek hat. Nach dem zweiten Buch ist der Schlüssel zwar aus der Map entfernt – ein drittes Buch würde ihn allerdings neu anlegen, so als wäre es das erste.
Das folgende Beispiel hängt „The Invisible Man“ von H. G. Wells aus dem Jahr 1897 als zwölftes Buch an die Bibliothek an und zeigt den Fehler:
List<Book> books = new ArrayList<>(BOOKS);
books.add(new Book("The Invisible Man", "H. G. Wells", 1897, SCIENCE_FICTION));
Map<String, String> onlyTitleByAuthor = books.stream()
.collect(toMap(Book::author, Book::title, (first, second) -> null));
{Bram Stoker=Dracula,
Mary Shelley=Frankenstein,
Jane Austen=Pride and Prejudice,
Lewis Carroll=Alice's Adventures in Wonderland,
Herman Melville=Moby-Dick,
H. G. Wells=The Invisible Man}
Obwohl H. G. Wells drei Bücher in der Liste hat, steht er in der Map – als hätte er nur eines.
Ich empfehle dir, die Bücher zuerst mit groupingBy() und counting() zu zählen und danach nur die Bücher der Autor:innen mit genau einem Buch in die Map zu sammeln:
Map<String, Long> bookCountByAuthor = BOOKS.stream()
.collect(groupingBy(Book::author, counting()));
Map<String, String> onlyTitleByAuthor = BOOKS.stream()
.filter(book -> bookCountByAuthor.get(book.author()) == 1)
.collect(toMap(Book::author, Book::title));
{Bram Stoker=Dracula,
Jane Austen=Pride and Prejudice,
Mary Shelley=Frankenstein,
Lewis Carroll=Alice's Adventures in Wonderland,
Herman Melville=Moby-Dick}
Das kostet einen zweiten Durchlauf über die Bücher. Dafür gilt das Ergebnis für jede Zahl von Büchern pro Autor:in, und die toMap()-Variante mit zwei Argumenten bestätigt, dass jeder Schlüssel nur einmal vorkommt.
Zusammenfassung
toMap() sammelt die Elemente eines Streams in einer Map. Die Funktion keyMapper liefert für jedes Element den Schlüssel, die Funktion valueMapper den Wert.
Mit zwei Argumenten wirft toMap() bei einem doppelten Schlüssel eine IllegalStateException. Mit einer Merge-Funktion als drittem Argument bestimmst du, welcher Wert unter einem doppelten Schlüssel landet – einer der beiden oder ein aus beiden berechneter. Das vierte Argument bestimmt den Map-Typ und damit die Reihenfolge. null-Werte lehnt toMap() in jeder Variante mit einer NullPointerException ab.
Fünf Empfehlungen für den Alltag:
- Nimm
toMap()mit zwei Argumenten für Schlüssel, die eindeutig sein müssen – ein doppelter Schlüssel ist dann ein Fehler in den Daten, und die Exception zeigt ihn. - Nimm eine Merge-Funktion nur, wenn doppelte Schlüssel erwartet sind und die Funktion eine Regel ausdrückt.
- Verlass dich nicht auf die Reihenfolge der Map; übergib
LinkedHashMap::new,TreeMap::newoder für Enum-Schlüssel eineEnumMap, wenn du eine brauchst. - Nimm
toUnmodifiableMap()für Maps, die nach dem Sammeln nur noch gelesen werden. - Nimm
groupingBy(), sobald ein Schlüssel mehrere Werte haben kann.
Wie collect() in eine Stream-Pipeline passt und welche Collectors es außerdem gibt, zeigt der Artikel über Java Streams.
Konntest du etwas aus diesem Artikel mitnehmen? Mit einer Bewertung auf meinem ProvenExpert-Profil hilfst du anderen Entwickler:innen einzuschätzen, ob sich das Lesen lohnt – und mir zu verstehen, welche Inhalte dir weiterhelfen.




