📦 Section 4 · Question #18

What is ConcurrentHashMap

Prior to ConcurrentHashMap, thread safety for maps was achieved in two legacy ways:


🟢 Junior Level

1. What is ConcurrentHashMap

ConcurrentHashMap (CHM) is a high-throughput, thread-safe implementation of the ConcurrentMap interface (which extends Map) in the Java Collections Framework (java.util.concurrent), specifically engineered for highly concurrent multi-threaded environments.

Prior to ConcurrentHashMap, thread safety for maps was achieved in two legacy ways:

  1. Hashtable (Java 1.0).
  2. Collections.synchronizedMap(new HashMap<>()) (Java 1.2).

Both legacy approaches rely on Coarse-Grained Locking across the entire map instance. Every method (get, put, remove, size) acquires a single monitor lock on the whole object. As a result, reading threads block writing threads and vice versa, creating severe contention bottlenecks that prevent applications from scaling on multi-core processors.

ConcurrentHashMap eliminates this bottleneck:

  • Read operations (get) are completely Lock-Free, proceeding concurrently without blocking readers or writers.
  • Write operations (put, remove) lock only the specific bucket being modified, allowing concurrent threads to write to different buckets simultaneously.
// Thread-safe concurrent map instantiation:
Map<String, Integer> concurrentMap = new ConcurrentHashMap<>();

// Multiple threads can write and read simultaneously without external synchronized blocks:
concurrentMap.put("alpha", 1);
concurrentMap.computeIfAbsent("beta", k -> 2);
Integer value = concurrentMap.get("alpha"); // Lock-free read!

2. Implementation Comparison Matrix

Criterion HashMap Collections.synchronizedMap ConcurrentHashMap
Thread Safety ❌ None ✅ Yes (Single global lock) ✅ Yes (Fine-grained per-bucket)
Read Synchronization None Locks entire map Lock-Free (Zero locks)
Write Synchronization None Locks entire map Locks only the target bucket head
null Key / Value Key: ✅, Val: ✅ Key: ✅, Val: ✅ Key: ❌, Val: ❌ (NullPointerException)
Iterator Behavior Fail-Fast (CME) Fail-Fast (requires external lock) Weakly Consistent (Never throws CME)

🟡 Middle Level

1. Why ConcurrentHashMap Strictly Prohibits null

Attempting to insert null as either a key or a value into a ConcurrentHashMap throws an immediate NullPointerException:

map.put(null, 100);  // ❌ Throws NullPointerException!
map.put("key", null); // ❌ Throws NullPointerException!

Architectural Rationale (Doug Lea): In single-threaded collections like HashMap, calling map.get(key) == null can be disambiguated by checking map.containsKey(key): if containsKey returns true, the value is explicitly null; otherwise, the key is absent.

In concurrent programming, this pattern creates a fatal Race Condition:

// In a concurrent environment, this logic is fundamentally flawed:
if (map.get(key) == null) {
    // Another thread can insert or delete the key RIGHT HERE!
    if (map.containsKey(key)) {
        // This assertion is no longer trustworthy!
    }
}

To eliminate this ambiguity (“is the key missing, or is the value null?”), Doug Lea enforced a strict invariant: null keys and values are strictly prohibited across all modern concurrent collections.


2. Atomic Compound Actions

A widespread pitfall in concurrent programming is the illusion of safety when executing multiple independent map calls (Check-Then-Act sequences):

// ❌ ANTI-PATTERN: Race condition between two non-atomic calls!
if (!map.containsKey("counter")) {
    map.put("counter", 1); // A concurrent thread can intervene between containsKey and put!
}

// ✅ ATOMIC: ConcurrentMap atomic methods
map.putIfAbsent("counter", 1);

// ✅ ATOMIC MUTATION: In-place lambda calculation (Java 8+)
map.compute("counter", (k, v) -> (v == null) ? 1 : v + 1);

// ✅ ATOMIC ACCUMULATION: Atomic merge counter
map.merge("counter", 1, Integer::sum);

3. Weakly Consistent Iterators

Iterators returned by ConcurrentHashMap.keySet(), values(), and entrySet() are Weakly Consistent:

  • They never throw ConcurrentModificationException.
  • They reflect the state of the map at the moment the iterator was constructed and are guaranteed to traverse all entries present at creation time.
  • They may (but are not guaranteed to) reflect insertions, updates, or removals performed by concurrent threads after iterator creation.
  • They guarantee each entry is delivered at most once.

🔴 Senior Level

1. Architectural Evolution: Java 7 vs. Java 8+

Java 7: Segment-Level Locking (16 ReentrantLocks)
┌───────────┐ ┌───────────┐ ┌───────────┐ ┌───────────┐
│ Segment 0 │ │ Segment 1 │ │ Segment 2 │ │ Segment 3 │ ... (Concurrency capped at 16)
└───────────┘ └───────────┘ └───────────┘ └───────────┘

Java 8+: Node-Level Locking + CAS
[Bucket 0] -> Empty? Atomic CAS insertion (Lock-free!)
[Bucket 1] -> Populated? Synchronize strictly on the single bucket HEAD node!
[Bucket 2] -> Concurrent read? Lock-free volatile read!
  • Java 7: Sliced the table into 16 discrete Segment classes (each subclassing ReentrantLock), providing a fixed concurrency level of 16.
  • Java 8+: Completely eliminated Segments. Empty buckets are initialized using lock-free CAS (Compare-And-Swap) instructions. Collisions synchronize solely on the head node of that specific bucket (synchronized(node)), scaling concurrent write capacity directly with table capacity.

2. Distributed Size Tracking: CounterCell Array & mappingCount()

In high-throughput systems, maintaining a single shared volatile int size would create an extreme cache-coherence bottleneck (continuous CPU cache line invalidation across cores).

ConcurrentHashMap implements an internal stripped counter mechanism identical to LongAdder:

  • A base counter field volatile long baseCount.
  • A striped array of counter cells volatile CounterCell[] counterCells.
  • When threads update entries without contention, they increment baseCount via CAS.
  • Under high contention, threads hash their thread IDs to increment an individual cell in counterCells.
  • mappingCount() sums baseCount and active cells, returning a long value suitable for collections containing $> 2^{31}$ entries:
// Preferred over size() for massive distributed maps:
long totalEntries = map.mappingCount();

3. Cooperative Multi-Threaded Resizing (ForwardingNode)

When resizing from $N$ to $2N$ buckets:

  1. ConcurrentHashMap inserts a special marker node called ForwardingNode with hash -1 (MOVED) into completed buckets.
  2. If another thread attempts to write to a bucket currently undergoing migration, it detects the ForwardingNode and immediately calls helpTransfer().
  3. Multiple writing threads actively cooperate to migrate chunks of buckets in parallel, eliminating prolonged GC or resizing pauses.

4. The computeIfAbsent() Deadlock Hazard

While computeIfAbsent() provides atomic convenience, executing blocking logic or nested map mutations inside the lambda can cause severe production outages:

// ❌ HAZARD 1: Thread-Pool Starvation via Blocking I/O
map.computeIfAbsent("token", k -> {
    return httpClient.fetchRemoteToken(); // ⚠️ DANGEROUS! Holds bucket monitor for seconds!
});

// ❌ HAZARD 2: Guaranteed Deadlock
Map<Integer, Integer> map = new ConcurrentHashMap<>();
map.computeIfAbsent(1, k1 -> {
    return map.computeIfAbsent(2, k2 -> 42); // ⚠️ DEADLOCK if keys map to the same bucket chain!
});

4 Tricky Questions

1. Why does ConcurrentHashMap.size() return an approximate count rather than an exact atomic snapshot?

Answer: In a highly concurrent system, obtaining an exact instantaneous count requires acquiring a global lock across all buckets, halting every writing thread and destroying throughput. ConcurrentHashMap sums baseCount and the striped CounterCell[] array on-the-fly without locking. Because concurrent threads continue inserting and deleting elements while the sum loop executes, the returned size represents a weakly consistent estimate accurate to the nanosecond of execution, which is sufficient for monitoring and capacity planning.


2. Why did Doug Lea strictly prohibit null keys and values in ConcurrentHashMap, while standard HashMap allows them?

Answer: In single-threaded code, map.get(k) == null can be disambiguated with map.containsKey(k) to check if the value itself was null. In a multi-threaded application, get() followed by containsKey() creates a non-atomic race condition: between the two calls, a peer thread could insert or delete the key, making the check unreliable. By forbidding null values entirely, map.get(k) == null unambiguously means one thing: the key is absent.


3. What architectural differences exist between ConcurrentHashMap in Java 7 and Java 8+?

Answer:

  • Java 7: Used Segment-Level Locking. The map was divided into a fixed array of 16 Segment objects, each extending ReentrantLock. Concurrency was bounded by the segment count (16 by default). Memory overhead was high due to nested segment objects.
  • Java 8+: Adopted Node-Level Locking + CAS. Segments were eliminated. Unoccupied buckets are populated via lock-free CPU CAS instructions. Populated buckets are locked using the Java built-in monitor of the bucket’s head node (synchronized(firstNode)). Bucket lists convert to Red-Black Trees (TreeBin) upon reaching 8 collisions, and resizing is performed cooperatively via ForwardingNode.

4. How can computeIfAbsent() inadvertently cause an application freeze or deadlock in a high-concurrency production service?

Answer: The mapping function passed to computeIfAbsent() executes inside the synchronized block of that specific bucket’s head node. If the mapping function:

  1. Performs slow I/O, database calls, or remote HTTP requests, the entire bucket remains locked, stalling all other threads trying to access keys in that bucket.
  2. Mutates or queries the same ConcurrentHashMap recursively, and the nested key maps to the same bucket, the thread attempts to acquire a monitor it is waiting on in a non-reentrant state, causing a permanent Deadlock.

🎯 Interview Cheat Sheet

30-Second Elevator Pitch

“ConcurrentHashMap is a high-performance thread-safe map implementing ConcurrentMap. Unlike Hashtable or Collections.synchronizedMap which lock the entire object, ConcurrentHashMap provides completely lock-free reads using volatile references and fine-grained per-bucket writes using CAS for empty slots and synchronized(headNode) for collisions. It strictly prohibits null keys and values to prevent multi-threaded check-then-act race conditions. Iterators are weakly consistent and never throw ConcurrentModificationException. For scalable sizing, it uses a striped CounterCell array (similar to LongAdder), accessible via mappingCount().”

High-Impact Architectural Facts

  • Lock-Free Reads: get() requires zero locks; table, val, and next are volatile, satisfying the JMM Happens-Before relationship.
  • Node-Level Locking (Java 8+): No Segments; writes synchronize strictly on the collision head node, with empty buckets populated via atomic CAS.
  • Null Safety: null keys and values throw NullPointerException by design.
  • Parallel Resizing: Writers encountering a ForwardingNode (hash = -1) help transfer entries in parallel via helpTransfer().
  • Large Capacity: mappingCount() returns long for maps exceeding $2\text{ billion}$ entries.

Common Pitfalls & Red Flags

  • ❌ Assuming ConcurrentHashMap locks segments in modern Java: Segments were removed in Java 8 (2014); modern CHM uses node-level locking.
  • ❌ Performing blocking I/O inside computeIfAbsent: Holds the bucket monitor lock, exhausting thread pools.
  • ❌ Using compound check-then-act logic: if (!map.containsKey(k)) map.put(k, v) is not atomic; always use putIfAbsent(), compute(), or merge().
  • ❌ Inserting null: Causes immediate NullPointerException.