How does ConcurrentHashMap ensure thread-safety
The ConcurrentHashMap class delivers thread safety in multi-threaded architectures without resorting to coarse global object locking. Instead of forcing all threads to queue beh...
🟢 Junior Level
1. Fundamental Principle of Thread Safety
The ConcurrentHashMap class delivers thread safety in multi-threaded architectures without resorting to coarse global object locking. Instead of forcing all threads to queue behind a single monitor (as in legacy Hashtable or Collections.synchronizedMap), ConcurrentHashMap coordinates three cooperative low-level mechanisms:
- Lock-Free Reads (
get): Read operations execute without acquiring any locks whatsoever, running concurrently with active write operations. - Atomic CAS (Compare-And-Swap): CPU-level atomic instructions insert new elements into unoccupied buckets with zero lock contention.
- Fine-Grained Node-Level Synchronization: When a hash collision occurs,
ConcurrentHashMapsynchronizes strictly on the head node of that specific bucket (synchronized (head)). All other buckets in the hash table remain completely open for simultaneous parallel writes by other threads.
Thread 1 ──► put("A", 1) ──► Bucket [2] (Locks monitor of node 'A')
Thread 2 ──► put("B", 2) ──► Bucket [7] (Unoccupied, CAS insertion, runs concurrently!)
Thread 3 ──► get("C") ──► Bucket [5] (Lock-free volatile read, runs concurrently!)
🟡 Middle Level
1. Java Memory Model (JMM) Guarantees & Volatile Visibility
Inside ConcurrentHashMap, bucket array elements and node fields are explicitly declared with volatile semantics:
// Internal table bucket array:
transient volatile Node<K,V>[] table;
// Collision chain node definition:
static class Node<K,V> implements Map.Entry<K,V> {
final int hash;
final K key;
volatile V val; // Volatile value reference!
volatile Node<K,V> next; // Volatile successor link!
}
Why Read Operations (get) are Completely Lock-Free:
- Bucket array elements are accessed via
tabAt(table, i)usingvolatile loadsemantics (viaUnsafe/VarHandle). - The payload reference
valand the collision pointernextare both declaredvolatile. - Under the Java Memory Model specification, writing to a
volatilevariable establishes a formal Happens-Before relationship with any subsequent read of that same variable. - A reading thread is guaranteed to observe the most recent value written by any thread without acquiring a mutual exclusion lock (
mutex).
2. Step-by-Step Insertion Lifecycle: putVal()
When a thread calls map.put(key, value):
┌───────────────────────────┐
│ Invoke put(key, value) │
└─────────────┬─────────────┘
│
Hash Spread: spread(key.hashCode())
│
▼
┌───────────────┐
┌──────────┤ table == null?├──────────┐
│ └───────────────┘ │
YES│ │ NO
▼ ▼
initTable() via CAS Index i = (n - 1) & hash
│ │
└──────────────────┐ ▼
│ ┌─────────────────┐
└────────►│ table[i] empty?│
└────────┬────────┘
YES│ │ NO
│ │
┌───────────────────────────────┘ ▼
▼ ┌────────────────────┐
CAS: casTabAt(table, i, null, node) │ hash == MOVED (-1)?│
│ └─────────┬──────────┘
┌────────┴────────┐ YES│ │ NO
SUCCESS FAIL │ │
│ │ │ ▼
DONE RETRY helpTransfer() ◄────┘ synchronized (head) {
Insert into List/Tree
}
- Null Validation: If
key == nullorvalue == null, it immediately throwsNullPointerException. - Hash Code Spreading:
hash = (h ^ (h >>> 16)) & HASH_BITS, preserving non-negative integers for standard nodes. - Empty Bucket Insertion (CAS): If
table[i] == null, the thread attempts an atomiccasTabAt(table, i, null, node):- If the slot is still null, the node is inserted in 1 CPU clock cycle without locking.
- If a peer thread beat it to the slot, CAS returns
false, and the loop retries.
- Resizing Co-operation (
MOVED): Ifhead.hash == -1(MOVED), the bucket is currently migrating; the thread callshelpTransfer()to accelerate the resize. - Collision Resolution (
synchronized): If elements already exist in the bucket, the thread acquires the intrinsic monitor of the head node:synchronized (f) { if (tabAt(tab, i) == f) { // Double-Check: verifies head has not changed if (fh >= 0) { // Linear traversal: update existing key or append to tail } else if (f instanceof TreeBin) { // Red-Black Tree insertion } } } - Treeification: If collision chain length reaches 8 (
TREEIFY_THRESHOLD) and capacity $\ge 64$,treeifyBin()converts the bucket into a Red-Black Tree (TreeBin).
🔴 Senior Level
1. Cooperative Distributed Resizing (ForwardingNode & helpTransfer())
Unlike HashMap where resizing locks or destabilizes the entire structure, resizing in ConcurrentHashMap is concurrent, distributed, and cooperative:
- When capacity thresholds are crossed, the initiating thread allocates
nextTablewith double the size ($2N$). - The table is partitioned into chunks of buckets called strides (minimum 16 buckets per thread).
- As soon as a thread finishes migrating a bucket to
nextTable, it places a sentinelForwardingNodewith hash-1(MOVED) into the old bucket, pointing tonextTable. - Cooperation: Any thread arriving to execute
put(),remove(), ormerge()that encounters aForwardingNodedoes not block; it invokeshelpTransfer(), claims an unclaimed stride of buckets, and helps transfer entries in parallel. - Non-blocking Reads during Resizing: A thread invoking
get()on a bucket holding aForwardingNodedoes not wait; it callsForwardingNode.find(), searching the already-migrated node directly innextTable.
2. Special Sentinel Nodes Encoded via Negative Hashes
ConcurrentHashMap encodes internal node archetypes using negative hash codes on the bucket head:
| Node Type | Hash Code | Architectural Role |
|---|---|---|
Node<K,V> |
$\ge 0$ | Standard single-linked collision list entry |
ForwardingNode |
-1 (MOVED) |
Resizing placeholder; routes get() to nextTable |
TreeBin |
-2 (TREEBIN) |
Red-Black Tree root wrapper; manages tree read/write lock bits |
ReservationNode |
-3 (RESERVED) |
Placeholder for atomic computeIfAbsent() execution |
The Role of ReservationNode:
When computeIfAbsent(key, mappingFunction) is invoked for a key mapping to an empty bucket, the map cannot know the result of mappingFunction beforehand. To prevent race conditions, the thread installs a ReservationNode via CAS, acquires synchronized (reservationNode), computes the value via the user lambda, and atomically replaces the sentinel with the newly computed Node.
3. Architectural Evolution: Why Java 8 Abandoned Segments
In Java 7, ConcurrentHashMap was composed of an array of Segment<K,V> instances (each extending ReentrantLock):
- Problem 1: Fixed concurrency level (default 16 segments). Maximum simultaneous writers was strictly capped at 16, regardless of CPU core counts or table capacity.
- Problem 2: High memory footprint from allocating 16 heavy
ReentrantLockobjects and their associated AbstractQueuedSynchronizer (AQS) queues. - Problem 3:
size()had to lock all segments sequentially if concurrent modifications occurred during two non-blocking probe passes.
In Java 8+, the architecture transitioned to Node-Level Locking:
- Locks are placed directly on the existing
Nodeobject header using intrinsicsynchronized(head). - Eliminates auxiliary lock objects, slashing heap footprint.
- Concurrency scales dynamically with table capacity (thousands of concurrent write channels).
- Leverages HotSpot’s intrinsic monitor optimizations (biased locking, adaptive spinning, lock coarsening).
4 Tricky Questions
1. Why does reading get() require no locks even while another thread is restructuring the collision list or treeifying?
Answer:
- Volatile Link Visibility:
Node.valandNode.nextarevolatile. In a singly-linked list, modifications append to the tail or update values in place; readers traversingnextreferences observe consistent, fully initialized state via JMM Happens-Before guarantees. - Atomic TreeBin Read-Write State: When a bucket converts to a Red-Black Tree (
TreeBin), writers acquire an internal state lock. However, readers do not block on this lock: if a writer is holding the tree lock,TreeBin.find()falls back to traversing an auxiliary singly-linked list that is maintained alongside the tree. - Resizing Delegation: If a bucket is being transferred, the reader encounters a
ForwardingNodeand transparently searchesnextTableviaForwardingNode.find().
2. Why does putVal() perform a double-check if (tabAt(tab, i) == f) inside synchronized (f)?
Answer:
This is an application of the Double-Checked Locking pattern.
Between the moment the thread observed node f at index i via tabAt() and the moment it successfully acquired the intrinsic lock synchronized (f), a context switch could occur. During that window, another thread could have deleted the node, converted the bucket to a TreeBin, or replaced the bucket with a ForwardingNode during a resize. The check confirms that f remains the genuine, current head of bucket i.
3. Why did the Java 8 architects choose intrinsic synchronized (node) over ReentrantLock for bucket head locking?
Answer:
- Memory Footprint: Each
ReentrantLockis an independent heap object with an innerSyncsubclass extending AQS, consuming 32–48 bytes of heap memory. Allocating aReentrantLockfor every bucket in a table with 65,536 buckets would squander megabytes of memory. In contrast,synchronized(node)requires zero additional objects, utilizing the existing 8-byte Mark Word inside the node’s object header. - JVM Compiler & Hardware Optimization: HotSpot applies adaptive spinning, lock coarsening, and lock elision directly to intrinsic monitors. Under low contention, entering an uncontended
synchronizedblock executes via a single CAS on the Mark Word with zero OS kernel context switches.
4. What is the role of ReservationNode (hash -3) during computeIfAbsent(), and what concurrency problem does it solve?
Answer:
When computeIfAbsent() is called on an empty bucket slot, the value must be generated by executing a user-provided lambda. If the map simply executed the lambda and then attempted a CAS, two concurrent threads could execute the expensive lambda simultaneously, causing duplicate computation.
To prevent this, ConcurrentHashMap installs a ReservationNode (with hash RESERVED = -3) into the empty slot using CAS before running the lambda. It synchronizes on this node, executes the user’s mapping function safely, and then replaces the ReservationNode with the actual computed node.
🎯 Interview Cheat Sheet
30-Second Elevator Pitch
“
ConcurrentHashMapin Java 8+ achieves high-throughput thread safety using a three-tier architecture:
- Lock-Free Reads:
get()uses zero locks, readingvolatilearray slots (tabAt),volatile val, andvolatile nextreferences under JMM Happens-Before rules.- Lock-Free Empty Bucket Writes: Empty slots are populated via atomic CPU Compare-And-Swap (
casTabAt).- Fine-Grained Node-Level Locking: Bucket collisions lock strictly the first node of that bucket (
synchronized(head)), allowing thousands of parallel writes to different buckets. Resizing is cooperative and parallelized viaForwardingNodeandhelpTransfer(), while sizing is scaled across threads viaCounterCellstripes.”
High-Impact Architectural Facts
- Memory Optimization: Uses
synchronized(headNode)rather thanReentrantLockto avoid allocating lock objects; uses the node’s native Mark Word header. - Negative Sentinel Hashes:
MOVED(-1) indicates resizing;TREEBIN(-2) indicates Red-Black Tree;RESERVED(-3) reserves a slot forcomputeIfAbsent(). - Double-Checked Locking: Validates
tabAt(tab, i) == headinsidesynchronized(head)to guard against concurrent structural mutations. - TreeBin Readers Never Block: If a thread is writing to a
TreeBin, reading threads traverse a secondary linked list maintained alongside the tree.
Common Pitfalls & Red Flags
- ❌ Believing Java 8 CHM uses Segments: Segments were eliminated in Java 8; modern CHM locks individual bucket head nodes.
- ❌ Assuming
get()acquires read locks:get()is 100% lock-free in all scenarios. - ❌ Wrapping
ConcurrentHashMapin external synchronization: Destroys parallel execution; usecompute(),merge(), orputIfAbsent(). - ❌ Running slow or blocking code in
computeIfAbsent(): Holds the bucket monitor lock, blocking all peer operations on that bucket.
Related Topics
- What is ConcurrentHashMap — Interface and functional overview
- What is Map and what implementations exist — Map hierarchy overview
- When to use synchronized collections — Coarse vs fine-grained locking
- What is CopyOnWriteArrayList — Snapshot concurrency pattern