PrepZone Logo
PrepZone

Thread-Safe Collections

Why synchronizedMap bottlenecks, how ConcurrentHashMap avoids it, and when copy-on-write wins.

Read these first

Why this matters

  • Collections.synchronizedMap looks 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.
  • ConcurrentHashMap is 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

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

Java
// 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

synchronizedMap — one lock
Thread A writesHolds the only lock
Thread B waits
Thread C waits
ConcurrentHashMap — per bucket
Thread A locks bucket 3
Thread B locks bucket 7Runs in parallel
Thread C readsNever blocks
Hashtable and synchronizedMap serialise every thread through a single lock. ConcurrentHashMap locks only the bucket being written, so readers never block at all.

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. synchronized on 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.

Java
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
Java
// 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

AspectConcurrent collectionUse it for
ConcurrentHashMapA shared mapThe default for any concurrent map
ConcurrentHashMap.newKeySet()A shared setBacked by the map; O(1) contains
CopyOnWriteArrayListRead-heavy, write-rare listsListener registries; every write copies
ConcurrentLinkedQueueAn unbounded non-blocking queueLock-free, uses compare-and-swap
LinkedBlockingQueueProducer-consumer with backpressureBounded; put and take block
ConcurrentSkipListMapA sorted concurrent mapThe concurrent answer to TreeMap
  • ConcurrentHashMap

    Concurrent collectionA shared map
    Use it forThe default for any concurrent map
  • ConcurrentHashMap.newKeySet()

    Concurrent collectionA shared set
    Use it forBacked by the map; O(1) contains
  • CopyOnWriteArrayList

    Concurrent collectionRead-heavy, write-rare lists
    Use it forListener registries; every write copies
  • ConcurrentLinkedQueue

    Concurrent collectionAn unbounded non-blocking queue
    Use it forLock-free, uses compare-and-swap
  • LinkedBlockingQueue

    Concurrent collectionProducer-consumer with backpressure
    Use it forBounded; put and take block
  • ConcurrentSkipListMap

    Concurrent collectionA sorted concurrent map
    Use it forThe concurrent answer to TreeMap

ConcurrentHashMap and LinkedBlockingQueue between them cover most real needs.

Java
// The concurrent Set — not CopyOnWriteArraySet, whose contains() is O(n)
Set<String> active = ConcurrentHashMap.newKeySet();
active.add("session-1");
active.remove("session-1");
Java
// 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.

Java
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.of or 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.synchronizedMap makes my code thread-safe." It makes each call atomic. Compound operations and iteration still need external locking.
  • "ConcurrentHashMap locks 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.
  • "CopyOnWriteArraySet is the concurrent set." It is array-backed with O(n) contains. Use ConcurrentHashMap.newKeySet().
  • "ConcurrentHashMap accepts nulls like HashMap." 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.synchronizedX locks 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 get returning null unambiguously means absent.
  • ConcurrentHashMap.newKeySet() for sets; CopyOnWriteArrayList only 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.