ConcurrentHashMap – Unterschiede zu HashMap und Collections.synchronizedMap
1. Tổng quan
Wenn mehrere Threads gleichzeitig lesend und schreibend auf eine Map zugreifen, stehen dir drei bekannte Optionen zur Verfügung: HashMap, Collections.synchronizedMap und ConcurrentHashMap. Diese drei Implementierungen unterscheiden sich deutlich in ihrer Thread-Safety, in ihrem Lock-Mechanismus und im Verhalten beim Iterieren.
Dieser Artikel erklärt jeden Typ anhand von ausführbaren Beispielen. Nach dem Lesen wirst du wissen, welchen Typ du wählen sollst, wie du Operationen atomar implementierst und welche häufigen Fehler du vermeiden solltest.
2. Chuẩn bị
Die Beispiele verwenden Java 21. Für die Tests benötigst du JUnit 5 und AssertJ:
<dependencies>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.11.3</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<version>3.26.3</version>
<scope>test</scope>
</dependency>
</dependencies>
Wenn dein Projekt Spring Boot 3.x ist, enthält spring-boot-starter-test bereits beide Bibliotheken.
3. HashMap: nhanh nhưng không thread-safe
HashMap hat keinen Synchronisierungsmechanismus. In einem Single-Thread-Kontext ist es die schnellste Wahl. Wenn mehrere Threads gleichzeitig schreiben, können Daten verloren gehen.
3.1. Updates gehen verloren beim gleichzeitigen Zählen
Das folgende Beispiel zählt die Anzahl der Bestellungen eines Kunden mit 8 Threads:
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class HashMapRaceDemo {
public static void main(String[] args) {
Map<String, Integer> orderCount = new HashMap<>();
try (ExecutorService executor = Executors.newFixedThreadPool(8)) {
for (int i = 0; i < 10_000; i++) {
executor.submit(() -> orderCount.merge("customer-1", 1, Integer::sum));
}
} // close() wartet, bis alle Tasks beendet sind
System.out.println("Anzahl Bestellungen: " + orderCount.get("customer-1"));
}
}
Wir senden 10.000 Tasks, wobei jede Task 1 mit merge hinzuzählt. Seit Java 19 ist ExecutorService ein AutoCloseable, daher wartet try-with-resources auf die Beendigung aller Tasks. Das Ergebnis ändert sich bei jedem Durchlauf:
Anzahl Bestellungen: 9817
Der Grund ist, dass merge von HashMap aus mehreren Schritten besteht: den alten Wert lesen, den neuen Wert berechnen und dann zurückschreiben. Zwei Threads können dieselbe 100 lesen, beide 101 schreiben, und eine Addition geht verloren. Schlimmer noch: Wenn zwei Threads eine Größenänderung (Resize) auslösen, kann die interne Struktur beschädigt werden.
3.2. ConcurrentModificationException beim Iterieren
Der Iterator von HashMap ist fail-fast. Wenn die Map während der Iteration ohne den Iterator selbst geändert wird, kann der nächste Aufruf von next() eine ConcurrentModificationException werfen. Dieser Fehler tritt auch in einem Single-Thread auf, zum Beispiel wenn du map.remove() in einer for-each-Schleife aufrufst.
Hinweis: Der fail-fast-Mechanismus ist nur eine Best-Effort-Strategie. Das Fehlen einer Exception bedeutet nicht, dass dein Code korrekt ist.
4. Collections.synchronizedMap: ein Lock für alles
Collections.synchronizedMap umhüllt eine beliebige Map. Jede Methode des Wrappers wird in synchronized (mutex) ausgeführt, wobei mutex standardmäßig das Wrapper-Objekt selbst ist.
Map<String, Integer> orderCount = Collections.synchronizedMap(new HashMap<>());
Wenn du die Initialisierungszeile aus Abschnitt 3.1 durch die obige Zeile ersetzt, ist das Ergebnis immer korrekt:
Anzahl Bestellungen: 10000
Seit Java 8 überschreibt dieser Wrapper auch Default-Methoden wie merge, compute und putIfAbsent, sodass jeder einzelne Aufruf atomar ist.
4.1. Zusammengesetzte Operationen sind nicht atomar
Jede Methode ist separat synchronisiert. Wenn du zwei Aufrufe zusammensetzt, ist die Sequenz nicht mehr atomar:
Map<String, Customer> customers = Collections.synchronizedMap(new HashMap<>());
// FALSCH: Zwei Threads können beide sehen, dass der Schlüssel fehlt, und beide put aufrufen
if (!customers.containsKey(id)) {
customers.put(id, new Customer(id));
}
// RICHTIG: Halte den Lock während der gesamten check-then-act-Sequenz
synchronized (customers) {
if (!customers.containsKey(id)) {
customers.put(id, new Customer(id));
}
}
Eine einfachere Lösung ist die Verwendung von customers.putIfAbsent(id, new Customer(id)), da dies ein einzelner Aufruf ist.
4.2. Du musst beim Iterieren selbst locken
Der Iterator des Wrappers ist nicht synchronisiert. Die Javadoc fordert dich auf, selbst die Map zu locken:
synchronized (customers) {
for (Customer c : customers.values()) {
System.out.println(c);
}
}
Wenn du den synchronized-Block vergisst, kannst du trotzdem auf eine ConcurrentModificationException treffen. Wenn du locked hast, müssen alle anderen Threads auf Lese- oder Schreibzugriff warten, bis die Schleife beendet ist.
4.3. Performance-Engpässe
Es gibt nur einen Lock für die gesamte Map. Zwei Threads, die zwei verschiedene Schlüssel lesen, müssen sich trotzdem anstellen. Mit steigender Anzahl von Threads nimmt der Lock-Contention zu, und der Durchsatz sinkt deutlich.
5. ConcurrentHashMap: Konzipiert für Nebenläufigkeit
ConcurrentHashMap (Paket java.util.concurrent) wurde speziell für gleichzeitigen Zugriff konzipiert. Seit Java 8 funktioniert die Implementierung wie folgt:
- Lesen ohne Lock.
getverwendet volatile Lesevorgänge und wird daher kaum jemals blockiert. - Schreiben mit Bucket-Level Locking. Wenn ein Bucket leer ist, wird mit CAS (compare-and-swap) eingefügt. Wenn ein Bucket bereits Elemente hat, wird nur der Kopfknoten dieses Buckets
synchronized. Threads, die in verschiedene Buckets schreiben, können parallel laufen. - Kooperatives Resizing. Mehrere Threads beteiligen sich gemeinsam am Verschieben von Daten in die neue Tabelle, anstatt die gesamte Map zu sperren.
5.1. Zusammengesetzte Operationen sind atomar
Die Methoden putIfAbsent, computeIfAbsent, computeIfPresent, compute und merge sind alle atomar pro Schlüssel:
import java.util.concurrent.ConcurrentHashMap;
public class InventoryService {
private final ConcurrentHashMap<String, Integer> stock = new ConcurrentHashMap<>();
public void addStock(String product, int quantity) {
stock.merge(product, quantity, Integer::sum);
}
public boolean reserve(String product) {
Integer result = stock.computeIfPresent(product,
(key, current) -> current > 0 ? current - 1 : current);
return result != null && result >= 0;
}
public int available(String product) {
return stock.getOrDefault(product, 0);
}
}
addStock addiert die Menge mit merge hinzu. Wenn der Schlüssel nicht existiert, benutzt merge direkt quantity als Wert. reserve reduziert den Bestand in computeIfPresent um 1. Der gesamte Vorgang – Lesen, Prüfen und Schreiben – erfolgt in einem atomaren Schritt pro Schlüssel, sodass zwei Threads nicht gleichzeitig die letzte verfügbare Einheit reservieren können.
Um mit sehr hoher Frequenz zu zählen, reduziert die Kombination mit LongAdder die Contention weiter:
ConcurrentHashMap<String, LongAdder> hits = new ConcurrentHashMap<>();
hits.computeIfAbsent("/orders", k -> new LongAdder()).increment();
5.2. Iterator ist schwach konsistent
Der Iterator von ConcurrentHashMap wirft nie eine ConcurrentModificationException. Du kannst die Map während des Iterierens ändern:
ConcurrentHashMap<String, Integer> stock = new ConcurrentHashMap<>(
Map.of("apple", 10, "banana", 0, "cherry", 3));
for (String product : stock.keySet()) {
if (stock.get(product) == 0) {
stock.remove(product);
}
}
System.out.println(stock);
{apple=10, cherry=3}
Der Iterator spiegelt den Zustand der Map zu einem bestimmten Zeitpunkt wider, ab dem er erstellt wurde. Der Iterator kann Änderungen, die danach erfolgen, möglicherweise sehen oder nicht sehen. Jedes Element wird maximal einmal zurückgegeben. Dies ist ein bewusster Kompromiss: Du wirst nicht blockiert und erhältst keine Exception, aber auch keinen exakten Snapshot.
5.3. Akzeptiert keine null-Werte
ConcurrentHashMap<String, Integer> map = new ConcurrentHashMap<>();
map.put("apple", null); // NullPointerException
map.put(null, 1); // NullPointerException
Bei HashMap kann get(key), das null zurückgibt, zwei Bedeutungen haben: Es gibt keinen Schlüssel, oder der Schlüssel wird auf null abgebildet. In einem Single-Thread rufst du zusätzlich containsKey auf, um zu unterscheiden. In einer nebenläufigen Umgebung könnte sich die Map zwischen den beiden Aufrufen ändern. Deshalb verbietet ConcurrentHashMap null, damit get nur eine Bedeutung hat: Der Schlüssel existiert nicht.
5.4. size() ist nur eine Schätzung
Während andere Threads schreiben, geben size() und isEmpty() nur einen ungefähren Zustand wieder. Wenn die Map möglicherweise Integer.MAX_VALUE Elemente überschreitet, verwende mappingCount(), da diese Methode long zurückgibt. Schreibe keine Geschäftslogik, die darauf angewiesen ist, dass size() zu einem bestimmten Zeitpunkt absolut korrekt ist.
6. Testen des Verhaltens bei Nebenläufigkeit
Der folgende Test bestätigt, dass ConcurrentHashMap unter gleichzeitiger Last korrekt zählt und null-Werte ablehnt:
import org.junit.jupiter.api.Test;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.stream.IntStream;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class ConcurrentMapTest {
@Test
void givenManyThreads_whenMerge_thenNoUpdateIsLost() {
Map<String, Integer> orderCount = new ConcurrentHashMap<>();
try (ExecutorService executor = Executors.newFixedThreadPool(8)) {
IntStream.range(0, 10_000).forEach(i ->
executor.submit(() -> orderCount.merge("customer-1", 1, Integer::sum)));
}
assertThat(orderCount).containsEntry("customer-1", 10_000);
}
@Test
void givenNullValue_whenPut_thenThrowNpe() {
Map<String, Integer> stock = new ConcurrentHashMap<>();
assertThatThrownBy(() -> stock.put("apple", null))
.isInstanceOf(NullPointerException.class);
}
}
Der erste Test besteht immer. Wenn du zu new HashMap<>() wechselst, schlägt der Test bei den meisten Durchläufen fehl, da der tatsächliche Wert unter 10.000 liegt.
Hinweis: Ein bestandener Multithreading-Test beweist nicht, dass der Code korrekt ist. Race Conditions können nur unter echter Last auftreten. Verlasse dich auf die API-Garantien wie die Atomarität von
merge, nicht nur auf das Bestehen von Tests.
7. Häufige Fehler mit ConcurrentHashMap
Kombinieren von get und put. Die Verwendung von ConcurrentHashMap macht deinen Code nicht automatisch thread-safe:
// FALSCH: Race Condition zwischen get und put
Integer current = stock.get("apple");
stock.put("apple", current - 1);
// RICHTIG
stock.computeIfPresent("apple", (k, v) -> v - 1);
Die Funktion in compute* läuft lange oder hat Nebenwirkungen. Die Mapping-Funktion wird ausgeführt, während der Bucket locked ist. Wenn du HTTP-Aufrufe oder Datenbankabfragen in computeIfAbsent machst, werden alle anderen Threads blockiert, die in diesen Bucket schreiben. Halte die Funktion kurz und frei von Nebenwirkungen.
Die Map selbst in compute* ändern. Das Aufrufen von map.put oder map.computeIfAbsent verschachtelt in einer Mapping-Funktion derselben Map kann IllegalStateException: Recursive update werfen oder zu Deadlock führen. Ein klassisches Beispiel ist ein rekursiver Fibonacci-Memoization-Cache mit computeIfAbsent.
Atomarität über mehrere Schlüssel erforderlich. ConcurrentHashMap garantiert Atomarität nur pro Schlüssel. Das Verschieben des Bestands von Lager A zu Lager B ist eine Operation über zwei Schlüssel. Dies erfordert einen separaten Lock oder eine Neugestaltung der Daten, um alles unter einem Schlüssel zusammenzufassen.
Reihenfolge erforderlich. ConcurrentHashMap behält keine Insertionsreihenfolge bei. Wenn du sortierte Schlüssel brauchst, verwende ConcurrentSkipListMap.
8. Zusammenfassender Vergleich
Kriterium | HashMap | synchronizedMap | ConcurrentHashMap
----------------------|------------------|--------------------------|--------------------------
Thread-safe | Nein | Ja | Ja
Lock-Mechanismus | Keine | Ein Lock für gesamte Map | CAS + Lock pro Bucket
Gleichzeitiges Lesen | Nicht sicher | Sequenziell (blockiert) | Kein Lock
Zusammengesetzte Ops | Nicht atomar | Benötigt externe Synch. | compute/merge atomar
Iterator | Fail-fast | Fail-fast, tun Sie Lock | Schwach konsistent
Schlüssel/Wert null | Erlaubt | Je nach umhüllter Map | Nicht erlaubt
Multi-Thread-Perf | — | Schlecht bei hohem Cont. | Gut
Welchen Typ solltest du also wählen?
HashMap: Die Map wird nur von einem Thread zugegriffen, oder sie wird erstellt, sicher veröffentlicht und nur gelesen. Für den reinen Lesezugriff istMap.copyOfnoch besser.Collections.synchronizedMap: Du brauchst einen Wrapper um eine bestehendeMapwieLinkedHashMapoderTreeMap, um die Reihenfolge oder spezifisches Verhalten zu bewahren, oder die gleichzeitige Last ist niedrig, oder du benötigst einen Lock für die gesamte Map für eine lange Operationskette.ConcurrentHashMap: Die Standardwahl für Caches, Zähler, Registries und jede Map, die von mehreren Threads gemeinsam genutzt wird.
9. Fazit
HashMap ist schnell, verliert aber Daten, wenn mehrere Threads gleichzeitig schreiben. Collections.synchronizedMap ist sicher durch einen einzelnen Lock. Im Gegenzug schafft es einen Engpass, und du musst selbst locken, wenn du iterierst oder mehrere Operationen kombinierst. ConcurrentHashMap ermöglicht lockfreies Lesen und paralleles Schreiben pro Bucket. Es bietet atomare Operationen wie merge und computeIfAbsent, und sein Iterator wirft keine Exception, wenn sich die Map ändert.
Denke an zwei Regeln. Erstens: Eine Thread-sichere Collection macht deine Operationsketten nicht automatisch atomar, also verwende compute* und merge statt get gefolgt von put. Zweitens: Halte die Mapping-Funktion kurz und ändere nicht die Map selbst von innen heraus.