Why this matters
Collections.synchronizedMaplooks like the answer and is usually the wrong one, for two separate reasons.- Making each call atomic does not make a sequence of calls atomic, and that gap causes bugs that pass every single-threaded test.
ConcurrentHashMapis a standard interview topic, and the segment-to-node-locking change in Java 8 is the detail that distinguishes a current answer.
The problem with wrapping
Map<String, Integer> wrapped = Collections.synchronizedMap(new HashMap<>());
Every method acquires the same lock on the whole map. Two threads reading unrelated keys block each other for no reason. Under contention this becomes the bottleneck of the application.
The second problem is worse, because it is silent:
// Each call is atomic. The sequence is not.
if (!wrapped.containsKey("count")) { // thread A checks — absent
wrapped.put("count", 1); // thread B does the same, and one write is lost
}
Both threads see "absent" and both write. Making individual calls thread-safe does nothing for a
check-then-act sequence — you would still have to wrap the whole block in synchronized, at which point
the wrapper has bought you nothing.
ConcurrentHashMap
How it achieves concurrency
- Reads are lock-free. The table and node fields are
volatile, so a reader sees a consistent value with no synchronisation at all. - Writes lock one bucket.
synchronizedon the head node of that bucket means writes to different buckets proceed in parallel. - Resizing is cooperative. Multiple threads help transfer entries rather than one blocking the rest.
- Size is tracked with striped counters — an array of cells, so incrementing does not become a contention point.
Before Java 8 it used segment locking: the table was divided into 16 independently locked segments, giving a concurrency level of 16. Java 8 replaced that with per-node locking, so the effective concurrency is the number of buckets rather than a fixed 16.
Atomic compound operations
This is the practical reason to use it: the check-then-act gap is closed by single atomic calls.
ConcurrentHashMap<String, Integer> counts = new ConcurrentHashMap<>();
counts.putIfAbsent("views", 0); // atomic insert-if-missing
counts.merge("views", 1, Integer::sum); // atomic increment
counts.compute("views", (k, v) -> v == null ? 1 : v + 1);
counts.computeIfAbsent("cache", k -> expensive(k)); // computed at most once
counts.replace("views", 10, 20); // atomic compare-and-set
counts.remove("views", 0); // remove only if the value matches
// A thread-safe counter with no explicit locking anywhere
public class HitCounter {
private final ConcurrentHashMap<String, LongAdder> hits = new ConcurrentHashMap<>();
public void record(String page) {
hits.computeIfAbsent(page, key -> new LongAdder()).increment();
}
public long count(String page) {
LongAdder adder = hits.get(page);
return adder == null ? 0 : adder.sum();
}
}
LongAdder spreads increments across several internal cells, so threads rarely contend. Under heavy
concurrent writes it substantially outperforms AtomicLong, which has every thread competing on one
field.
No nulls
ConcurrentHashMap rejects null keys and values. The reason is that get returning null would be
ambiguous — absent, or present with a null value — and you cannot resolve the ambiguity with
containsKey because another thread may change the answer between the two calls.
The rest of the family
| Aspect | Concurrent collection | Use it for |
|---|---|---|
| ConcurrentHashMap | A shared map | The default for any concurrent map |
| ConcurrentHashMap.newKeySet() | A shared set | Backed by the map; O(1) contains |
| CopyOnWriteArrayList | Read-heavy, write-rare lists | Listener registries; every write copies |
| ConcurrentLinkedQueue | An unbounded non-blocking queue | Lock-free, uses compare-and-swap |
| LinkedBlockingQueue | Producer-consumer with backpressure | Bounded; put and take block |
| ConcurrentSkipListMap | A sorted concurrent map | The concurrent answer to TreeMap |
ConcurrentHashMap
Concurrent collectionA shared mapUse it forThe default for any concurrent mapConcurrentHashMap.newKeySet()
Concurrent collectionA shared setUse it forBacked by the map; O(1) containsCopyOnWriteArrayList
Concurrent collectionRead-heavy, write-rare listsUse it forListener registries; every write copiesConcurrentLinkedQueue
Concurrent collectionAn unbounded non-blocking queueUse it forLock-free, uses compare-and-swapLinkedBlockingQueue
Concurrent collectionProducer-consumer with backpressureUse it forBounded; put and take blockConcurrentSkipListMap
Concurrent collectionA sorted concurrent mapUse it forThe concurrent answer to TreeMap
ConcurrentHashMap and LinkedBlockingQueue between them cover most real needs.
// The concurrent Set — not CopyOnWriteArraySet, whose contains() is O(n)
Set<String> active = ConcurrentHashMap.newKeySet();
active.add("session-1");
active.remove("session-1");
// Read-heavy listener list: iteration never throws and takes no lock
private final List<Listener> listeners = new CopyOnWriteArrayList<>();
public void fire(Event event) {
for (Listener listener : listeners) { // iterates a stable snapshot
listener.onEvent(event);
}
}
Weakly consistent iteration
Concurrent collections never throw ConcurrentModificationException. Their iterators are weakly
consistent: they reflect the state at some point since creation, may or may not show later changes, and
never return the same element twice.
ConcurrentHashMap<String, Integer> map = new ConcurrentHashMap<>(Map.of("a", 1, "b", 2));
for (Map.Entry<String, Integer> entry : map.entrySet()) {
map.put("c", 3); // legal; the iterator may or may not include it
}
The practical consequence is that size(), isEmpty() and iteration are approximate under concurrent
modification. They are correct for monitoring and wrong as the basis of a decision.
Choosing
- Single-threaded, or confined to one thread — plain
HashMap,ArrayList. Synchronisation you do not need is pure cost. - Shared mutable map —
ConcurrentHashMap, always. - Shared set —
ConcurrentHashMap.newKeySet(). - Read-heavy, write-rare list —
CopyOnWriteArrayList. - Handing work between threads — a bounded
BlockingQueue. - Immutable shared data —
List.of,Map.ofor a record. Nothing can change, so nothing needs protecting.
The last point deserves emphasis: the cheapest concurrent collection is one nobody writes to. Publishing an immutable snapshot and replacing the whole reference is often simpler and faster than coordinating mutation.
Common misreadings
- "
Collections.synchronizedMapmakes my code thread-safe." It makes each call atomic. Compound operations and iteration still need external locking. - "
ConcurrentHashMaplocks the whole map for writes." It locks one bucket. Since Java 8 there are no segments. - "Reads need a lock." Reads are lock-free, using volatile semantics.
- "
size()is exact." Under concurrent modification it is an estimate. - "
CopyOnWriteArraySetis the concurrent set." It is array-backed with O(n)contains. UseConcurrentHashMap.newKeySet(). - "
ConcurrentHashMapaccepts nulls likeHashMap." It rejects both null keys and null values. - "Concurrent collections make your logic thread-safe." They make each operation safe. Your invariants across several operations are still yours to protect.
Quick recall
Everything you need if you only revisit this box.
Collections.synchronizedXlocks the whole collection per call and does not make compound operations atomic.ConcurrentHashMap: lock-free volatile reads, per-bucket write locking, cooperative resize, striped size counters. Java 8 replaced Java 7's 16 segments.- Use its atomic compound methods —
putIfAbsent,merge,compute,computeIfAbsent,replace(k,old,new)— instead of check-then-act. - Keep remapping functions short and side-effect free; they run under the bucket lock.
- It forbids null keys and values, so
getreturningnullunambiguously means absent. ConcurrentHashMap.newKeySet()for sets;CopyOnWriteArrayListonly when reads vastly outnumber writes.- Iteration is weakly consistent and never throws, so
size()is approximate — never base a decision on it. - Immutable shared data needs no concurrent collection at all.
Test yourself
Answer these before moving on — recall is what makes it stick.