ConcurrentHashMap Quiz
KOTLIN › Concurrency
When does ConcurrentHashMap.get() acquire a lock?
- Never: reads traverse volatile node fields without locking
- Only while a resize transfer is in progress
- Whenever the target bin holds more than one node
- On every call: a brief lock on the bin's head node
Answer: Never: reads traverse volatile node fields without locking
The read path is lock-free by design: node value/next are volatile, so readers see safely published entries. During a resize they just follow ForwardingNodes to the new table. That's why read-heavy workloads scale so well on it.
Two threads race putIfAbsent("k", a) and putIfAbsent("k", b). What is the outcome?
- Exactly one value wins; the loser's call returns the winner's value
- Both succeed and the map briefly holds two entries for "k"
- The second call throws IllegalStateException
- The winner is overwritten by the loser (last write wins)
Answer: Exactly one value wins; the loser's call returns the winner's value
putIfAbsent is atomic: check and insert happen under the bin's protection as one step. The losing caller gets the existing value back (non-null return means 'already present'), which is exactly what you build single-flight initialisation on.
For a shared cache, what does computeIfAbsent(key) { load(it) } guarantee that getOrPut-style check-then-put doesn't?
- The loader runs at most once per key; concurrent callers wait and get that result
- The loader runs on a background thread automatically
- The loaded value is refreshed when it goes stale
- The key is pinned so it can never be evicted
Answer: The loader runs at most once per key; concurrent callers wait and get that result
computeIfAbsent executes the mapping function under the bin lock: one thread loads, racers block on that bin then see the value. It's the map-level version of single-flight. The trade-off: a slow loader stalls that bin, so keep it fast.
Inside computeIfAbsent's lambda you call put() on the SAME map. What can happen?
- IllegalStateException or a deadlock on the bin lock
- Nothing: the map queues the nested update for later
- The nested put silently wins over the computed value
- A ConcurrentModificationException on the next read
Answer: IllegalStateException or a deadlock on the bin lock
The mapping function runs while the bin is locked; re-entering the map from inside it is explicitly forbidden (recursive updates may throw IllegalStateException or block forever if the nested call hits the same locked bin). Compute functions must not touch the map they're computing into.
A memoised recursive function uses computeIfAbsent for each sub-result (e.g. fib(n) calling fib(n-1) inside the lambda). Why does it break?
- The recursion re-enters computeIfAbsent while a bin is locked: recursive update, ISE or deadlock
- Deep recursion overflows the bin's tree structure
- Each level evicts the previous level's cached value
- It works, but the cache hit-rate is zero due to hashing
Answer: The recursion re-enters computeIfAbsent while a bin is locked: recursive update, ISE or deadlock
The nested computeIfAbsent can land on the same locked bin (or lock bins in conflicting order across threads). Restructure so the recursion happens OUTSIDE the mapping function: compute the value first, then a plain putIfAbsent.
Under heavy concurrent writes, what is true of size()?
- It's an estimate from striped counters; don't gate logic on it
- It locks the table briefly, so it is exact but slow
- It is exact for sizes under 64 and estimated above
- It throws if called during a resize
Answer: It's an estimate from striped counters; don't gate logic on it
Size is aggregated from contention-spread counter cells while writers keep writing, so the moment it returns it may already be wrong. Use it for stats and logging; for correctness decisions use the return values of the atomic ops themselves.
You iterate the map while other threads keep writing. What does the iterator do?
- Proceeds safely, reflecting some, all, or none of the concurrent updates
- Throws ConcurrentModificationException like HashMap
- Blocks writers until the iteration finishes
- Iterates a full snapshot taken at creation time
Answer: Proceeds safely, reflecting some, all, or none of the concurrent updates
Weakly consistent iteration: no CME, each element visited at most once, concurrent updates may or may not appear. There's no snapshot and no lock, so treat what you see as approximately-now, and copy the entries first if you need a stable view.
Why does map.put(key, null) throw on a ConcurrentHashMap?
- A null value would make get()'s null ambiguous between absent and stored-null
- Null values can't be CAS'd on 32-bit architectures
- The tree bins sort values and null isn't comparable
- It's a historical Hashtable restriction kept for source compatibility
Answer: A null value would make get()'s null ambiguous between absent and stored-null
In HashMap you can disambiguate with containsKey(); in a concurrent map that second call races with writers, so the answer could change in between. Keeping null impossible means get() == null always means absent. Model 'present but empty' explicitly (sealed type, sentinel).
What is replace(key, expected, newValue) for?
- Optimistic updates: swap only if the current value is still the one you read
- Forcing a value in even if another thread holds the bin lock
- Replacing the key object while keeping its value
- Atomically moving a value from one key to another
Answer: Optimistic updates: swap only if the current value is still the one you read
It's compare-and-set at the map level: read the value, derive a new one, and replace only if nobody changed it meanwhile; retry on failure. That loop gives you atomic read-modify-write without locking the bin for the whole computation.
What is the idiomatic atomic way to keep a counter per key?
- map.merge(key, 1, Int::plus)
- map[key] = (map[key] ?: 0) + 1
- map.putIfAbsent(key, (map[key] ?: 0) + 1)
- synchronized(map) { map[key] = map[key]!! + 1 }
Answer: map.merge(key, 1, Int::plus)
merge does the read-combine-write atomically per key: it inserts 1 if absent, otherwise applies the remapping function under the bin's protection. The read-then-write versions race, and a global synchronized throws away the map's scalability.
The docs promise: actions in a thread prior to placing an object into a ConcurrentHashMap happen-before actions subsequent to its retrieval. What does that let you do?
- Build an object fully, put it, and know readers see it fully built without extra sync
- Mutate the object freely after publishing: readers always see the latest state
- Skip volatile on every field of every class you store
- Treat any sequence of map calls as one atomic transaction
Answer: Build an object fully, put it, and know readers see it fully built without extra sync
The put/get pair is a happens-before edge, so construction-before-put is visible after get: safe publication for free. It does NOT cover mutations made after publication; if the stored object keeps changing, that object needs its own thread safety (or store immutable values and replace()).
You need a concurrent Set (e.g. of in-flight request ids). What is the standard way to get one?
- ConcurrentHashMap.newKeySet()
- Collections.synchronizedSet(HashSet()) is equivalent in behaviour
- A CopyOnWriteArrayList with manual dedup
- TreeSet, since red-black trees are lock-free
Answer: ConcurrentHashMap.newKeySet()
newKeySet() returns a Set view backed by a ConcurrentHashMap, inheriting lock-free reads, per-bin writes and weakly consistent iteration. synchronizedSet works but serialises everything on one monitor and its iteration must be manually synchronized.
Collections.synchronizedMap(HashMap()) vs ConcurrentHashMap: what is the key operational difference?
- synchronizedMap serialises every call on one monitor and iteration must be manually synchronized; CHM scales per-bin and iterates weakly consistently
- synchronizedMap allows null keys, so it is strictly more capable
- They are equivalent since Java 8 unified their implementations
- CHM is only faster when there are more writer than reader threads
Answer: synchronizedMap serialises every call on one monitor and iteration must be manually synchronized; CHM scales per-bin and iterates weakly consistently
The wrapper puts one lock around every method, so all threads queue, and iterating without synchronizing on the map is fail-fast CME territory. CHM was purpose-built: lock-free reads, striped writes, safe iteration. The wrapper's one niche is wrapping an existing map cheaply (and null tolerance).
In Kotlin, cache.getOrPut(key) { expensiveLoad() } on a ConcurrentMap: what is true under a race?
- The result is consistent (one value wins via putIfAbsent) but expensiveLoad() may run more than once
- expensiveLoad() runs exactly once: the map blocks the losers
- It throws because getOrPut requires a MutableMap, not a ConcurrentMap
- Each racer gets its own loaded value back
Answer: The result is consistent (one value wins via putIfAbsent) but expensiveLoad() may run more than once
The ConcurrentMap overload computes the default OUTSIDE any lock and then putIfAbsent's it: racers may each run the loader, but only one result is stored and returned to everyone. If the loader is expensive or has side effects, use computeIfAbsent (bin-locked, at-most-once) instead.