Hashtable, synchronized HashMap, and ConcurrentHashMap before and after Java 8 internals — why lock granularity is the entire story, and where ConcurrentHashMap alone still isn't enough.
Published September 22, 2026
Once HashMap Deep Dive is solid, the natural follow-up is: what do you use when multiple threads touch the map concurrently? Three real answers exist, and they differ almost entirely in lock granularity — how much of the map a thread blocks while it works.
Map<String, Integer> map = new Hashtable<>();
Every public method on Hashtable is synchronized on the instance itself. A put() from thread A blocks a get() from thread B even if they touch completely unrelated keys — the entire map is one lock. This is why it's effectively obsolete: it gives you correctness at the cost of serializing all access, regardless of actual contention, which throws away most of the benefit of having multiple threads in the first place.
Map<String, Integer> map = Collections.synchronizedMap(new HashMap<>());
This wraps a plain HashMap and synchronizes on a shared lock for every method call — functionally the same coarse-grained locking as Hashtable, just retrofittable onto any Map. Neither this nor Hashtable scale under real concurrent load; both exist mainly for legacy compatibility or trivially low-contention cases.
One shared trap with both: compound operations still aren't atomic even though each individual method call is synchronized.
if (!map.containsKey(k)) map.put(k, v); // NOT atomic — two separate synchronized calls
Between the containsKey and the put, another thread can insert the same key — this is exactly the gap ConcurrentHashMap.putIfAbsent() closes atomically.
Before Java 8, ConcurrentHashMap divided its internal table into 16 segments by default, each independently lockable. A put() only locks the single segment its key hashes into — up to 16 threads could write to different segments fully in parallel, a massive improvement over one lock for the whole map. Reads were mostly lock-free (volatile reads).
Java 8 dropped segments entirely and moved to per-bin (per-bucket) synchronization, using CAS (compare-and-swap) operations for most updates:
synchronized only on that specific bin's first node — not the whole map, not even a whole segment, just that one bucket.This pushed concurrency granularity from "16 segments" to effectively "one lock per bucket," which for a table with thousands of buckets means orders of magnitude less contention under real concurrent write load.
Reads (get()) don't synchronize at all in the common case — they rely on volatile reads of each Node's fields, guaranteeing visibility of the latest write without acquiring a lock. This is why ConcurrentHashMap reads scale almost linearly with thread count, in contrast to Hashtable, where even reads are serialized behind the single lock.
map.computeIfAbsent(key, k -> expensiveInitialization(k));
This is atomic per key — if two threads call computeIfAbsent for the same key simultaneously, the mapping function runs exactly once for that key, and both threads see the same resulting value. This closes the check-then-act gap that plagues Collections.synchronizedMap() (see above), and it's the correct tool for lazy-initialization-under-concurrency patterns (e.g. a per-key cache).
The trap: the mapping function must not attempt to modify the same map recursively (e.g. call map.put() on a different key from inside the lambda) — this can deadlock or throw IllegalStateException, since the bin is locked for the duration of the computation.
Every individual operation on ConcurrentHashMap is thread-safe. Compound operations spanning multiple keys are not. Example: transferring a value between two keys —
Integer fromBalance = map.get("accountA");
map.put("accountA", fromBalance - amount);
map.put("accountB", map.get("accountB") + amount); // race window between these calls
Nothing in ConcurrentHashMap's contract protects multi-key invariants — another thread can observe or modify "accountB" in the gap between these lines. This needs external locking (a lock per logical transaction, or moving to a different concurrency-control strategy entirely, like optimistic locking with a version check) — ConcurrentHashMap solves per-key atomicity, not cross-key transactional consistency.
Q: Does ConcurrentHashMap allow null keys or values?
A: No, unlike HashMap. This is deliberate: in a concurrent context, map.get(key) == null is ambiguous between "key absent" and "key present with null value," and resolving that ambiguity safely without a lock isn't possible — disallowing null sidesteps the whole problem.
Q: Is size() on ConcurrentHashMap exact?
A: It's a best-effort estimate under concurrent modification — by the time size() returns, the map may have already changed. For genuinely exact counts under concurrency, you need a different design (e.g. an external atomic counter maintained alongside the map).
Q: Why did segments disappear in Java 8 instead of just tuning the segment count? A: Segments capped concurrency at a fixed number (16 by default) regardless of table size — a huge table still had only 16 lockable units. Per-bin locking scales the number of independently-lockable units with the table itself, which is strictly finer-grained and removes the fixed ceiling.
Q: When would you still reach for Collections.synchronizedMap() over ConcurrentHashMap today?
A: Rarely — mainly when you need null keys/values and can tolerate coarse locking, or when wrapping an existing Map implementation that isn't HashMap and no concurrent equivalent exists for it.