skip to content

ConcurrentHashMap makes each operation atomic, yet multi-key invariants and aggregate reads can still be wrong under concurrency. Where are the atomicity boundaries, and how do you design around them?

level: principalimportance: should knowfreq 42%

answer

  1. Atomic = single call, single key (linearizable)
  2. Not atomic: call sequences, multi-key updates
  3. size()/bulk reads are weakly consistent estimates
  4. Collapse invariant into one immutable value + compute
  5. Couple keys -> external lock / AtomicReference / DB

basics

~20 s

Each single operation (get, put, merge, etc.) is atomic, but a series of operations or anything spanning multiple keys is not. size() and bulk reads are only approximate while the map changes. For cross-key or whole-map consistency you need your own coordination, a snapshot, or a different design.

solid answer

~50 s

ConcurrentHashMap guarantees atomicity at the granularity of a single method call on a single key—putIfAbsent, merge, compute all read-modify-write that one key indivisibly. It does NOT guarantee: (1) atomicity across a sequence of calls (two compound calls can interleave); (2) consistency across multiple keys (no way to update two keys as one transaction); (3) exact aggregate reads—size()/mappingCount() are estimates under mutation and bulk operations (forEach, reduce, search) traverse a weakly consistent view that may mix old and new state. To design around this: keep invariants inside a single key (e.g. store a composite immutable value object updated via compute, so the multi-field invariant is atomic); use an external lock or a transactional store when several keys must change together; take an explicit snapshot when you need a stable view; and treat size()/iteration as advisory, not authoritative. The mental model: CHM gives you per-key linearizability, not multi-key serializability.

code

java · 17 lines
java
// Multi-field invariant collapsed into ONE key via an immutable value + compute -> atomic.
record Stats(long count, long sum) {}
ConcurrentHashMap<String, Stats> m = new ConcurrentHashMap<>();

// Atomically update BOTH fields together for a key:
m.compute(key, (k, cur) -> {
    if (cur == null) return new Stats(1, value);
    return new Stats(cur.count() + 1, cur.sum() + value); // one indivisible read-modify-write
});

// What you CANNOT do atomically with CHM alone:
//   m.merge(a, 1L, Long::sum);
//   m.merge(b, -1L, Long::sum);   // a and b are not updated as one transaction
// A reader can see a decremented, b not yet incremented. Use an external lock / single
// AtomicReference<ImmutableState> / transactional store when keys are coupled.

long approx = m.mappingCount(); // estimate under concurrent mutation; do not gate correctness on it

go deeper

for a junior

Understands that each CHM operation is individually safe, even if not the boundaries.

for a middle

Knows get-then-put sequences aren't atomic and that compound methods fix single-key check-then-act, plus that size() can be approximate.

for a senior

Clearly separates per-key atomicity from multi-key/aggregate consistency, and uses immutable value objects with compute to make multi-field updates atomic.

for a principal

Frames the guarantee as per-key linearizability vs multi-key serializability, designs invariants to fit one key, knows when to escalate to external coordination/transactions, and treats aggregates and weakly-consistent bulk views as advisory in system design.

## The precise guarantee ConcurrentHashMap provides **linearizability per single operation on a single key**: each `get`, `put`, `remove`, `putIfAbsent`, `computeIfAbsent`, `compute`, `merge` appears to take effect **instantaneously at some point** between its call and return, with no other operation interleaving *on that key during that call*. (*Linearizable* = behaves as if it happened atomically at one instant.) That is the **whole** guarantee. Everything below falls outside it. ## Boundary 1 — sequences of operations are not atomic Two *individually* atomic calls can interleave with another thread's calls: ```java if (map.get(k) == null) map.put(k, v); // even both atomic, the pair is racy map.merge(a, 1, sum); map.merge(b, 1, sum); // a and b not updated together ``` There is **no transaction** spanning calls. If your correctness depends on "these two updates happen together," CHM alone cannot give it. ## Boundary 2 — multi-key invariants Suppose an invariant ties two keys (e.g. "`count[A] + count[B]` is constant," or "moving an item from list A to list B"). CHM has **no way** to update both keys atomically. A reader can observe an intermediate state where A is decremented but B not yet incremented. Options: - **Collapse the invariant into one key:** store *both* fields in a single **immutable value object** and update it with `compute`, so the multi-field change is one atomic per-key op. This is the most idiomatic fix. - **External coordination:** guard the multi-key update with your own lock, or use an `AtomicReference` to an immutable snapshot of the whole relevant state, or move to a real transactional store (DB, STM). ## Boundary 3 — aggregate / bulk reads are weakly consistent - **`size()` / `mappingCount()`** are **estimates** under concurrent mutation. CHM counts with a striped `LongAdder`-style counter for scalability, so the returned total is racy. Never base correctness (e.g. capacity enforcement) on it; if you must, you need external synchronization. - **Iteration and the bulk ops** (`forEach`, `search`, `reduce`, and the keySet/values/entrySet views) traverse a **weakly consistent** snapshot: they never throw `ConcurrentModificationException`, but may reflect insertions/removals that happened during the traversal, possibly visiting some and missing others. They are **not** a consistent point-in-time snapshot of the whole map. - If you truly need a stable snapshot, you must **build one yourself** (e.g. copy under an external lock, or accept the weak view). ## Design patterns around the boundaries 1. **One key = one consistency unit.** Model each independently-mutated invariant so it lives behind a single key, then use `compute`/`merge` with an **immutable value** for the atomic read-modify-write. Immutability avoids torn reads of the value itself. 2. **Idempotent, side-effect-free remapping functions.** Because `compute`/`merge` can retry and run under the bin lock, keep functions pure, fast, non-blocking, and never mutate the same map inside them. 3. **Escalate when keys are coupled.** Genuine multi-key transactions need an external mechanism: a coarse lock around the critical section, a single `AtomicReference<ImmutableState>` updated via CAS, or a transactional datastore. Don't fake it with multiple CHM calls. 4. **Treat aggregates as advisory.** Use `size()` for metrics/heuristics, not invariants. For exact counts you need quiescence or external coordination. 5. **Know the parallelism knobs.** Bulk ops take a `parallelismThreshold`; below it they run sequentially. Parallel bulk ops still observe a weakly consistent view—parallelism doesn't add consistency. ## The one-line mental model **CHM gives per-key linearizability, not multi-key serializability, and not consistent global snapshots.** Architect each invariant to fit inside one key, or step outside the map for coordination.

  • You need to atomically move a value from key A to key B. Can CHM do it?
    Not as a single atomic step across two keys. Either redesign so both belong to one key (one value object updated via compute), or guard the move with an external lock / transactional store. Two separate CHM calls leave an observable intermediate state.
  • Why store an immutable value object rather than mutating a shared mutable value?
    Mutating a shared value object outside the map's control can be seen half-updated (torn reads) by lock-free readers. Replacing it wholesale via compute publishes a fully-formed, consistent value atomically per key.

saying these in an interview costs you the question

  • Assuming a chain of CHM calls is transactional
  • Using size() to enforce a hard capacity invariant
  • Believing forEach/iteration gives a consistent whole-map snapshot
  • Trying to keep a two-key invariant with two separate merge calls
  • Thinking parallel bulk operations add consistency

context