How does ConcurrentHashMap achieve thread safety?
Since Java 8 there are no segments. It locks one bin at a time — a CAS to install the first node in an empty bin, and synchronized on the bin's head node otherwise — while reads never lock at all. The catch is that per-key atomicity does not make your sequence of calls atomic.
The Answer
Say this in the room. 45 seconds.
- Segments are gone. That answer is from Java 7 and it is the fastest way to date yourself in this question.
- Since Java 8 the map is one table, and the lock is one bin. Empty bin: a CAS installs the first node, no lock at all. Occupied bin:
synchronizedon that bin's head node. - So write concurrency scales with the table size instead of a
concurrencyLevelfixed at construction. - Reads never lock.
Node.valandNode.nextarevolatile, soget()is a plain read that is guaranteed to see completed writes. size()is an estimate maintained by striped counters, iterators are weakly consistent, and neither throwsConcurrentModificationException.- Null keys and values are rejected, unlike
HashMap. - The part that actually bites: every method is atomic, and your sequence of methods is not.
containsKeythenputis a race on a perfectly thread-safe map.
Understand It
What it exists to prevent
A HashMap written by several threads at once is undefined behaviour, and it fails in three different ways. This block races one deliberately, alongside a ConcurrentHashMap doing identical work — eight threads each writing five thousand disjoint keys, so nothing should collide and nothing should be lost:
Map<Integer, Integer> plain = new HashMap<>();
int[] plainOutcome = fill(plain);
Map<Integer, Integer> concurrent = new ConcurrentHashMap<>();
int[] concurrentOutcome = fill(concurrent);
System.out.println(THREADS + " threads, " + WRITES_PER_THREAD + " disjoint keys each");
System.out.println(report("HashMap", plain, plainOutcome));
System.out.println(report("ConcurrentHashMap", concurrent, concurrentOutcome));8 threads, 5000 disjoint keys each
HashMap 30109 of 40000, lost? yes, threw: 0, still running: 0
ConcurrentHashMap 40000 of 40000, lost? no, threw: 0, still running: 0The numbers change on every run, which is why this block is verified for shape. Across runs on the same machine that HashMap line has printed all three of:
lost? yes— every single time. A quarter of the entries typically vanish.threw: 8— every thread dying withClassCastException: HashMap$Node cannot be cast to HashMap$TreeNode, because two threads treeified the same bin at once.still running: 1— a thread that never finished, spinning in a bin that a concurrent resize left with a cycle in it.
That third one is why the harness runs those threads as daemons behind a bounded join. This page is compiled and executed on every deploy, and a demonstration that can hang the build is not one worth having. It is also the honest headline: a raced HashMap does not merely lose data, it can corrupt its own structure and stop responding.
ConcurrentHashMap prints 40000 of 40000, every run, with no locks anywhere in the reading path.
How it does it, since Java 8
The Java 7 design was an array of segments, each an independent ReentrantLock covering a slice of the table. concurrencyLevel — default 16 — fixed how many segments existed, at construction, forever. Sixteen writers maximum, whether the map held a hundred entries or ten million.
Java 8 threw that away. There is one table, exactly like HashMap's, and locking is per bin:
| Situation | What happens |
|---|---|
| Bin is empty | A single CAS installs the new node. No lock is taken at all. |
| Bin is occupied | synchronized on the head node of that bin, then the usual chain or tree insert. |
| Bin is being moved (resize) | The head is a ForwardingNode; the writer helps with the transfer instead of blocking. |
| Any read | No lock. val and next are volatile. |
Three consequences worth stating out loud in an interview:
- Lock granularity is a bin, so the number of concurrent writers scales with the table. A bigger map is a more concurrent map.
- The uncontended path has no lock, only a CAS, which is why writes to a sparse map are cheap.
- Resizing is cooperative. A thread that finds a bin under transfer joins the transfer rather than waiting, so a resize does not serialise everyone behind one thread.
Long bins treeify into red-black trees on the same rules as HashMap — eight nodes in a bin and at least 64 table slots — which bounds the damage from a bad hashCode.
Thread-safe methods, unsafe sequences
This is the one that costs real money, and the one interviewers use to separate "I know the class name" from "I have used it".
Every individual method is atomic. Two of them in a row are not. Here eight threads race to initialise five thousand keys — once by checking then putting, once with computeIfAbsent. The counter records how many times the expensive value was actually built:
ConcurrentHashMap<Integer, Integer> checkThenAct = new ConcurrentHashMap<>();
AtomicInteger naive = new AtomicInteger();
hammer(id -> {
for (int k = 0; k < KEYS; k++) {
if (!checkThenAct.containsKey(k)) checkThenAct.put(k, buildValue(naive, k));
}
});
ConcurrentHashMap<Integer, Integer> atomic = new ConcurrentHashMap<>();
AtomicInteger guarded = new AtomicInteger();
hammer(id -> {
for (int k = 0; k < KEYS; k++) atomic.computeIfAbsent(k, key -> buildValue(guarded, key));
});
System.out.println(KEYS + " keys, " + THREADS + " threads racing to initialise each");
System.out.println(" containsKey then put : value built " + naive.get() + " times");
System.out.println(" computeIfAbsent : value built " + guarded.get() + " times");
System.out.println(" built twice for some key? "
+ (naive.get() > KEYS ? "yes" : "no") + " / " + (guarded.get() > KEYS ? "yes" : "no"));5000 keys, 8 threads racing to initialise each
containsKey then put : value built 12795 times
computeIfAbsent : value built 5000 times
built twice for some key? yes / noFive thousand keys, and the check-then-act version built the value twelve thousand times. If that value is a database query or an HTTP call, you have just tripled the load on something downstream and called it a cache.
computeIfAbsent builds exactly 5000, because the check and the insert happen inside the bin's lock.
The atomic compound operations, and what each is for:
| Method | Use it when |
|---|---|
putIfAbsent | You already have the value and only want it if the key is free |
computeIfAbsent | Building the value is expensive and must happen once |
compute | The new value depends on the old one |
merge | Accumulating — counters, sums, appending to a list |
replace(k, old, new) | Optimistic update; fails if someone changed it first |
One trap inside the fix: the function you pass to computeIfAbsent runs while the bin is locked. Keep it short, and never touch the same map from inside it — a recursive update on the same bin throws IllegalStateException, and on a different bin it can deadlock.
Iterators, size, and why they are honest about it
Map<String, String> hash = new HashMap<>(Map.of("a", "1", "b", "2"));
try {
for (String key : hash.keySet()) hash.put("c", "3");
System.out.println(" HashMap : iterated, size now " + hash.size());
} catch (ConcurrentModificationException e) {
System.out.println(" HashMap : ConcurrentModificationException");
}
Map<String, String> chm = new ConcurrentHashMap<>(Map.of("a", "1", "b", "2"));
for (String key : chm.keySet()) chm.put("c", "3");
System.out.println(" ConcurrentHashMap : iterated, size now " + chm.size()); HashMap : ConcurrentModificationException
ConcurrentHashMap : iterated, size now 3HashMap's iterator is fail-fast: it tracks a modification count and throws the moment it notices a change. That is a debugging aid, not a safety guarantee — it is best-effort and must never be used for control flow.
ConcurrentHashMap's iterator is weakly consistent: it never throws, it reflects the map at some point at or after creation, and it may or may not show changes made during iteration. It walks the real table, so it sees whatever is there when it arrives.
The same honesty applies to size(). It is maintained by striped counter cells rather than one contended field, so it is an estimate and can be stale the instant it returns. Use it for metrics and logs; never for a decision like "is there room for one more". mappingCount() is the same thing returning a long, and is the correct choice for large maps.
Why null is rejected
HashMap allows one null key and any number of null values. ConcurrentHashMap rejects both, and the reason is not tidiness.
In a single-threaded map, get(k) == null is ambiguous — absent, or present with a null value — and you resolve it with containsKey. In a concurrent map that resolution is worthless: the entry can be added or removed between the two calls, so you can never establish which case you were in. Doug Lea's position is that the ambiguity is unresolvable, so the value is disallowed.
The practical consequence: map.get(missing) returning null is unambiguous here, which is what makes getOrDefault and the compute methods reliable.
Against the alternatives
| Lock granularity | Reads | Iterator | |
|---|---|---|---|
Hashtable | The whole map, every method | Locked | Fail-fast |
Collections.synchronizedMap | The whole map, one monitor | Locked | Fail-fast, and you must synchronize while iterating |
ConcurrentHashMap | One bin | Lock-free | Weakly consistent |
synchronizedMap has one trap people forget: iteration is not covered by the wrapper. You have to hold the map's monitor manually for the whole loop, and almost nobody does. If your access pattern is genuinely read-mostly with rare bulk replacement, a volatile reference to an immutable map beats all three.
Interviewer's Next Move
The question after the question. This is what the round is actually testing.
1. "How does ConcurrentHashMap achieve thread safety?"
Since Java 8, per-bin locking on one table: a CAS installs the first node in an empty bin, and synchronized on the bin's head node covers the rest. Reads never lock because the node fields are volatile. Segments were removed in Java 8.
2. "How did it work before Java 8?"
An array of segments, each an independent ReentrantLock over a slice of the table, with the count fixed at construction by concurrencyLevel — default 16. That capped concurrent writers at 16 regardless of map size.
3. "So what is the lock granularity now?" One bin. Which means concurrency scales with the table, and a larger map admits more simultaneous writers — the opposite of the old fixed ceiling.
4. "Is if (!map.containsKey(k)) map.put(k, v) safe on a ConcurrentHashMap?"
No. Both calls are atomic; the sequence is not. Another thread can insert between them. Use putIfAbsent or computeIfAbsent — and at eight threads over five thousand keys, the naive version builds the value roughly two and a half times too often.
5. "Why can't it hold null keys or values?"
Because get returning null would be ambiguous between absent and present-with-null, and in a concurrent map you cannot resolve that with a follow-up containsKey — the entry may change between the calls. Disallowing null makes a null return mean exactly one thing.
6. "Can you trust size()?"
Not for decisions. It is an estimate from striped counter cells and may be stale on return. Fine for metrics; wrong for capacity checks. mappingCount() is the long version.
7. "What happens if you iterate while another thread writes?"
Nothing bad. The iterator is weakly consistent — it never throws and may or may not reflect concurrent changes. HashMap would throw ConcurrentModificationException, and even that is best-effort and must not be relied on.
8. "ConcurrentHashMap or Collections.synchronizedMap?"
ConcurrentHashMap almost always: bin-level locking versus one monitor for the whole map, and lock-free reads. synchronizedMap also requires you to hold the monitor manually around any iteration, which is a trap most code gets wrong.
Code traps
Trap A — predict before you run:
ConcurrentHashMap<String, Integer> counts = new ConcurrentHashMap<>();
void record(String key) {
counts.put(key, counts.getOrDefault(key, 0) + 1);
}
Answer
Counts are lost. getOrDefault and put are each atomic; the read-modify-write across them is not, so two threads read the same value and both write the same increment. This is count++ wearing a map.
Use counts.merge(key, 1, Integer::sum) — or computeIfAbsent(key, k -> new AtomicInteger()).incrementAndGet() when you also need to read the counter without touching the map.
Trap B:
ConcurrentHashMap<String, List<String>> groups = new ConcurrentHashMap<>();
void add(String group, String member) {
groups.computeIfAbsent(group, g -> new ArrayList<>()).add(member);
}
Answer
computeIfAbsent correctly creates exactly one list per group. Then .add(member) happens outside the bin lock, on a plain ArrayList, from several threads at once — so the list itself is corrupted, losing elements or throwing ArrayIndexOutOfBoundsException.
The map is thread-safe. What you stored in it is not. Use a concurrent list, or do the whole update inside compute, or wrap it: computeIfAbsent(g, k -> Collections.synchronizedList(new ArrayList<>())).
Trap C:
if (cache.size() < MAX_ENTRIES) {
cache.put(key, value);
}
Answer
Two problems at once. size() is an estimate, so the comparison may be against a stale number — and even with an exact size, check-then-act lets any number of threads pass the check together and all put.
A capacity bound is an invariant over the whole map, and per-key atomicity cannot give you one. You need a lock over the check and the insert, or a cache library that maintains the bound itself.
Common wrong answers
| Said in interviews | Reality |
|---|---|
| "It divides the map into segments and locks each one." | Java 7. Segments were removed in Java 8; the lock is now one bin. |
| "Reads take a read lock." | Reads take no lock at all. The node fields are volatile. |
| "It's thread-safe, so my code using it is thread-safe." | Each method is atomic. Your sequence of methods is not. |
"concurrencyLevel tunes how parallel it is." | It is a sizing hint now. It has fixed nothing since Java 7. |
"size() gives the exact count." | An estimate from striped counters, potentially stale on return. |
| "Iterating while writing throws." | It is weakly consistent and never throws. HashMap is the one that throws. |
Check Yourself
Q1. Where exactly is the lock taken on a write, and when is none taken at all?
Answer
On an occupied bin, synchronized on that bin's head node — so the granularity is one bin. On an empty bin, no lock at all: a single CAS installs the first node. And if the bin is a ForwardingNode the writer helps complete the resize instead of blocking.
Q2. Your cache uses containsKey then put and the database is seeing far more queries than there are distinct keys. Why, and what is the fix?
Answer
Both calls are atomic but the pair is not, so many threads pass the check for the same key and all build the value. computeIfAbsent performs the check and the insert inside the bin lock, so the value is built once — keeping the mapping function short, since it runs while the bin is held.
Q3. map.computeIfAbsent(k, x -> new ArrayList<>()).add(item) — what is still wrong?
Answer
The list. The map guarantees one list is created, then add runs outside the lock on a plain ArrayList from multiple threads, corrupting it. Thread-safety of a container says nothing about what you put inside it.
Practice
| Tier | Exercise | Time |
|---|---|---|
| Warm-up | Watch a HashMap come apart | 10 min |
| Challenge | Make five compound operations atomic | 25 min |
| Production | The cache that computed everything twice | 45 min |
| Interview | Full round replay | 10 min |
What changed, and when
Version boundaries are where follow-ups live. Know the change and the behaviour it replaced.
- Java 8LTS
Rewritten with no segments at all: an empty bin takes a CAS, an occupied bin takes synchronized on its head node, so lock granularity is one bin and concurrency scales with the table. size() became an estimate maintained by striped counter cells, and long bins treeify like HashMap's.
Before Java 8: The map was an array of Segments, each an independent ReentrantLock covering a slice of the table. The number of segments was fixed at construction by concurrencyLevel — default 16 — so at most 16 threads could write at once no matter how large the map grew.
Practice ladder
Reading this page is not knowing it. Four tiers, ending in a real incident.
- Warm-up10 min
Watch a HashMap come apart
One concept, guided. Near-impossible to fail.
- Challenge25 min
Make five compound operations atomic
Edge cases. You have to reason, and two valid fixes differ.
- Production incident45 min
The cache that computed everything twice
A real incident: symptom first, cause hidden, tradeoff at the end.
- Interview replay10 min
Full round replay — ConcurrentHashMap
Timed verbal replay with pass/fail criteria per follow-up.
Where this question goes next
- What is the happens-before relationship?
- hashmap thread safety — not written yet
- atomic vs lock — not written yet
- executor service — not written yet
Questions that lead here
How does HashMap work internally?
A lazily-created array of buckets indexed by a spread hash; collisions chain into a linked list that becomes a red-black tree only when the bin holds 8+ nodes AND the table is at least 64 slots.
Asked constantlyintermediate1–8 yrs9 min readCollectionsWhat does volatile guarantee, and what does it not?
volatile guarantees visibility and ordering: a write is seen by any later read, and operations are not reordered across it. It never guarantees atomicity, so volatile count++ still loses updates — it is three operations, and volatile makes each of them visible without making the trio indivisible.
Asked constantlyintermediate1–12 yrs12 min readConcurrencyWhat is the happens-before relationship?
A guarantee, not a statement about time: if A happens-before B, everything A wrote is visible to B. Without an edge between two threads there is no guarantee at all, whatever the clock says. The practical payoff is the opposite of what people expect — most fields crossing an existing edge need no volatile.
Asked constantlyintermediate2–12 yrs12 min readConcurrency
Every runnable example above was compiled and executed against openjdk 21.0.11 on this build, and its output diffed against what this page claims. Last updated 2026-08-28.