Concurrency

Non-atomic check-then-act on a ConcurrentHashMap

Written and reviewed by Sahil Srivastav

ConcurrencyAtomicityCollections
# 8 threads x 50,000 increments on a ConcurrentHashMap<String, Integer>
$ java CounterRepro
expected total = 400000
actual   total = 271394      (32.2% of updates lost)

# And the duplicate-creation variant, same shape:
2026-03-11T09:14:02.881Z WARN  SessionRegistry - replacing existing session for tenant=acme
2026-03-11T09:14:02.881Z WARN  SessionRegistry - replacing existing session for tenant=acme
2026-03-11T09:14:02.884Z ERROR MeterRegistry  - duplicate gauge registration: sessions.active{tenant=acme}

What this error actually means

A `ConcurrentHashMap` guarantees that each individual operation is atomic and that the map’s internal state never corrupts. It guarantees nothing about a *sequence* of operations you perform on it. `get`, then decide, then `put` is three atomic operations with two gaps, and another thread can complete its own full sequence inside either gap.

The distinction is between the map being thread-safe and your *compound action* being thread-safe. Thread safety is not a property that composes: two atomic operations placed next to each other are not atomic together, and no amount of concurrency in the collection can make them so, because the map cannot see the invariant that spans them.

What makes `compute`, `computeIfAbsent`, `merge`, `putIfAbsent`, and `replace` different is that they perform the whole read-modify-write while holding the lock on the map’s internal bin for that key. The map does not just do two operations for you — it does them without releasing the per-bin lock in between, so no other thread can observe or modify that key mid-sequence. That is an atomicity guarantee your code cannot construct from the outside, which is precisely why these methods exist.

There is no exception here either. The lost-update form loses data silently: a counter that reads low, a set of accumulated items missing entries, a status field that reverts. The duplicate-creation form is louder only by accident — you notice it when the object being created has a side effect that complains, such as a metric registration that rejects duplicates or a connection that logs on open.

Causes, most common first

  1. 1Read-modify-write spelled as `get` then `put`. The canonical lost update: `map.put(k, map.get(k) + 1)`. Two threads read the same value, both add one, both store, and one increment vanishes. Identical shape for appending to a list, unioning a set, or recomputing a derived field.
  2. 2`containsKey` then `put` for "create if absent". Both threads find the key absent, both construct, both store. The surviving value is arbitrary, and the loser’s object is garbage — which matters a great deal if constructing it opened a socket, started a thread, or registered a listener.
  3. 3Mutating a value object obtained from `get`. The map operation is atomic; the mutation of the retrieved object is not protected by anything at all. `map.get(k).addItem(x)` from two threads races inside the value’s own data structure, which may not be thread-safe, producing corruption rather than merely a lost update.
  4. 4`size()`, `isEmpty()`, or iteration used in a decision. These are documented as reflecting a momentary state, not a snapshot, and are explicitly not useful for control flow under concurrent modification. Evicting "if size exceeds N" from multiple threads over-evicts, and iterating to compute a total while writers run yields a number that was never true.
  5. 5A compound invariant spanning two keys or two maps. Per-key atomicity is not enough when the invariant is "these two entries agree". `compute` on each key in turn is two atomic steps with a visible inconsistent state in between. This needs a lock or a single value object holding both fields.
  6. 6Assuming `putIfAbsent` avoids constructing the value. `putIfAbsent(k, expensiveNew())` evaluates the argument before the call regardless of whether the key is present, so every thread builds an object and all but one throw theirs away. Correct atomically, wasteful — and incorrect if construction has side effects.

When you see it

  • Counters and totals that are consistently *under* the true value, with the gap growing with thread count
  • The discrepancy scales with contention: negligible at one thread, tens of percent at eight
  • Two threads both log "creating" for the same key, and one of the created objects is silently discarded
  • A resource created for the discarded duplicate is never closed, producing a slow leak of connections or file handles
  • Duplicate-registration errors from metric registries, JMX, or event buses for a key that should be unique
  • Perfectly reproducible in aggregate and never reproducible for a specific request, so it cannot be traced from logs
  • Switching the map to `Collections.synchronizedMap` does not help, which is often the moment the real cause becomes clear

How to diagnose it

Step 1

Run the loss test rather than reasoning about it

N threads, M increments each, assert the total equals N×M. This takes two minutes to write, fails instantly on a get-then-put, and becomes the regression test. Reasoning about interleavings is slower and less convincing than the number.

var latch = new CountDownLatch(1);
IntStream.range(0, 8).forEach(i -> new Thread(() -> {
    await(latch);
    for (int j = 0; j < 50_000; j++) bump(map, "k");
}).start());
latch.countDown();

Step 2

Grep for the pattern directly

This bug has a syntactic signature, which makes it one of the few races you can find by reading rather than by running. Any `put` whose value expression contains a `get` of the same map is a defect.

grep -rn -E "\.put\([^,]+, *[a-zA-Z_]+\.get\(|containsKey\(" src/main/java | grep -v test

Step 3

Count constructions against distinct keys

For the duplicate-creation variant, log every construction with the key and compare distinct keys against total constructions. Any excess is a lost race, and the ratio tells you how contended the path is.

grep "creating session" app.log | awk '{print $NF}' | sort | uniq -c | awk '$1 > 1'

Step 4

Check for `Recursive update` in the logs

If a previous fix moved to `computeIfAbsent` and the mapping function touches the same map, the JDK detects it and throws. Seeing this means the atomic method is in place but the function is doing too much inside the bin lock.

grep -n "IllegalStateException: Recursive update" app.log

Step 5

Verify the value type is safe to mutate

If code mutates values obtained from the map, the atomicity of the map is irrelevant. Confirm the value is immutable or itself thread-safe; a plain `ArrayList` or `HashSet` as a map value under concurrent mutation is data corruption, not a lost update.

The fix

Replace the sequence with the single atomic operation that expresses it. `merge(k, 1, Integer::sum)` for accumulation, `compute(k, (key, old) -> …)` for a general read-modify-write, `computeIfAbsent(k, loader)` for create-if-absent, `replace(k, expected, updated)` when you need compare-and-set on a key. Each performs the whole update under the bin lock, so no gap exists for another thread to occupy.

For counters specifically, prefer `LongAdder` values over boxed `Integer` and arithmetic: `map.computeIfAbsent(k, x -> new LongAdder()).increment()`. The `computeIfAbsent` is atomic for creation, and `LongAdder` handles the increment with striped counters that scale far better than a contended CAS on a shared cell.

Keep the mapping function short, side-effect-free, and free of any access to the same map. It runs while the bin lock is held, so a slow function blocks every other operation on that key’s bin, and touching the same map can deadlock or throw `IllegalStateException: Recursive update`. If the value is expensive to build, store a `CompletableFuture` or a memoising `Future` as the value and let callers await it outside the lock.

Make map values immutable, and replace rather than mutate. `compute(k, (key, old) -> old.withItem(x))` is atomic and safe; `get(k).add(x)` is neither. If a value must be a mutable collection, make it a concurrent one and be explicit that the map protects the *mapping* and not the contents.

Never use `size()`, `isEmpty()`, or iteration as a decision input under concurrent writes. When you need a bound, enforce it with a structure designed for it — a `Semaphore` for admission, or a cache library with a size policy — rather than checking a count that is stale the instant you read it.

When the invariant spans two keys or two maps, per-key atomicity is insufficient and you need a lock around the whole transition, or a redesign that puts the co-varying fields in one immutable value under one key. This is the one case where reaching for a lock is the right answer rather than a failure of imagination.

// Loses updates: three operations, two gaps
Integer current = counts.get(key);
counts.put(key, current == null ? 1 : current + 1);

// Duplicates the value, and its side effects, under a race
if (!sessions.containsKey(tenant)) {
    sessions.put(tenant, openSession(tenant));   // both threads open a session
}

// Atomic accumulation: whole update under the bin lock
counts.merge(key, 1L, Long::sum);

// Atomic create-if-absent: the function runs at most once per key
Session s = sessions.computeIfAbsent(tenant, this::openSession);

// High-contention counters scale better with striped adders
counts.computeIfAbsent(key, k -> new LongAdder()).increment();

// Atomic read-modify-write on an immutable value
baskets.compute(userId, (k, old) ->
    (old == null ? Basket.empty() : old).withLine(line));

How to stop it coming back

  • Treat `get`-then-`put` and `containsKey`-then-`put` on a concurrent map as defects on sight; there is always an atomic method that replaces them
  • Prefer immutable map values and whole-value replacement so no code path can mutate a shared value outside the map’s atomicity
  • Add a contended-update test for every counter or accumulator: N threads, fixed increments, exact expected total
  • Keep mapping functions pure and fast — never perform I/O, acquire another lock, or touch the same map inside one
  • Never base control flow on `size()` or iteration of a concurrently modified map; they are documented as approximate for exactly this reason
  • Where creation has side effects, make the creation path unmistakably single-shot via `computeIfAbsent`, and log a warning if a second creation for the same key ever occurs

Practise production debugging in a real repository

Reading about a failure and reproducing one are different skills. Gronex ships broken backend repositories with failing test suites that encode the real invariant, so you debug from evidence instead of memorising symptoms.

FAQ

Why is `compute()` atomic when `get()` plus `put()` is not?

`compute` holds the internal lock for the key’s bin across the read, the function call, and the write. No other thread can see or modify that key in between. Two separate calls each take and release the lock, so there are two windows in which another thread can complete its own full sequence — and no client-side code can close them.

Would `Collections.synchronizedMap` fix it?

No. It makes each individual method call synchronised, exactly like `ConcurrentHashMap` already guarantees. Your compound action still consists of two separately locked calls with a gap between them. The only thing that helps is making the whole read-modify-write one operation — or holding your own lock across it.

What is the difference between `putIfAbsent` and `computeIfAbsent`?

Both are atomic, but `putIfAbsent` takes an already-constructed value, so every racing thread builds one and all but one discard it. `computeIfAbsent` takes a function that is invoked at most once per absent key. Use `putIfAbsent` for cheap values with no side effects, and `computeIfAbsent` when construction is expensive or does anything observable.

Why do I get "IllegalStateException: Recursive update"?

Your mapping function accessed the same `ConcurrentHashMap` it was invoked from. Because the bin lock is held for the duration, a nested modification can deadlock, so the JDK detects the reentry and throws instead. Compute what you need before the call, or store a future and resolve it outside the map operation.

Is `AtomicInteger` as a map value enough?

Yes for the increment, provided the *creation* of that `AtomicInteger` is also atomic — so `computeIfAbsent(k, x -> new AtomicInteger()).incrementAndGet()`, not `get`-then-`put`. Under heavy contention on a single key, `LongAdder` outperforms it substantially because it spreads the updates across cells instead of retrying one CAS.

Related

Other errors engineers hit next to this one

Full error and symptom index →