1. Warum sich gewöhnliche Collections nicht für Multithreading eignen
Erinnern wir uns, wie wir in unserer Hauptanwendung (z. B. einem Chatraum) mit Collections gearbeitet haben:
List<String> messages = new ArrayList<>();
messages.add("Hallo!");
messages.add("Wie geht's?");
In einem Single-Thread-Programm ist alles bestens. Aber wenn mehrere Threads gleichzeitig Elemente in derselben Collection hinzufügen, entfernen oder lesen – willkommen in der Welt von Race Conditions, inkonsistenten Zuständen und mysteriösen Bugs.
Zum Beispiel fügt ein Thread ein Element hinzu, ein anderer löscht, ein dritter iteriert – und plötzlich erhalten wir eine ConcurrentModificationException, manchmal sogar eine ArrayIndexOutOfBoundsException oder einfach eine „kaputte“ Collection.
Klassiker:
List<String> list = new ArrayList<>();
Runnable writer = () -> {
for (int i = 0; i < 1000; i++) {
list.add("msg-" + i);
}
};
Runnable reader = () -> {
for (String msg : list) {
// ...
}
};
// Starten Sie writer und reader in verschiedenen Threads - schon haben Sie Bugs!
Fazit: Gewöhnliche Collections (ArrayList, HashMap, HashSet usw.) sind NICHT thread-safe. Man darf sie nicht aus mehreren Threads ohne zusätzliche Synchronisation (synchronized, Locks usw.) verwenden.
2. Welche thread-sicheren Collections es in Java gibt
Java lässt Sie nicht im Stich. Für Multithreading-Aufgaben gibt es im Paket java.util.concurrent eine ganze Sammlung von Collections (Verzeihung für die Tautologie), die man gefahrlos aus mehreren Threads verwenden kann.
Wichtige thread-sichere Collections:
| Collection | Einsatzgebiet | Besonderheiten |
|---|---|---|
|
Map, Cache, häufiger Zugriff | Hohe Leistung, kein globaler Lock |
|
List, seltene Änderungen, häufige Lesezugriffe | Schnelles Lesen, langsame Änderungen |
|
Set, seltene Änderungen, häufige Lesezugriffe | Analog zur Liste mit Copy-On-Write |
|
Warteschlange, FIFO | Schnell, nicht blockierend, Task-Queues |
|
Sortierte Map (NavigableMap) | Thread-sicheres Pendant zu TreeMap |
|
Sortiertes Set | Thread-sicheres Pendant zu TreeSet |
|
Blockierende Warteschlangen (Thread-Pools) | Interface, viele Implementierungen |
Wichtig! Das gute alte Collections.synchronizedList(list) und Ähnliches ist nicht ganz dasselbe wie die modernen Collections aus java.util.concurrent. Mehr dazu – gleich unten.
3. ConcurrentHashMap: Ihr Freund in der Multithreading-Welt
ConcurrentHashMap<K, V> ist im Grunde dasselbe wie eine HashMap, nur für Multithreading aufgerüstet. Sie erlaubt mehreren Threads, Daten gleichzeitig sicher zu lesen und zu schreiben, ohne die gesamte Map zu blockieren.
In einer gewöhnlichen HashMap muss man für thread-sicheren Zugriff die gesamte Struktur sperren – und sie verwandelt sich sofort in einen „Flaschenhals“: Während ein Thread arbeitet, warten die anderen.
ConcurrentHashMap löst dieses Problem eleganter. In frühen Versionen war die Map in Segmente mit separaten Sperren aufgeteilt, in neueren Implementierungen kommen leichte atomare Operationen (CAS) auf Ebene einzelner Buckets zum Einsatz. Dadurch können Threads parallel arbeiten, solange sie nicht dieselben Daten berühren.
Beispiel für die Verwendung von ConcurrentHashMap
import java.util.concurrent.ConcurrentHashMap;
public class ChatStats {
private final ConcurrentHashMap<String, Integer> userMessageCount = new ConcurrentHashMap<>();
public void increment(String user) {
// Wert atomar erhöhen
userMessageCount.merge(user, 1, Integer::sum);
}
public int getCount(String user) {
return userMessageCount.getOrDefault(user, 0);
}
}
Wichtig hierbei:
- Methoden können aus verschiedenen Threads aufgerufen werden – alles bleibt korrekt.
- Die Methode merge ist atomar: Wenn mehrere Threads den Zähler gleichzeitig erhöhen, ist das Ergebnis korrekt.
- Für das Lesen ist keine zusätzliche Synchronisation nötig.
Wodurch ist ConcurrentHashMap besser als synchronizedMap?
Map<String, String> map = Collections.synchronizedMap(new HashMap<>());
Wenn Sie synchronizedMap verwenden, blockiert jede Operation – Lesen, Schreiben oder Löschen – die gesamte Map. Während ein Thread mit den Daten arbeitet, müssen die anderen auf ihre Reihe warten.
ConcurrentHashMap ist weitaus eleganter aufgebaut: Sie ermöglicht mehreren Threads, gleichzeitig zu lesen und sogar zu schreiben, sofern sie nicht denselben Bereich der Map (Buckets) ansprechen. In realen Multithreading-Systemen liefert sie daher eine deutlich bessere Performance – der Unterschied kann teils um Größenordnungen ausfallen.
4. CopyOnWriteArrayList und CopyOnWriteArraySet
CopyOnWriteArrayList und CopyOnWriteArraySet sind besondere Collections, die bei jeder Änderung (z. B. bei add() oder remove()) eine neue Kopie des gesamten Arrays erstellen. Dafür erfolgt das Lesen ohne jegliche Synchronisation und ist vollständig thread-sicher.
Stellen Sie sich eine Gästeliste für eine Party vor. Jedes Mal, wenn jemand kommt oder geht, schreiben Sie die Liste neu und verteilen frische Kopien an alle. Etwas verschwenderisch, aber dafür kommt niemand durcheinander, wer gerade da ist.
Wann das wirklich praktisch ist
- Lesen passiert häufig, Änderungen hingegen selten.
- Klassischer Use-Case – eine Liste von Event-Listenern: Handler werden selten hinzugefügt, Events kommen jedoch ständig.
Beispiel: Chat-Listener
import java.util.concurrent.CopyOnWriteArrayList;
public class ChatRoom {
private final CopyOnWriteArrayList<ChatListener> listeners = new CopyOnWriteArrayList<>();
public void addListener(ChatListener listener) {
listeners.add(listener);
}
public void removeListener(ChatListener listener) {
listeners.remove(listener);
}
public void sendMessage(String message) {
// Thread-sicher, selbst wenn sich gerade jemand an- oder abmeldet
for (ChatListener listener : listeners) {
listener.onMessage(message);
}
}
}
Wichtige Besonderheiten:
- Iteration über CopyOnWriteArrayList wirft niemals ConcurrentModificationException.
- Änderungen (add/remove) sind zeit- und speicherintensiv (das gesamte Array wird kopiert!).
- Nicht für große Collections mit häufigen Änderungen verwenden.
5. Weitere thread-sichere Collections
ConcurrentLinkedQueue
ConcurrentLinkedQueue ist eine nicht blockierende FIFO-Warteschlange. Sie erlaubt mehreren Threads, gleichzeitig sicher Elemente hinzuzufügen und zu entnehmen, ohne explizite Locks zu verwenden. Häufig eingesetzt, um Aufgaben zwischen Threads zu übergeben – schnell und ohne „Staus“.
import java.util.concurrent.ConcurrentLinkedQueue;
ConcurrentLinkedQueue<String> queue = new ConcurrentLinkedQueue<>();
queue.add("task1");
String task = queue.poll(); // gibt null zurück, wenn die Warteschlange leer ist
ConcurrentSkipListMap und ConcurrentSkipListSet
- Thread-sichere Pendants zu TreeMap und TreeSet.
- Elemente sind stets sortiert.
- Verwendet, wenn die Schlüsselreihenfolge wichtig ist.
import java.util.concurrent.ConcurrentSkipListMap;
ConcurrentSkipListMap<Integer, String> sortedMap = new ConcurrentSkipListMap<>();
sortedMap.put(10, "a");
sortedMap.put(2, "b");
System.out.println(sortedMap.firstEntry()); // 2=b
BlockingQueue und seine Implementierungen
- Interface einer Queue mit Blockieroperationen (warten, bis ein Element verfügbar ist/Platz frei wird).
- Implementierungen: ArrayBlockingQueue, LinkedBlockingQueue, PriorityBlockingQueue u. a.
- Einsatz in Thread-Pools, für das „Producer-Consumer“-Muster.
import java.util.concurrent.ArrayBlockingQueue;
ArrayBlockingQueue<String> blockingQueue = new ArrayBlockingQueue<>(10);
blockingQueue.put("task"); // Blockiert, wenn die Queue voll ist
String t = blockingQueue.take(); // Blockiert, wenn die Queue leer ist
6. Beispiele: sichere Operationen mit Collections
Beispiel 1: Thread-sichere Map zum Zählen von Nachrichten
import java.util.concurrent.ConcurrentHashMap;
ConcurrentHashMap<String, Integer> messageCount = new ConcurrentHashMap<>();
// Thread 1
messageCount.put("Anna", 1);
// Thread 2
messageCount.put("Anna", messageCount.getOrDefault("Anna", 0) + 1); // Nicht atomar!
// Richtig (atomar):
messageCount.merge("Anna", 1, Integer::sum);
Beispiel 2: Iteration über CopyOnWriteArrayList
import java.util.concurrent.CopyOnWriteArrayList;
CopyOnWriteArrayList<String> users = new CopyOnWriteArrayList<>();
users.add("Anton");
users.add("Maria");
for (String user : users) {
System.out.println(user);
users.remove(user); // Wirft keine ConcurrentModificationException!
}
System.out.println(users); // []
Beispiel 3: Aufgaben-Queue zwischen Threads
import java.util.concurrent.ConcurrentLinkedQueue;
ConcurrentLinkedQueue<String> queue = new ConcurrentLinkedQueue<>();
// Producer-Thread
queue.add("task-1");
// Consumer-Thread
String task = queue.poll(); // null, wenn leer
7. Nützliche Feinheiten
Wann (und warum) thread-sichere Collections verwenden
Der Einsatz thread-sicherer Collections ist sinnvoll, wenn:
- Dieselbe Collection zwischen mehreren Threads geteilt wird.
- Sie nicht jede Operation manuell synchronisieren möchten.
- Race Conditions und Konsistenzfehler vermieden werden sollen.
Typische Szenarien:
- Cache in einem Multithreading-System (z. B. ConcurrentHashMap zur Speicherung von Benutzersitzungen).
- Aufgaben-Queues zwischen Threads (ConcurrentLinkedQueue, BlockingQueue).
- Listener-Listen für Events (CopyOnWriteArrayList).
- Mehrthreadige Datenverarbeitung (z. B. im MapReduce-Stil).
Einschränkungen und Fallstricke
- Operationen über mehrere Elemente sind nicht atomar. Eine Konstruktion wie if (!map.containsKey(k)) map.put(k, v) ist nicht atomar. Verwenden Sie putIfAbsent, computeIfAbsent, merge.
- CopyOnWriteArrayList ist bei häufigen Änderungen ineffizient. Bei großen Größen und häufigen add/remove steigen die Overheads stark an.
- Iteration über ConcurrentHashMap ist „schwach“. Der Durchlauf liefert einen schwach konsistenten Snapshot: Ein Teil paralleler Änderungen kann unsichtbar bleiben.
- Thread-sichere Collections lösen nicht alle Synchronisationsprobleme. Wenn die Logik mehrere Collections/Variablen gleichzeitig betrifft, ist externe Synchronisation erforderlich (synchronized, Locks, atomare Klassen).
8. Häufige Fehler bei der Arbeit mit thread-sicheren Collections
Fehler Nr. 1: Magie von thread-sicheren Collections erwarten. „Die Collection ist thread-safe, also kann man alles tun und muss nicht an Synchronisation denken.“ Leider sind Sequenzen aus mehreren Operationen (Prüfen + Hinzufügen) nicht atomar. Verwenden Sie spezialisierte Methoden: putIfAbsent, compute, merge.
Fehler Nr. 2: CopyOnWriteArrayList für große und häufig geänderte Collections verwenden. Geeignet für Listener-Listen, aber bei 10 000+ Elementen und häufigen Änderungen entstehen hohe Speicher- und Zeitkosten.
Fehler Nr. 3: ConcurrentModificationException bei der Verwendung gewöhnlicher Collections. Sie iterieren über ArrayList oder HashMap, während ein anderer Thread die Collection ändert – schon bekommen Sie eine ConcurrentModificationException. Verwenden Sie spezialisierte Collections oder sperren Sie den Zugriff manuell.
Fehler Nr. 4: Die Atomarität komplexer Operationen vergessen. Wenn mehrere Collections auf einmal geändert oder eine Serie zusammenhängender Aktionen ausgeführt werden muss, helfen thread-sichere Collections nicht weiter. Verwenden Sie externe Synchronisation oder transaktionale Logik.
Fehler Nr. 5: Fehler bei der Iteration über ConcurrentHashMap. Die Iteration ist schwach konsistent: Man kann den Iterator nicht als „Snapshot“ des Map-Zustands verwenden. Für einen konsistenten Snapshot kopieren Sie die Daten in eine separate Struktur.
GO TO FULL VERSION