Mid-level 4 min · March 06, 2026

ConcurrentHashMap — Why get()+put() Loses Updates

Daily reconciliation showed 3% fewer API counts—no errors, just silent overwrites.

N
Naren · Founder
Plain-English first. Then code. Then the interview question.
About
 ● Production Incident 🔎 Debug Guide
Quick Answer
  • 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)
Plain-English First

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.

ConcurrentHashMapBasics.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
package io.thecodeforge.collections;

import java.util.concurrent.ConcurrentHashMap;

public class ConcurrentHashMapBasics {
    private final ConcurrentHashMap<String, Integer> cache = new ConcurrentHashMap<>();

    // Atomic compute: only inserts if absent, no external lock needed
    public int incrementOrInit(String key) {
        return cache.compute(key, (k, v) -> (v == null) ? 1 : v + 1);
    }

    // Get with default: putIfAbsent is atomic
    public int getOrCreate(String key, int defaultValue) {
        Integer existing = cache.putIfAbsent(key, defaultValue);
        return existing != null ? existing : defaultValue;
    }
}
Forge Tip:
Type this code yourself rather than copy-pasting. The muscle memory of writing it will help it stick.
Production Insight
Null keys cause NullPointerException at runtime — ConcurrentHashMap rejects them unconditionally.
If you need null semantics, use a sentinel dummy object or wrap the key in Optional.
Rule: Never treat ConcurrentHashMap as a drop-in for HashMap without auditing key nullability.
Key Takeaway
ConcurrentHashMap is lock-free for reads and fine-grained for writes.
Use atomic compute operations to avoid the check-then-act race entirely.
The map itself won't corrupt — but your logic can if you mix get+put manually.

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.

io/thecodeforge/collections/InternalMetrics.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
package io.thecodeforge.collections;

import java.util.concurrent.ConcurrentHashMap;

public class InternalMetrics {
    // Simulate hash collision to trigger treeification
    public static void main(String[] args) {
        ConcurrentHashMap<Integer, String> map = new ConcurrentHashMap<>();
        // Force collisions by using Integer objects with same hashCode
        for (int i = 0; i < 12; i++) {
            map.put(i, "value" + i);
        }
        // Internally, if all 12 keys land in same bucket and size>64, tree forms
        // But with default capacity, treeification normally requires >8 collisions + size>64
        System.out.println("Map size: " + map.size());
    }
}
Output
Map size: 12
Segment vs CAS: One Mental Leap
  • 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).
Production Insight
Tree bins require the key class to implement Comparable — otherwise degenerate to O(n).
If you see hotspot locks in JFR on a single bucket, check that your key's hashCode doesn't have pathological distribution.
Performance under heavy resize spikes: table doubling triggers a full rehash across all threads — expect jitter.
Key Takeaway
Java 8 ConcurrentHashMap is not the same as Java 7 — drop the segment mental model.
Per-bucket locking with CAS first insertion gives near-HashMap throughput for reads.
Tree bins save you from hash collision DoS, but only if your keys are Comparable.

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.

io/thecodeforge/collections/AtomicCounter.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
package io.thecodeforge.collections;

import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;

public class AtomicCounter {
    private final ConcurrentHashMap<String, Integer> counts = new ConcurrentHashMap<>();

    public void increment(String key) {
        // Atomic — safe under high concurrency
        counts.compute(key, (k, v) -> (v == null) ? 1 : v + 1);
    }

    public int getCount(String key) {
        return counts.getOrDefault(key, 0);
    }

    public static void main(String[] args) throws InterruptedException {
        AtomicCounter c = new AtomicCounter();
        ExecutorService exec = Executors.newFixedThreadPool(10);
        for (int i = 0; i < 100000; i++) {
            exec.submit(() -> c.increment("visits"));
        }
        exec.shutdown();
        exec.awaitTermination(5, TimeUnit.SECONDS);
        System.out.println("Final count: " + c.getCount("visits")); // Exactly 100000
    }
}
Output
Final count: 100000
Warning: Functions passed to compute must be stateless and fast
The remapping function runs inside the bucket lock. If you do I/O or call a slow service inside compute, you'll block all other writers on that bucket. Keep it pure Java, non-blocking, sub-millisecond.
Production Insight
Slow compute functions cause CPU and lock contention visible as 'blocked threads' in thread dumps.
Never use computeIfAbsent with an expensive object factory for keys that may never be used — the function runs only when absent, but that's still a lock held during construction.
Rule: If the factory touches a database, restructure to async put after DB call.
Key Takeaway
Atomic operations prevent lost updates — never hand-roll get+put.
Keep remapping functions fast and pure — they run inside a critical section.
computeIfAbsent is not lazy if you expect the value to exist most of the time.

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.

io/thecodeforge/collections/PerformanceTest.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
package io.thecodeforge.collections;

import java.util.concurrent.*;
import java.util.*;

public class PerformanceTest {
    static final int OPS = 5_000_000;
    static final int THREADS = 8;

    public static void main(String[] args) throws Exception {
        Map<String, Integer> map = new ConcurrentHashMap<>();
        // Warm-up
        for (int i = 0; i < 100_000; i++) map.put(String.valueOf(i), i);
        long start = System.nanoTime();
        ExecutorService exec = Executors.newFixedThreadPool(THREADS);
        for (int t = 0; t < THREADS; t++) {
            exec.submit(() -> {
                for (int i = 0; i < OPS / THREADS; i++) {
                    map.computeIfPresent("key" + (i % 1000), (k, v) -> v + 1);
                }
            });
        }
        exec.shutdown();
        exec.awaitTermination(1, TimeUnit.MINUTES);
        long elapsed = System.nanoTime() - start;
        System.out.printf("Throughput: %.0f ops/sec%n", OPS * 1e9 / elapsed);
    }
}
Output
Throughput: ~2,500,000 ops/sec
Performance Rule of Thumb
If your read-to-write ratio exceeds 90:10, ConcurrentHashMap adds less than 3% overhead over an unsynchronized HashMap. Prefer it by default for all shared state — the safety is nearly free.
Production Insight
Resizing spikes create latency outliers — JFR shows GC jitter plus table transfer pauses.
If your map holds millions of entries, pre-size it. Table resize growth factor is 2x, which can temporarily double memory.
Under extreme write-heavy workloads (>100k writes/sec), consider striped counters instead — ConcurrentHashMap's atomic per-key scaling can't match LongAdder.
Key Takeaway
ConcurrentHashMap is read-optimised — nearly HashMap speed for read-heavy workloads.
Pre-size for expected load to avoid resize latency spikes.
For pure counter scenarios, LongAdder is more scalable than map-based counters.

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, size() may be stale — it's an estimate, not a precise count. For precise size, use 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.

io/thecodeforge/collections/PitfallExamples.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
package io.thecodeforge.collections;

import java.util.concurrent.ConcurrentHashMap;

public class PitfallExamples {
    private final ConcurrentHashMap<String, String> cache = new ConcurrentHashMap<>();

    // BAD: race between containsKey and get
    public String badGet(String key) {
        if (cache.containsKey(key)) {
            return cache.get(key); // value might have been removed
        }
        return null;
    }

    // GOOD: atomic get + null check
    public String goodGet(String key) {
        return cache.get(key); // null means absent
    }

    // BAD: size() for accounting (approximate, not exact)
    public int badSize() {
        return cache.size(); // could be outdated instantly
    }

    // OK: iterate snapshot
    public void printSnapshot() {
        // Copy for consistent iteration
        var snapshot = new ConcurrentHashMap<>(cache);
        snapshot.forEach((k, v) -> System.out.println(k + " -> " + v));
    }
}
Watch Out: computeIfAbsent can return stale value under rare race
If two threads call computeIfAbsent for the same key simultaneously, one will enter the lambda and the other will wait for the lock. The waiting thread gets the value returned by the first — but the first may have computed a value that depends on external state that changed. This is rare but possible. For true idempotency, use compute with a merge function.
Production Insight
Weakly consistent iterators cause confusing bugs in reporting code — snapshot before iterating over large sets.
size() accuracy degrades with active modifications — use mappingCount() but know it's an estimate.
Rule: Treat ConcurrentHashMap as per-operation safe, not cross-operation atomic without its atomic methods.
Key Takeaway
Don't sequence get after containsKey — one atomic get is all you need.
Iteration is weakly consistent — snapshot for point-in-time views.
Deadlocks are impossible inside ConcurrentHashMap, but your lambda can reintroduce them.
● Production incidentPOST-MORTEMseverity: high

The Anomaly That Evaporates: Lost API Request Counts

Symptom
Daily reconciliation showed 3% fewer processed requests than expected. No errors, no timeouts, just fewer increments.
Assumption
The team assumed ConcurrentHashMap was thread-safe for counting because each individual put is atomic.
Root cause
The code used 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.
Fix
Replaced the two-liner with map.merge(key, 1, Integer::sum). This locks the bucket for the duration of the remap, so the addition happens atomically.
Key lesson
  • 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.
Production debug guideSymptom → Root Cause → Fastest Fix4 entries
Symptom · 01
NullPointerException at ConcurrentHashMap.put or get
Fix
Immediately check for null keys or values. Audit all map interactions for null passing.
Symptom · 02
Lost counters or missing entries under concurrent writes
Fix
Look for get() + put() pattern — replace with compute() or merge(). Use jstack to identify threads racing on same key.
Symptom · 03
Unexpected OOM with large map size
Fix
Verify you didn't accidentally share a HashMap across threads. Dump heap, find map object. Check if CHM's resize doubled memory unexpectedly.
Symptom · 04
Threads blocked in ConcurrentHashMap methods
Fix
Capture thread dump. Look for stack trace showing compute lambda — typically a slow external call (DB, HTTP). The bucket lock serialises all operations on that key.
★ ConcurrentHashMap Debug Cheat SheetQuick diagnostic steps for common CHM issues in production
NullPointerException on put/get
Immediate action
Check for null keys — ConcurrentHashMap does not allow null keys or values
Commands
grep -r '\.put\(null' yourcode.java
Add NullPointerException catch and log the key
Fix now
Replace null with a sentinel object or use Optional wrapper
Lost updates in counter logic+
Immediate action
Replace get+put with compute or merge
Commands
Find map.get followed by map.put in same method
Refactor to map.merge(key, 1, Integer::sum)
Fix now
Use compute, merge, or putIfAbsent for atomicity
Unexpected ConcurrentModificationException+
Immediate action
Check if you're using HashMap or TreeMap instead of CHM
Commands
Verify map type: System.out.println(map.getClass().getName())
If CHM, exception should never happen — check for non-CHM instance
Fix now
Use ConcurrentHashMap if concurrent iteration and modification are needed
Thread stuck / high CPU on map operation+
Immediate action
Blocked threads due to slow remapping function in compute
Commands
jstack <pid> | grep -A 20 'ConcurrentHashMap.*compute'
Check for I/O or blocking calls inside compute lambdas
Fix now
Refactor slow operations outside the compute function
ConcurrentHashMap vs HashMap vs Hashtable vs SynchronizedMap
PropertyHashMapHashtableSynchronizedMapConcurrentHashMap
Thread safetyNoYes (global lock)Yes (global lock)Yes (per-bucket lock)
Null keys/valuesAllows one null key, many null valuesNo nullsDepends on wrapped mapNo nulls
Performance (reads)FastestSlow (lock contention)Slow (lock contention)Near-HashMap
Performance (writes)Fastest (single thread)SlowSlowFast (per-bucket)
Concurrent iterationThrows ConcurrentModificationExceptionThrows ConcurrentModificationExceptionThrows ConcurrentModificationExceptionWeakly consistent (never throws)
Composite operationsNot atomicNot atomicNot atomicAtomic (compute, putIfAbsent)
Size calculationO(1) exactO(1) exactO(1) exactApproximate O(1)

Key takeaways

1
ConcurrentHashMap gives near-HashMap read performance with thread-safe writes
use it as default for shared mutable state.
2
Atomic composite methods (compute, merge) eliminate race conditions in check-then-act sequences.
3
Never assume operations that are individually safe compose into safe sequences
the map can't protect your logic.
4
Pre-size for expected load to avoid resize latency spikes that block all threads briefly.
5
Weakly consistent iterators are safe but not point-in-time
snapshot for consistent views.

Common mistakes to avoid

4 patterns
×

Using null keys or values

Symptom
NullPointerException at runtime with no clear cause. The stack points to InternalPut or InternalGet inside CHM.
Fix
Audit all usages. Replace null with a sentinel object (e.g., private static final Object NULL_SENTINEL = new Object()).
×

Hand-rolling atomic operations with separate get and put

Symptom
Lost updates under concurrent load. Counters end up lower than expected. Only visible under stress testing or production.
Fix
Replace all get+put sequences with compute, merge, or putIfAbsent. These lock the bucket for the entire operation.
×

Assuming size() or mappingCount() returns exact value

Symptom
Incorrect calculations in monitoring or batch jobs. The size fluctuates under writes and never stabilises.
Fix
If you need an exact count at a point in time, take a snapshot via new ConcurrentHashMap<>(original) and iterate the snapshot. For live thresholds, accept the approximation.
×

Putting blocking code inside compute lambda

Symptom
Threads blocked inside ConcurrentHashMap.compute, causing latency spikes and connection pool exhaustion.
Fix
Always keep lambdas pure: no network I/O, no DB queries, no thread sleeps. Pull the data before entering the atomic scope.
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01SENIOR
How does ConcurrentHashMap achieve thread safety without synchronizing e...
Q02SENIOR
Can you use ConcurrentHashMap as a cache with expiration? What are the l...
Q03SENIOR
Explain the difference between ConcurrentHashMap and Collections.synchro...
Q04SENIOR
What is a weakly consistent iterator, and what guarantees does it provid...
Q05SENIOR
Why does ConcurrentHashMap not allow null keys or values?
Q01 of 05SENIOR

How does ConcurrentHashMap achieve thread safety without synchronizing every operation?

ANSWER
It uses a combination of CAS (Compare-And-Swap) for insertion into empty buckets and synchronised blocks only on the head of a non-empty bucket. Reads are lock-free — they just dereference a volatile reference to the Node array. In Java 8, the segment model was replaced with per-bucket locking. This gives fine-grained concurrency: two threads writing to different buckets can proceed in parallel. The synchronised block is very short — it typically just swaps one Node reference. For composite operations, the lock is held across the entire compute function, but only for that one bucket.
FAQ · 5 QUESTIONS

Frequently Asked Questions

01
Is ConcurrentHashMap fully thread-safe?
02
Does ConcurrentHashMap use the same hashing as HashMap?
03
Can I use ConcurrentHashMap in Java 7 code?
04
How do I safely iterate over a ConcurrentHashMap?
05
Is there a size limit for ConcurrentHashMap?
🔥

That's Collections. Mark it forged?

4 min read · try the examples if you haven't

Previous
Deque and ArrayDeque in Java
14 / 21 · Collections
Next
EnumMap and EnumSet in Java