ConcurrentHashMap — Why get()+put() Loses Updates
Daily reconciliation showed 3% fewer API counts—no errors, just silent overwrites.
20+ years shipping production Java in banking & fintech. Everything here is grounded in real deployments.
- ✓Deep production experience
- ✓Understanding of internals and trade-offs
- ✓Experience debugging complex systems
- A thread-safe map that uses CAS + per-bucket locks instead of a global lock
- Reads are lock-free; writes lock only a single bucket (not the entire map)
- Java 8 uses a Node array + tree bins (TREEIFY_THRESHOLD=8) for hash collisions
- Atomic operations like computeIfAbsent are fully thread-safe with no external sync needed
- At ~80% load under heavy write contention, throughput is 5-10x higher than synchronizedMap
- Biggest mistake: assuming null keys are allowed (they are not — use a separate sentinel)
Picture a huge library with 16 separate reading rooms. A normal library (HashMap) locks the entire building when one person updates a catalogue. A smarter library (ConcurrentHashMap) only locks the one room being updated — every other room stays open for readers and writers. That's the whole idea: fine-grained locking so thousands of threads can work simultaneously without stepping on each other.
In any production Java service that handles real traffic, you'll have multiple threads reading and writing shared data at the same time. HashMap silently corrupts data under concurrent writes. Hashtable and Collections.synchronizedMap fix the corruption by locking the entire map on every single operation — which turns your multi-threaded service into a single-threaded bottleneck the moment load spikes. Neither option is acceptable when you're building something that has to stay fast under pressure.
ConcurrentHashMap solves this by being surgically precise about where and when it locks. Instead of one global lock, it uses a strategy that lets concurrent reads proceed with zero locking and partitions writes so different threads rarely block each other. In Java 8 the implementation was completely rewritten to ditch the old 'segment' model in favour of CAS (Compare-And-Swap) operations and per-bucket synchronization, pushing throughput to near-HashMap levels while keeping full thread safety.
By the end of this article you'll understand exactly how ConcurrentHashMap achieves that balance — the internal data structure, the locking strategy that changed in Java 8, the atomic operations that let you avoid race conditions in your own code, and the real production mistakes that will bite you if you treat it like a drop-in replacement for HashMap without understanding what it actually guarantees.
What is ConcurrentHashMap in Java?
ConcurrentHashMap sits between HashMap and Hashtable. It gives you the same API as HashMap but guarantees thread safety without the global lock penalty. Internally it splits the bucket array into independent regions — originally segments, now individual buckets with CAS for first insert and synchronized only on the bucket head for updates. This design means reads never block, and writes only contend with other writes hitting the exact same bucket.
You use it exactly like HashMap — put, get, remove, containsKey — but you can call these from any thread without external synchronization. The real power comes from the atomic composite operations: putIfAbsent, computeIfAbsent, compute, and merge. These let you perform check-then-act sequences atomically with zero chance of a race condition.
Java 7 vs Java 8 Internals: From Segments to CAS + Tree Bins
Before Java 8, ConcurrentHashMap used a Segment array — each segment was itself a smaller HashMap with its own lock. The default concurrency level of 16 meant up to 16 threads could write at once, one per segment. Reads needed locking only on volatile reads of segment table references. It worked, but the fixed segment count limited scalability when the map grew large.
Java 8 threw out the segment model entirely. The new design uses a single Node array (table) where each bucket is either a Node, a TreeNode (for tree bins), or a ReservationNode (during compute). Updates use CAS on the table reference for the first node, then synchronize on the bucket head for structural modifications. This gives vastly better concurrency because the lock granularity is per-bucket — hundreds of buckets mean hundreds of concurrent writers. Tree bins kick in when a bucket exceeds TREEIFY_THRESHOLD (8) and the map size is over 64, converting the linked list into a balanced tree for O(log n) lookups under high collisions.
- Java 7: 16 rooms, each with own lock. Two writers in different rooms proceed in parallel.
- Java 8: hundreds of buckets, lock only when a bucket has >1 node. CAS handles the first insertion.
- Tree bins: similar to how HashMap converts to tree in Java 8+, but here it's even more critical because high contention on one bucket benefits from O(log n).
Atomic Operations and the Check-Then-Act Trap
The biggest benefit of ConcurrentHashMap over a synchronized wrapper is the set of atomic compound operations. Methods like putIfAbsent, computeIfAbsent, compute, and merge run their function inside the bucket lock, making the entire read-write sequence atomic. If you try to implement the same logic with get followed by put, you'll get a race condition that loses updates.
Consider a request counter: map.compute(key, (k, v) -> v == null ? 1 : v + 1) is safe. The equivalent Integer v = map.get(key); map.put(key, v == null ? 1 : v + 1); can lose increments because another thread might update between the get and put. This is the classic check-then-act race that ConcurrentHashMap's atomic methods eliminate.