Skip to content
Preptima
hardConceptCodingMidSeniorStaff

Every call you make is on a ConcurrentHashMap, and you still lost an update. How does a thread-safe collection get raced?

Thread safety composes per operation, not across operations. A get followed by a put is two atomic steps with a legal interleaving between them. You close the window by making the whole compound action one operation, such as compute or merge, or by holding a single lock across both steps.

5 min readUpdated 2026-07-29Target archetype: Big Tech, Enterprise Captive, Product Startup
Practice answering out loud

What the interviewer is scoring

  • Does the candidate point at the window between two calls rather than doubting the collection's thread safety
  • Whether check-then-act and read-modify-write are separated as two shapes of one defect
  • That the fix is described as widening the atomic unit instead of adding synchronisation to more methods
  • Can they explain why size or containsKey cannot inform a decision another thread may invalidate
  • Whether the defect is recognised as timing-dependent, and therefore as something a load test will not reliably reproduce

Answer

Atomicity does not compose

ConcurrentHashMap guarantees that each of its operations is atomic with respect to the others. It guarantees nothing about a sequence of them, and it cannot: between your get returning and your put being issued, the map is free to serve every other thread, and every interleaving it permits is a correct implementation of its contract. Two threads that both read the value 5, both add 1, and both write 6 have each used a thread-safe map correctly, and between them they have lost an increment.

The reason this trips people is that "thread-safe collection" gets heard as "thread-safe code". The unit of atomicity is the operation the collection exposes, so when an invariant spans two operations, the collection is the wrong place to look for it.

The two shapes this takes

Read-modify-write is the version above: read a value, compute a new one from it, write it back. The window sits between the read and the write, and what gets lost is one of the two updates.

Check-then-act is its sibling. You ask whether something is present or absent, then act on the answer.

// Broken. Two threads can both observe the absence and both construct a
// Session, and one of the two objects is then silently discarded.
Session s = sessions.get(id);
if (s == null) {
    s = new Session(id);
    sessions.put(id, s);
}

// Atomic. The map performs the absence check and the insertion as one
// operation, so every caller receives the same instance.
Session s = sessions.computeIfAbsent(id, Session::new);

The broken version is worse than a lost update when the object it creates owns something — a connection, a file handle, a registration in a metrics registry — because the duplicate that loses the race is never closed and never unregistered. The atomic version fixes it not by locking more widely but by pushing the whole compound action down into the one component that can perform it indivisibly.

The rest of the atomic-composite API exists for the same reason: putIfAbsent for a plain insert, merge for accumulating into whatever value is already there, compute when the new value is a function of the old, and the two-argument replace when you must overwrite only a value you have already seen. Each one corresponds to a hand-written sequence that has a window in it.

There is a cost attached, and naming it is what separates a confident answer from a memorised one. The function you pass to compute, computeIfAbsent or merge runs while that bin of the map is locked. Anything slow inside it — a database round trip, a remote lookup, acquiring a second lock — blocks every other writer hashing to the same bin, and the documentation is explicit that the function must not attempt to update other mappings of the same map. The atomic composite is the right tool for constructing a value and the wrong tool for doing work.

Aggregate methods cannot support a decision

size, isEmpty and containsKey on a concurrent map are correct at the instant they return and potentially stale immediately afterwards. Using one as the condition of an if recreates exactly the window you were trying to close, and the reason is definitional rather than a weakness of the implementation: with concurrent writers there is no single moment that the answer describes. Treat them as monitoring signals, not as control flow. The same caution applies to iteration, which here is weakly consistent and reflects some but not necessarily all writes made after it started.

Locking helps only if the lock spans the whole action

Where no atomic composite fits, because the invariant spans two maps or a map and a database row, you are back to a lock, and the lock has to be held across the entire compound action rather than around each individual access. This is where Collections.synchronizedMap misleads people. It makes each method synchronised on one lock, so a get followed by a put is still two separately locked operations with a gap between them. Closing that gap means holding the wrapper's own monitor across both calls, which serialises the entire map and gives up the concurrency you were paying for.

The lock also has to be the same object on every path that touches the state, and this is the part reviews miss. State guarded by this in one method and by a private lock object in another is not guarded at all, and nothing in the compiler will tell you. Recording which lock guards which field, beside the field, makes the omission visible to the next reader.

A timing bug is not a load bug

In the mid-1980s, the Therac-25 radiation therapy machine delivered massive overdoses to several patients because a race condition between the operator interface and the beam-setup code could leave the machine in an inconsistent state, and the hardware interlocks that had caught that condition on earlier models had been removed in favour of software checks. What an interviewer raising it wants to hear is that a concurrency defect is a function of interleaving rather than of volume, so it can lie dormant for years and then surface when a faster operator, a faster machine or a larger thread pool changes the timing. The defences worth keeping are the ones that do not depend on the racing code being correct.

Applied to your map, that means a load test at ten times the traffic is weak evidence of anything. The interleaving that loses the update may require the two threads to be within a handful of instructions of each other, which is rare per attempt and inevitable across billions of them. What does find these defects is looking for the shape during review — any read of shared state whose result decides a later write to that same state — together with invariants asserted at runtime rather than only in tests, so that when the interleaving finally happens you get a loud failure instead of a quiet inconsistency reported to you by a customer.

Likely follow-ups

  • What are the documented constraints on the function you pass to computeIfAbsent, and why do they exist?
  • If two maps have to be updated together, what goes wrong with two locks and how do you avoid it?
  • When is a compareAndSet retry loop preferable to a lock, and when does it become worse?
  • How would you catch this class of defect in code review or CI rather than in production?

Related questions

Further reading

concurrencyatomicityconcurrenthashmaprace-conditionslocking