ConcurrentHashMap Quiz

KOTLIN › Concurrency

When does ConcurrentHashMap.get() acquire a lock?

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?

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?

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?

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?

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()?

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?

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?

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?

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?

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?

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?

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?

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?

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.

Back to ConcurrentHashMap