Java Concurrent Collections
Thread-sichere Collections in java.util.concurrent — ConcurrentHashMap, CopyOnWriteArrayList, BlockingQueue — und wann welche einsetzen.
HashMap, ArrayList, ArrayDeque — das sind die alltäglichen Collections, und keine davon ist thread-sicher. Werden sie von mehreren Threads ohne externe Synchronisation verwendet, entstehen verlorene Aktualisierungen, kaputte Invarianten und die gefürchtete ConcurrentModificationException mitten in einer Iteration. Die klassische Lösung war Collections.synchronizedMap(...), das eine gewöhnliche Map in ein einziges großes Lock verpackt. Das funktioniert, serialisiert aber jede Operation.
Das Paket java.util.concurrent hat den Ansatz „mit Lock verpacken" durch Collections ersetzt, die von Grund auf für den gleichzeitigen Zugriff konzipiert wurden: Lock-Striping-, Copy-on-Write- und lock-freie Varianten, die auf verschiedene Lese-/Schreibverhältnisse abgestimmt sind. Dieses Kapitel gibt einen Überblick — was jede Klasse am besten kann und welche Fehlerquellen man kennen sollte.
ConcurrentHashMap — das Arbeitstier
Die meistgenutzte concurrent Collection in Java. Eine HashMap-ähnliche Map, die man aus vielen Threads ohne externe Synchronisation verwenden kann:
ConcurrentHashMap<String, Integer> counts = new ConcurrentHashMap<>();
counts.put("hits", 1);
counts.merge("hits", 1, Integer::sum); // atomic add-or-increment
counts.computeIfAbsent("misses", k -> 0);
counts.computeIfPresent("hits", (k, v) -> v + 1);Drei Dinge machen sie unter Konkurrenz schnell:
- Lock-Striping. Verschiedene Schlüssel werden durch verschiedene interne Locks geschützt, sodass Schreibvorgänge auf unterschiedliche Schlüssel sich nicht gegenseitig blockieren.
- Lock-freie Lesevorgänge. Lesevorgänge benötigen (im Normalfall) überhaupt kein Lock. Ein Leser kann mit einem Schreiber konkurrieren; das Ergebnis ist entweder der alte oder der neue Wert, niemals ein korrupter.
- Atomare zusammengesetzte Aktualisierungen.
merge,compute,computeIfAbsentundputIfAbsentführen ihre Prüfung und Aktion atomar aus. Ohne sie hat das unsynchronisierte Musterif (!map.containsKey(k)) map.put(k, v)ein Race-Window zwischen den beiden Aufrufen; die atomaren Methoden schließen es.
ConcurrentHashMap sollte immer dann verwendet werden, wenn eine HashMap von mehr als einem Thread verwendet wird. Es ist der richtige Standard — schneller als Hashtable, schneller als synchronizedMap, und unterstützt atomare zusammengesetzte Aktualisierungen, die die anderen nicht bieten.
Eine Regel: null-Schlüssel und null-Werte sind nicht erlaubt. containsKey(k) ist zuverlässig; map.get(k) == null ist mehrdeutig (Schlüssel fehlt vs. Wert ist null). Das Verbot von nulls beseitigt die Mehrdeutigkeit.
ConcurrentSkipListMap — sortierte concurrent Map
Wenn man eine TreeMap-ähnliche Struktur (nach Schlüssel sortiert) und gleichzeitigen Zugriff benötigt:
ConcurrentSkipListMap<Long, Event> byTimestamp = new ConcurrentSkipListMap<>();
byTimestamp.put(1700000000000L, e1);
byTimestamp.put(1700000005000L, e2);
byTimestamp.firstEntry(); // earliest
byTimestamp.lastEntry(); // latest
byTimestamp.subMap(start, end); // range queryIntern durch eine Skip-Liste implementiert (eine probabilistische Alternative zu einem balancierten Baum, die einfacher lock-frei zu gestalten ist). Sie unterstützt die vollständige NavigableMap-API. Langsamer als ConcurrentHashMap bei einfachem Schlüssel-Lookup; die richtige Wahl, wenn man geordnete Iteration oder Bereichsabfragen benötigt.
CopyOnWriteArrayList — leselastige kleine Liste
CopyOnWriteArrayList<Listener> listeners = new CopyOnWriteArrayList<>();
listeners.add(myListener);
for (Listener l : listeners) l.onEvent(e); // never throws ConcurrentModificationExceptionJeder Schreibvorgang kopiert das zugrunde liegende Array. Lesevorgänge sind wartfrei — kein Lock, keine Synchronisation, kein CME. Der Preis ist offensichtlich: jedes add/remove/set hat O(n) Aufwand, weil das gesamte Array kopiert wird.
Das ist ein schrecklicher Kompromiss für schreiblastige Workloads. Es ist ein perfekter Kompromiss für den Workload, für den er konzipiert wurde:
- Eine kleine Liste (zehn, vielleicht hundert Einträge).
- Lesevorgänge überwiegen Schreibvorgänge bei weitem.
- Iteration ist häufig; sie darf niemals
CMEwerfen.
Der klassische Anwendungsfall: eine Liste von Event-Listenern, Konfigurationseinträgen oder registrierten Abonnenten. Lesevorgänge finden bei jedem Event statt; Schreibvorgänge erfolgen beim Start oder wenn sich eine Komponente registriert.
CopyOnWriteArrayList sollte nicht für „alles, was man in eine ArrayList legen könnte" verwendet werden. Für veränderliche gemeinsame Collections, die nicht winzig und leselastig sind, sollte man Collections.synchronizedList um eine ArrayList verwenden oder die Datenstruktur überdenken.
BlockingQueue — die Producer/Consumer-Queue
Die nützlichste Abstraktion in java.util.concurrent:
BlockingQueue<Task> queue = new ArrayBlockingQueue<>(1024);
queue.put(task); // blocks if full
queue.offer(task, 100, TimeUnit.MILLISECONDS); // blocks up to deadline
queue.add(task); // throws if full
Task t = queue.take(); // blocks if empty
Task t2 = queue.poll(100, TimeUnit.MILLISECONDS); // blocks up to deadline
Task t3 = queue.poll(); // returns null if emptyput und take sind die blockierenden Operationen: Sie warten, bis die Queue nicht voll bzw. nicht leer ist. Das ist das gesamte Fundament des Executor-Frameworks — jeder ThreadPoolExecutor hält intern eine BlockingQueue ausstehender Tasks; Worker nehmen mit take daraus; das öffentliche execute legt dort hinein.
Gängige Implementierungen:
| Klasse | Begrenzt? | Wann verwenden |
|---|---|---|
ArrayBlockingQueue(cap) | Ja — feste Kapazität | Fester Puffer; Gegendruck auf den Producer |
LinkedBlockingQueue() | Nein (oder begrenzt) | Hochdurchsatz-Allzweck-Queue |
SynchronousQueue | 0 — direkter Handoff | Jedes put wartet auf ein take; Thread-zu-Thread-Übergabe |
PriorityBlockingQueue | Nein | Tasks nach Priorität geordnet (nicht nach Einfügereihenfolge) |
DelayQueue | Nein | Jedes Element hat eine Verzögerung; wird erst entnommen wenn abgelaufen |
ArrayBlockingQueue ist der Produktionsstandard — er begrenzt die laufende Arbeit, was für Gegendruck unerlässlich ist. LinkedBlockingQueue ohne Kapazitätsgrenzen ist die Falle hinter Executors.newFixedThreadPool (unbegrenzte Queue → unbegrenzter Speicher).
ConcurrentLinkedQueue und ConcurrentLinkedDeque — die unbegrenzten lock-freien
ConcurrentLinkedQueue<Event> events = new ConcurrentLinkedQueue<>();
events.add(e);
Event e = events.poll(); // null if empty; doesn't blockNicht-blockierend, lock-frei, unbegrenzt. poll gibt null zurück statt zu blockieren; es gibt kein take. Am besten geeignet wenn:
- Hoher Durchsatz gewünscht wird.
- Man tolerieren kann, dass eine leere Queue schnell zurückkehrt statt zu warten.
- Kein Gegendruck benötigt wird.
Diese sind keine BlockingQueues — man wählt sie, wenn man die blockierende Semantik wirklich nicht möchte.
Iteration: schwache Konsistenz
Ein HashMap-Iterator wirft ConcurrentModificationException, wenn sich die Map während der Iteration ändert. Concurrent Collections verhalten sich anders: ihre Iteratoren sind schwach konsistent. Das bedeutet:
- Sie werfen keine
ConcurrentModificationException, auch wenn andere Threads die Collection verändern. - Sie sehen garantiert jedes Element, das zum Zeitpunkt der Iterator-Erstellung vorhanden war.
- Sie können oder müssen nicht Änderungen widerspiegeln, die nach der Iterator-Erstellung vorgenommen wurden.
Das ist für die meisten Verwendungszwecke in Ordnung — ein Snapshot-Iterator ist genau das, was nebenläufiger Code braucht. Der Preis: size() ist ebenfalls „schwach konsistent" — bei ConcurrentHashMap ist es eine Schätzung, kein garantierter Snapshot-Wert. Wer size() als verbindlich behandelt, missbraucht die API.
Wann welche verwenden
Ein grober Entscheidungsbaum:
- Schlüssel-Wert-Speicher →
ConcurrentHashMap(Standard),ConcurrentSkipListMap(sortiert/Bereich benötigt). - Leselastige Listener-Liste →
CopyOnWriteArrayList. - Producer-Consumer-Task-Queue →
ArrayBlockingQueue(begrenzt),LinkedBlockingQueue(keine Grenze benötigt),SynchronousQueue(direkter Handoff). - Prioritätswarteschlange über Threads →
PriorityBlockingQueue. - Verzögerte „später ausführen"-Queue →
DelayQueue. - Hochdurchsatz lock-frei nicht-blockierend →
ConcurrentLinkedQueue/ConcurrentLinkedDeque. - Set →
ConcurrentHashMap.newKeySet(),CopyOnWriteArraySet,ConcurrentSkipListSet.
Immer wenn eine gewöhnliche Collection von mehr als einem Thread verwendet wird, sollte man eine concurrent Collection wählen oder sie mit Collections.synchronizedX verpacken — niemals einfach hoffen, dass es funktioniert.
Ein durchgearbeitetes Beispiel: jede Collection bei ihrer Aufgabe
Das folgende Programm demonstriert vier concurrent Collections unter einer gemeinsamen Last — eine ConcurrentHashMap zum Zählen von Events, eine CopyOnWriteArrayList von Listenern, eine ArrayBlockingQueue für Producer/Consumer und eine ConcurrentLinkedQueue für lock-freies Anhängen.
Was man aus dem Durchlauf mitnehmen kann:
ConcurrentHashMap.mergeaus Abschnitt 1 lieferte den exakt erwarteten Zählwert40.000. Die Merge-Funktion (Integer::sum) lief atomar pro Schlüssel, sodass zwei Threads, die denselben Schlüssel inkrementieren, nie eine Aktualisierung verloren — die atomare zusammengesetzte Aktualisierung ist der springende Punkt. Mit einer gewöhnlichenHashMap+putwürde man einen Bruchteil des erwarteten Werts erhalten und wahrscheinlich auch einen korrupten internen Zustand.- Der
CopyOnWriteArrayList-Iterator aus Abschnitt 2 sah[a, b, c](den Snapshot zum Zeitpunkt der Iterator-Erstellung). Die Schreibvorgänge, diedundewährend der Iteration hinzufügten, warfen keineConcurrentModificationExceptionund waren für den laufenden Iterator nicht sichtbar. Die endgültige Liste enthielt alle fünf — die Schreibvorgänge fanden statt, waren für den bereits gestarteten Iterator nur unsichtbar. - Die
ArrayBlockingQueuemit Kapazität 4 aus Abschnitt 3 zwang den Producer, beiputzu blockieren, wann immer die Queue voll war. Die Ausgabe zeigte, wie die Queue sich auf 4 füllt, dann der Producer pausiert, während der Consumer entleert, dann der Producer fortsetzt. Das ist Gegendruck, der durch die Datenstruktur realisiert wird: Der Producer kann nicht schneller laufen als der Consumer, ohne zusätzlichen Koordinationscode. - Die
ConcurrentLinkedQueueaus Abschnitt 4 akzeptierte Schreibvorgänge von vier Threads ohne Blockierung und ohne Lock-Contention. Die final entleerte Anzahl stimmte exakt mit der angehängten überein — jedes geschriebene Element wurde erfolgreich gelesen. Der Preis: keintake(), um auf eine leere Queue zu warten;poll()gibtnullzurück, und man muss das selbst behandeln. - Durchgehend warfen die concurrent Collections keine
ConcurrentModificationException. Diese Ausnahme ist ein Merkmal der nicht-nebenläufigen Collections — sie ist die Art der JVM zu sagen „du hast das hier kaputt gemacht." Die concurrent Collections sind darauf ausgelegt, von mehreren Threads verändert zu werden, daher brauchen sie dieses Signal nicht.
Was kommt als Nächstes
Das nächste Kapitel, Java Virtual Threads, behandelt das Java-21-Feature, das die Denkweise über Thread-Anzahlen verändert — leichtgewichtige Threads, die von der JVM geplant werden und blockierendes I/O wieder günstig machen.