ConcurrentHashMap — Why get()+put() Loses Updates
Daily reconciliation showed 3% fewer API counts—no errors, just silent overwrites.
- 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.
Performance Characteristics and Tuning Trade-offs
ConcurrentHashMap performs best when reads dominate writes and writes are spread across different keys. Under pure read workloads, throughput matches HashMap within 5–10% because reads are essentially volatile reads with no locking. As write contention increases, performance degrades gracefully: with 100% writes, you still get 3–5x better than synchronizedMap due to per-bucket locking.
The initial capacity and concurrencyLevel parameters matter less in Java 8. concurrencyLevel is only a hint for sizing, not the number of allowed segments. Setting initial capacity too high wastes memory; too low triggers resize overhead. A good rule: ConcurrentHashMap<>(expectedSize * 1.1 / 0.75f) to allocate at ~75% load to avoid resizing during warm-up.
Resizing is more expensive than in HashMap because the table must be visible to all threads consistently. The implementation uses a transfer operation where each thread can help advance the table by stealing work from other threads (a kind of cooperative resizing). This reduces latency spikes but doesn't eliminate them — expect pauses of 1–10 ms on maps of hundreds of thousands of entries.
Production Pitfalls: What Breaks When You Assume It's Just 'Thread-Safe'
ConcurrentHashMap guarantees thread safety for its own operations — but not for sequences of operations you combine externally. A classic pitfall is iterating while another thread modifies: the iterator is weakly consistent (it reflects the state at some point during iteration, may miss recent changes, but never throws ConcurrentModificationException). If you need a point-in-time snapshot, call new ConcurrentHashMap<>(map) to copy.
Another common mistake: using containsKey then get. Even though each call is safe, the value might be removed between the two calls. Use get directly and check for null, or use atomic computeIfAbsent. Similarly, may be stale — it's an estimate, not a precise count. For precise size, use size()mappingCount() which returns a long but is still approximate.
Deadlocks are impossible because ConcurrentHashMap doesn't use external locks on its own. But if your remapping function inside compute acquires another lock (e.g., a database connection pool lock), you can still deadlock. Never call a blocking or locked method from inside a compute lambda.
The Anomaly That Evaporates: Lost API Request Counts
map.get(key); map.put(key, value + 1) — two separate atomic operations. Between the get and the put, another thread incremented the same key, and the second put overwrote the first increment without seeing it. Lost update.map.merge(key, 1, Integer::sum). This locks the bucket for the duration of the remap, so the addition happens atomically.- ConcurrentHashMap's per-operation safety does NOT make a sequence of operations safe.
- Always use atomic composite methods (compute, merge, putIfAbsent) for check-then-act logic.
- JFR show 'put' as fast, but 'compute' adds negligible overhead — there's no excuse for hand-rolled get+put.
get() + put() pattern — replace with compute() or merge(). Use jstack to identify threads racing on same key.Key takeaways
Common mistakes to avoid
4 patternsUsing null keys or values
Object()).Hand-rolling atomic operations with separate get and put
Assuming size() or mappingCount() returns exact value
Putting blocking code inside compute lambda
Interview Questions on This Topic
How does ConcurrentHashMap achieve thread safety without synchronizing every operation?
Frequently Asked Questions
That's Collections. Mark it forged?
4 min read · try the examples if you haven't