ConcurrentHashMap Internals Explained: Bins, Locks, and Lock-Free Reads
-
Last Updated: October 22, 2026
-
By: javahandson
-
Series

A clear guide to ConcurrentHashMap internals in Java 8+: per-bin locking, CAS on empty bins, lock-free reads, size(), and weakly-consistent iterators.
You already use ConcurrentHashMap in your code. You call put, you call get, and many threads touch the same map at once without anything breaking. This guide on ConcurrentHashMap internals goes under the hood, so you finally see why it stays correct and fast at the same time.
We will skip the API here. Instead, we look at the engine. You will see how the map locks only a tiny piece of itself, how reads skip locking completely, and how it even counts entries without one thread waiting on another. By the end, the design should feel simple rather than magical.
A plain HashMap is not thread-safe. Two threads writing to it at the same moment can corrupt the internal array. In older Java versions, a bad resize could even push threads into an infinite loop. So you cannot share a HashMap across threads without extra protection.
One easy fix is to wrap the map and lock every call. That works, but it is slow. ConcurrentHashMap takes a smarter route. It keeps the fast lookup of a hash table and adds just enough coordination to stay safe under heavy traffic.
| 💡 Interview Insight Interviewers often open with this exact gap: “Why not just use a synchronized HashMap?” The short answer is throughput. A synchronized wrapper lets only one thread work at a time, while ConcurrentHashMap lets many work in parallel. |
Hashtable is the old thread-safe map in Java. It is safe, and that part is fine. The problem is how it becomes safe.
Every public method in Hashtable is synchronized. That lock sits on the single Hashtable object. So the lock guards the entire map, not just the key you touch.
Picture ten threads hitting the same Hashtable. Only one gets in. The other nine wait in line, even if each wants a completely different key. In effect, your fancy multi-core machine behaves like a single-lane road.
Hashtable<String, Integer> table = new Hashtable<>();
// Both of these grab the SAME lock on the whole table.
table.put("a", 1); // thread 1 holds the lock
table.get("b"); // thread 2 must wait, even for a different keyThe cost is not just waiting. There is also the hidden tax of threads parking and waking up again. Under light load you may not notice. Under real load, with many cores and many threads, this single lock turns into the main bottleneck.
Java 7 already improved on this with lock striping. It split the map into separate pieces called segments, usually sixteen of them. Each segment had its own lock, so up to sixteen threads could write in parallel.
That design helped a lot, but it still wasted memory and had a fixed limit. Java 8 threw it out and went finer-grained. The rest of this article focuses on that modern Java 8+ design, which is what you run today.
Here is the short contrast before we dig in. It helps to keep the two side by side.
| Aspect | Hashtable | ConcurrentHashMap (Java 8+) |
|---|---|---|
| Lock scope | Whole map, one lock | One bin at a time |
| Reads | Locked | Lock-free |
| Empty-bin insert | Locked | CAS, no lock |
| Parallel writers | One at a time | Many, on different bins |
| Null key or value | Not allowed | Not allowed |
| Iterator on change | Fail-fast style | Weakly consistent |
At its heart, ConcurrentHashMap looks a lot like HashMap. There is one array, and each slot in it is called a bin or a bucket. The whole concurrency trick is built on top of this plain array.
The main field is a Node[] array, usually named table. Each entry you store becomes a small Node object. A node holds four things:
next reference, which links nodes that share the same bin.When several keys land in one bin, their nodes form a short linked list. If that list grows too long, the bin turns into a balanced tree. We will come back to that part near the end.
The bin index comes from the key’s hash. The map spreads the hash bits a little, then takes hash & (length - 1). That gives a slot number inside the array. The same key always maps to the same slot, which is how lookups stay direct.
| 💡 Interview Insight A common follow-up: “Does ConcurrentHashMap allow null keys or values?” No, it does not. In a single-threaded HashMap a null from get() could mean “absent” or “stored as null”. In a concurrent map that ambiguity is dangerous, so nulls are banned outright. |
One small detail trips people up. The internal array does not exist when you call the constructor. ConcurrentHashMap creates it lazily, on the first put. Even that first step is race-safe.
Two threads may try to build the table together. A field called sizeCtl acts as a flag and a guard. The winner uses a CAS to claim the right to initialize, builds the array, and sets the next threshold. Meanwhile, the loser simply yields and then finds the table ready. So the map never ends up with two tables.
Here is the key move. ConcurrentHashMap does not lock the whole table. It locks a single bin at a time. Two threads writing to different bins never block each other at all.
Think of the array as a long row of lockers. Hashtable puts one padlock on the whole room. ConcurrentHashMap gives each locker its own tiny lock. You only wait if someone is already inside the exact locker you need.

A write to an empty bin uses CAS; a write to an occupied bin locks only that bin’s head.
Most bins are empty most of the time. For an empty bin, locking would be overkill. So the map uses a CAS instead, which stands for compare-and-swap.
CAS is a single hardware instruction. It says: “if this slot is still empty, drop my node in; otherwise fail.” If it succeeds, the insert is done with no lock at all. If it fails, another thread got there first, so the code simply loops and tries again.
// Simplified putVal logic for an EMPTY bin
if (tabAt(table, i) == null) {
// Atomic: place the node only if the slot is still null.
if (casTabAt(table, i, null, newNode))
break; // success, no lock needed
// CAS failed -> someone beat us -> loop and retry
}This is why low-contention inserts are so cheap. On a fresh or sparse map, many puts never take a lock. They just win a CAS and move on.
Now suppose the bin already has a node. A CAS alone cannot safely walk and edit a chain. So the map falls back to a real lock, but a very narrow one.
It runs synchronized on the first node of that bin, the head. While it holds that head’s monitor, it walks the chain. Then it either updates a matching key or appends a new node at the end.
// Simplified putVal logic for an OCCUPIED bin
Node<K,V> head = tabAt(table, i);
synchronized (head) { // lock ONLY this bin
for (Node<K,V> e = head; ; e = e.next) {
if (e.hash == h && keyEquals(e.key, key)) {
e.val = value; // update existing key
break;
}
if (e.next == null) {
e.next = newNode; // append new key
break;
}
}
}Notice how small the locked region is. The lock covers one bin’s head, held for a very short walk. Every other bin stays wide open for other threads. That narrow scope is the whole reason the map scales.
| 💡 Interview Insight Strong interview line: ConcurrentHashMap uses CAS for the empty case and synchronized for the occupied case. The lock is on the bin’s head node, not on the map. Say that clearly and you have shown you understand the real mechanism. |
Writes are careful. Reads, on the other hand, are almost carefree. A get call never takes a lock. That is a deliberate and important design choice.
Inside each node, the value and the next link are marked volatile. The table slots are also read with volatile semantics. Volatile gives one simple guarantee here: when a writer updates a field, other threads see the fresh value, not a stale cached copy.
Because of that guarantee, a reader can walk a bin safely without any lock. It follows the next links and checks keys as it goes. Either it finds the key and returns the current value, or it reaches the end and returns null.
Most real maps are read far more than they are written. Caches, config lookups, and routing tables all lean heavily on reads. By keeping reads lock-free, the map lets readers run fully in parallel.
There is one honest caveat. A reader might catch the map mid-update and see a slightly older value. That is fine by design. The read is still safe and consistent; it just reflects a recent snapshot rather than a locked, frozen one.
Methods like computeIfAbsent, compute, and merge feel like magic. They let you read and update a key as one safe step. Internally, they lean on the same per-bin lock you already met.
When you call computeIfAbsent, the map locks that key’s bin. It then runs your function while holding the lock, and finally stores the result. Because the whole thing happens under one lock on one bin, no other thread can slip in and create the same key twice.
// Safe, atomic counting per key -- no explicit lock in your code.
ConcurrentHashMap<String, Integer> counts = new ConcurrentHashMap<>();
counts.merge("apple", 1, Integer::sum);
// The bin for "apple" is locked while the merge runs,
// so two threads can never lose an increment here.This design has one practical rule, though. Keep that function short and simple. Since it runs while a bin is locked, slow work inside it blocks other writers to that same bin. Also, never update the same map from inside the function, or you risk a nasty self-tangle.
| 💡 Interview Insight Great interview point: computeIfAbsent is atomic because it holds the bin’s lock across your function. That is also why the mapping function must be fast and must not modify the same map. Say both halves and you stand out. |
Counting entries sounds trivial. In a concurrent map, it is surprisingly tricky. A naive design would keep one shared counter and bump it on every put. That counter would then become a brand-new bottleneck.
Imagine twenty threads inserting at once. Each one wants to increment the same counter. Now they all fight over that single field. You removed the big map lock, only to add a tiny hot lock in its place. That trade is not worth it.
ConcurrentHashMap spreads the counting out instead. It keeps a baseCount field for the simple, low-contention case. Under pressure, it switches to an array of small counters called counter cells.
Different threads then update different cells. Contention drops because the threads rarely touch the same counter. If you have used LongAdder, this is the same striped-counting idea, built right into the map.
// Conceptually, size() adds everything up:
long total = baseCount;
for (CounterCell cell : counterCells)
if (cell != null)
total += cell.value;
return total; // summed on demand, not stored as one numberBecause the total is summed on demand and nothing is locked, size() is a close estimate rather than a frozen exact count. While you sum the cells, other threads may still be inserting. For very large maps, the docs even suggest mappingCount(), which returns a long and avoids int overflow.
| 💡 Interview Insight Watch out for this trap: never write loops like for (int i = 0; i < map.size(); i++) and assume the size is pinned. Under concurrency the count can shift between calls. Treat size() as a snapshot number, not a contract. |
Iteration is another place where ConcurrentHashMap behaves differently from HashMap. The difference comes straight from the concurrent design.
A HashMap iterator is fail-fast. Change the map while looping, and it throws ConcurrentModificationException to warn you. That makes sense for a single-threaded map, where such a change is almost always a bug.
A ConcurrentHashMap iterator never throws that exception. Concurrent updates are expected here, not a mistake. So the iterator just keeps going calmly while other threads add or remove entries.
The iterator reflects the map as it stood at or after the moment you created it. It walks each element at most once. Updates that happen during the loop may or may not show up, and both outcomes are allowed.
In practice, this is exactly what you want from a shared map. You get a safe, drift-tolerant walk instead of a brittle one that explodes on the first concurrent write.
Resizing is usually a heavy, single-threaded job. ConcurrentHashMap turns it into teamwork. When the map grows past its threshold, it moves entries into a bigger array, and several threads can share that moving work.
A bin that has already moved gets a special marker node, often called a forwarding node. If another thread lands on that marker, it does not just wait. Instead, it pitches in and helps transfer the remaining bins. As a result, a big resize finishes faster and does not freeze every writer.
You might worry about reads that arrive mid-resize. The forwarding node handles that case too. It carries a link to the new, larger table.
So when a get hits a bin that is already moved, it follows the marker across to the new table and finishes the lookup there. The reader never blocks, and it never sees a half-built state. In short, migration stays invisible to the code calling get and put.
| 💡 Interview Insight A nice depth signal for interviews: during a resize, old bins hold a forwarding node that points at the new table, other writers help transfer, and reads simply follow that forward link. No global lock, and no frozen map. |
This last part is not unique to the concurrent map. It is the same trick HashMap uses, so we keep it brief.
If one bin collects too many colliding keys, a long linked list is slow to search. To fix that, the bin converts into a red-black tree once two conditions hold:
After that, lookups inside that one bin drop from O(n) to O(log n). This caps the worst case when hashing goes bad. In ConcurrentHashMap, writers still lock the bin’s head during tree edits, while readers can still traverse it. If entries later drop below a small threshold, the tree can turn back into a plain list.
| 💡 Interview Insight You rarely need treeification details for daily work. For interviews, one sentence is enough: a bin becomes a red-black tree after about 8 collisions in a table of at least 64, which protects the worst-case lookup. |
Let us tie the pieces into one picture you can carry around.
Put together, these choices remove the one big lock and replace it with many tiny, local decisions. That is the whole philosophy behind ConcurrentHashMap internals: coordinate at the smallest possible scope, and only when you truly must.
A: No. Since Java 8 it locks only a single bin at a time, using synchronized on that bin’s head node. Threads writing to different bins never block each other.
A: It uses a CAS (compare-and-swap). The node is placed only if the slot is still null. If another thread wins, the CAS fails and the code simply retries, so no lock is taken for the empty case.
A: No. Reads are lock-free. The node’s value and next link are volatile, so a reader always sees fresh data while walking a bin. Readers never block readers or writers.
A: Hashtable uses one lock for the entire map, so only one thread works at a time. ConcurrentHashMap replaces that with per-bin control, CAS on empty bins, and lock-free reads, which lets many threads run in parallel.
A: No, both are banned. In a concurrent map, a null from get() cannot be told apart from a stored null, so that ambiguity is removed by disallowing nulls entirely.
A: The count is summed on demand from a baseCount plus striped counter cells, with no lock held. Other threads may insert while the sum runs, so size() reflects a recent snapshot. For very large maps, mappingCount() is preferred.
A: No, it is weakly consistent. It never throws ConcurrentModificationException on a concurrent change, walks each element at most once, and may or may not reflect updates made during iteration.
ConcurrentHashMap is fast because it refuses to lock more than it needs. It swapped Hashtable’s one global lock for per-bin control. Empty bins take a CAS, busy bins take a short synchronized walk, and reads stay completely lock-free.
On top of that, it counts with striped cells, iterates in a weakly-consistent way, and even shares resize work across threads. None of these tricks are magic once you see them. They are just careful answers to one question: how do you stay correct under many threads without turning the map into a traffic jam?
Keep this mental model handy. The next time you reach for a concurrent map, you will know exactly what it is doing for you underneath, and why it holds up when the load climbs.