Free lesson · Concurrency Interview Mastery
Exercise: Design a Concurrent HashMap
This exercise builds lock striping, refines to per-bin locking with lock-free reads, and diagnoses the global-lock and check-then-act bugs. Reason each through before the solution - the goal is to see how finer-grained locking and lock-free reads beat a single global lock, and why reads can safely skip locking.
Important: Two anchors: operations on different keys should not contend (stripe the locks - by segment, then by bin), and reads need no lock if the structure relies on the memory model (volatile bin heads, safely-published immutable nodes) so a reader never sees a half-built node.
Part 1 - Lock striping
Your task: Implement a striped concurrent map: N segments, each a small table with its own lock; route keys to segments by hash. Why does this beat a global lock?
<details> <summary>Hint</summary>
segments = array of N segments, each { table, lock }. segmentFor(key) = segments[hash(key) & (N-1)]. get/put/remove: seg = segmentFor(key); seg.lock.lock(); try { ... } finally { seg.lock.unlock() }. Operations on keys in different segments use different locks → concurrency up to N. A global lock serializes all operations; striping serializes only those hitting the same segment.
</details>
Part 2 - Per-bin locking with lock-free reads
Your task: Refine to per-bin locking: CAS for empty bins, lock only the bin head for collisions, and lock-free reads. Why can get skip locking?
<details> <summary>Hint</summary>
Bins array; get(k): volatile read of table[i], traverse list, no lock. put(k,v): if table[i] empty, CAS(table, i, null, newNode); else synchronized(table[i]) { traverse + insert/update }. Reads skip locking because the bin-head slots are volatile and nodes are safely published (final key/hash) - a reader sees either a fully-constructed node or none, never a torn one. Per-bin locking means different bins never contend.
</details>
Part 3 - Find the bugs
// Bug A - global lock:
function get(key): synchronized(this) { return table.get(key) } // <-- all reads serialize
function put(k,v): synchronized(this) { return table.put(k, v) }
// Bug B - non-atomic putIfAbsent:
function putIfAbsent(key, value):
if not map.containsKey(key): // <-- check
map.put(key, value) // <-- act (separate)
return null
return map.get(key)
Your task: Identify the scalability problem in A and the concurrency bug in B.
<details> <summary>Hint</summary>
Bug A: a single lock on this serializes every operation - even reads on unrelated keys - so throughput is flat regardless of cores (no parallelism). It's correct but defeats the purpose; stripe the locks or lock per-bin, and make reads lock-free. Bug B: containsKey then put are separate - two threads can both find the key absent and both put, the second overwriting the first (lost insert, or both think they inserted). Fix: an atomic putIfAbsent performed under the bin lock (or via CAS), so the check-and-insert is one indivisible operation.
</details>
Solution
Part 1 - Lock striping
class Segment: table = HashMap(); lock = ReentrantLock()
segments = array of N Segments # N a power of 2 (e.g., 16)
function segmentFor(key):
return segments[hash(key) & (N - 1)]
function get(key):
seg = segmentFor(key)
seg.lock.lock()
try: return seg.table.get(key)
finally: seg.lock.unlock()
function put(key, value):
seg = segmentFor(key)
seg.lock.lock()
try: return seg.table.put(key, value)
finally: seg.lock.unlock()
The map is split into N segments, each an independent small hash table with its own lock. A key is routed to a segment by its hash, so operations on keys in different segments acquire different locks and run concurrently - concurrency up to N. This beats a global lock because a global lock forces all operations to serialize through one lock (one operation at a time, no matter how many cores or how unrelated the keys); striping confines contention to operations hitting the same segment, which with N segments is roughly 1/N of the contention. This is Java 7's ConcurrentHashMap design. (Part 2 goes finer.)
Part 2 - Per-bin locking with lock-free reads
table = volatile array of bins # each bin = head Node (list), or null
function get(key): # NO LOCK
h = hash(key); i = h & (table.length - 1)
node = table[i] # volatile read of bin head
while node != null:
if node.hash == h and node.key == key: return node.value
node = node.next
return null
function put(key, value):
h = hash(key)
while true:
i = h & (table.length - 1)
if table[i] == null:
if CAS(table, i, null, new Node(h, key, value, null)): # lock-free empty-bin insert
return null
# CAS failed -> someone inserted -> retry
else:
synchronized (table[i]): # lock ONLY this bin
node = table[i]
while true:
if node.hash == h and node.key == key:
old = node.value; node.value = value; return old
if node.next == null: node.next = new Node(h, key, value, null); return null
node = node.next
Reads take no lock: a volatile read of the bin head followed by list traversal. This is safe because the bin-head array is volatile (a reader sees the latest published head) and nodes are safely published - their hash and key are final, set in the constructor before the node is CAS'd or linked in, so a reader sees a fully-formed node or none at all, never a half-built one. Writes use CAS for the empty-bin case (lock-free) and synchronized on the bin head only for collisions, so writes to different bins never contend. This is the finest granularity - one lock stripe per bin - plus lock-free reads, matching Java 8's ConcurrentHashMap.
Part 3 - The bugs
Bug A - global lock kills concurrency. Locking synchronized(this) for every get and put means a single lock guards the entire map, so all operations serialize - even two reads of completely unrelated keys can't proceed in parallel. Throughput is flat: adding cores doesn't help, because only one thread is ever inside the map. It's correct (no corruption) but defeats the entire purpose of a concurrent map. The fix is finer-grained locking: stripe the locks across segments (Part 1) or lock per-bin (Part 2), and make reads lock-free. A concurrent map's whole value is letting unrelated operations run in parallel, which a global lock forbids.
Bug B - non-atomic putIfAbsent (check-then-act). containsKey(key) and put(key, value) are two separate operations. Two threads calling putIfAbsent for the same absent key can both execute containsKey (both get false), then both put - the second overwrites the first's value, and both believe they inserted (or one's insert is silently lost). This violates the atomic insert-if-absent contract. The fix is to make the check-and-insert atomic: perform it under the bin's lock (traverse the bin and insert only if the key isn't found, all while holding the bin lock), or use a CAS-based insert. This is the check-then-act race (Module 6) again - the reason concurrent maps provide atomic compound operations (putIfAbsent, computeIfAbsent) rather than leaving callers to compose non-atomic primitives.
Both bugs reinforce the design: fine-grained (not global) locking for concurrency (Bug A), and atomic compound operations for race-free per-key updates (Bug B).
Common mistakes
Warning: Watch for: (1) A single global lock - serializes everything; stripe or lock per-bin. (2) Non-atomic putIfAbsent - check-then-act race; use an atomic compound operation under the bin lock or via CAS. (3) Locking reads - unnecessary if the structure uses volatile heads + safely-published nodes; lock-free reads scale far better. (4) Non-volatile bin heads - readers may see stale heads or half-built nodes; the bin array must be volatile and nodes safely published. (5) A single contended size counter - hotspot; use a striped counter (LongAdder). (6) Ignoring long-bin degradation - a bad hash makes a bin O(n); treeify past a threshold.
Why this exercise matters for interviews
Interview: The concurrent hash map is a top interview data structure, and this exercise mirrors the expected progression. Part 1 (lock striping) shows you understand confining contention. Part 2 (per-bin + lock-free reads) is the modern design - CAS for empty bins, per-bin locking for collisions, and lock-free reads justified by the memory model (volatile heads, safely-published nodes). Part 3 (bugs) targets the two signature errors: the global-lock anti-pattern (no concurrency) and non-atomic putIfAbsent (check-then-act). The throughline interviewers reward: beat the global lock with striping then per-bin locking, make reads lock-free and explain why it's safe, provide atomic compound operations, and know the hard refinements (resizing, striped size counter, treeification). Lock-free reads with the memory-model justification are what most distinguish a strong answer.