W3docs

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:

  1. Lock-Striping. Verschiedene Schlüssel werden durch verschiedene interne Locks geschützt, sodass Schreibvorgänge auf unterschiedliche Schlüssel sich nicht gegenseitig blockieren.
  2. 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.
  3. Atomare zusammengesetzte Aktualisierungen. merge, compute, computeIfAbsent und putIfAbsent führen ihre Prüfung und Aktion atomar aus. Ohne sie hat das unsynchronisierte Muster if (!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 query

Intern 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 ConcurrentModificationException

Jeder 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 CME werfen.

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 empty

put 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:

KlasseBegrenzt?Wann verwenden
ArrayBlockingQueue(cap)Ja — feste KapazitätFester Puffer; Gegendruck auf den Producer
LinkedBlockingQueue()Nein (oder begrenzt)Hochdurchsatz-Allzweck-Queue
SynchronousQueue0 — direkter HandoffJedes put wartet auf ein take; Thread-zu-Thread-Übergabe
PriorityBlockingQueueNeinTasks nach Priorität geordnet (nicht nach Einfügereihenfolge)
DelayQueueNeinJedes 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 block

Nicht-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-SpeicherConcurrentHashMap (Standard), ConcurrentSkipListMap (sortiert/Bereich benötigt).
  • Leselastige Listener-ListeCopyOnWriteArrayList.
  • Producer-Consumer-Task-QueueArrayBlockingQueue (begrenzt), LinkedBlockingQueue (keine Grenze benötigt), SynchronousQueue (direkter Handoff).
  • Prioritätswarteschlange über ThreadsPriorityBlockingQueue.
  • Verzögerte „später ausführen"-QueueDelayQueue.
  • Hochdurchsatz lock-frei nicht-blockierendConcurrentLinkedQueue / ConcurrentLinkedDeque.
  • SetConcurrentHashMap.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.

java— editable, runs on the server

Was man aus dem Durchlauf mitnehmen kann:

  • ConcurrentHashMap.merge aus Abschnitt 1 lieferte den exakt erwarteten Zählwert 40.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öhnlichen HashMap + put wü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, die d und e während der Iteration hinzufügten, warfen keine ConcurrentModificationException und 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 ArrayBlockingQueue mit Kapazität 4 aus Abschnitt 3 zwang den Producer, bei put zu 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 ConcurrentLinkedQueue aus 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: kein take(), um auf eine leere Queue zu warten; poll() gibt null zurü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.

Übungen

Übung
Du brauchst eine thread-sichere Map, die von vielen Threads verändert wird und atomares 'Einen Zähler unter einem Schlüssel inkrementieren' unterstützt. Was ist die richtige Wahl?
Du brauchst eine thread-sichere Map, die von vielen Threads verändert wird und atomares 'Einen Zähler unter einem Schlüssel inkrementieren' unterstützt. Was ist die richtige Wahl?
Was this page helpful?