Why are the compute/merge family the correct tools for thread-safe accumulation on a ConcurrentHashMap, and what are the pitfalls?
answer
- get-then-put = lost update race
- compute/merge = atomic read-modify-write per key
- function runs under per-bin lock: short, pure, non-blocking
- no nested computeIfAbsent on same map (deadlock)
- atomic per key only; LongAdder for hot counters
basics
~10 sOn a ConcurrentHashMap, compute, computeIfAbsent, computeIfPresent, and merge perform the read-modify-write for a key as one atomic step, so two threads can't lose each other's updates. Plain get-then-put can; that's the bug they fix.
solid answer
~50 sA naive concurrent counter — `m.put(k, m.get(k) + 1)` — has a lost-update race: two threads read the same old value and one overwrites the other. On `ConcurrentHashMap`, the remapping methods (`merge`, `compute`, `computeIfAbsent`, `computeIfPresent`) execute the entire read-modify-write **atomically for that key**, eliminating the race without external synchronization — so `m.merge(k, 1, Integer::sum)` is correct and lock-free at the API level. Pitfalls: the remapping function runs while holding a per-bin lock, so it must be **short, non-blocking, and side-effect-free** (it may be retried under contention). You must not update the **same** map inside the function (especially nested computeIfAbsent), which risks deadlock and is documented as forbidden. Null keys/values are disallowed entirely. Atomicity is per key only — operations spanning multiple keys still need higher-level coordination. For pure counters, LongAdder values often outperform Integer merges under heavy contention.
go deeper
Knows get-then-put can lose updates and that merge/compute are the safe alternatives.
Can write atomic counters with merge/compute and states that the function should be short and side-effect-free.
Explains per-bin locking, the per-key (not cross-key) atomicity boundary, and the no-self-modification rule.
Designs high-throughput accumulation (LongAdder, sharding), reasons about retry semantics and function purity, and architects multi-key invariants beyond per-key atomicity.
### The race in the naive approach Consider two threads incrementing the same counter: ```java int v = map.get(k); // both read 5 map.put(k, v + 1); // both write 6 -> one increment lost ``` Between the `get` and the `put`, another thread can run. This is a **lost update**: the final value is 6, not 7. `get`-then-`put` is two separate atomic operations, and nothing keeps another thread out in between. ### How ConcurrentHashMap's compute/merge fix it `ConcurrentHashMap` is split into independently-lockable **bins** (buckets). When you call `merge`, `compute`, `computeIfAbsent`, or `computeIfPresent`, the map locks the bin for that key, runs your function, and writes the result — all **as one atomic step for that key**. No other thread can interleave its read-modify-write on the *same* key. So: ```java map.merge(k, 1, Integer::sum); // atomic increment, no lost update map.compute(k, (key, v) -> (v == null ? 0 : v) + 1); // also atomic ``` are correct concurrent counters with no `synchronized` of your own. ### Constraints on the function Because the function runs **inside** the map's per-bin lock: 1. **Keep it short and non-blocking** — long or blocking work stalls every other thread hashing to that bin. 2. **No side effects you can't repeat** — under contention the implementation may compute and retry, so observable side effects (I/O, mutating external state) can happen more than once or be wasted. 3. **Never modify the same map inside the function.** Nested or recursive `computeIfAbsent` on the same map is explicitly forbidden in the Javadoc and can deadlock or corrupt internal state. (A famous JDK bug — JDK-8071667 — involved exactly this.) 4. **No null keys or values** — `ConcurrentHashMap` forbids null entirely, so `getOrDefault` is the way to read with a fallback. ### Atomicity is per key, not across keys These methods make a single key's update atomic. A logical operation touching **two** keys (e.g., transfer between accounts) is **not** atomic just because each call is — you still need higher-level coordination (a lock, or a redesign so the invariant lives under one key). ### When to reach for LongAdder Under very high contention on a single hot key, repeatedly retrying `Integer` merges contends on one bin. A common pattern is `Map<K, LongAdder>` with `map.computeIfAbsent(k, x -> new LongAdder()).increment()`: the adder spreads contention across internal cells, often beating `merge(k, 1L, Long::sum)` for pure counting. (Trade-off: reads must call `sum()`.) ### Summary Use the remapping family for any read-modify-write on a shared `ConcurrentHashMap`; never `get`-then-`put`. Keep functions pure and fast, never touch the same map inside them, and remember atomicity stops at one key.
- Why is map.put(k, map.get(k) + 1) unsafe under concurrency but map.merge(k, 1, Integer::sum) safe?The put version is two separate operations with a gap where another thread can read the same old value and clobber the update. merge performs the read-combine-write atomically for that key, so increments cannot be lost.
- What is the danger of calling computeIfAbsent on the same map inside its own mapping function?The function runs while the bin is locked; reentrant updates to the same map can deadlock or corrupt internal state, and the Javadoc explicitly forbids it.
saying these in an interview costs you the question
- Using map.put(k, map.get(k)+1) for a concurrent counter
- Doing blocking I/O inside the remapping function
- Calling computeIfAbsent on the same map from within its function
- Assuming a two-key update is atomic because each call is
- Trying to store null in a ConcurrentHashMap