Ch. 7 · Java

Java Concurrent Collections and Compound Operations

Why check-then-act on ConcurrentHashMap still races, and how merge, compute and putIfAbsent make compound updates atomic per key.

~7 min readintermediateupdated Oct 6, 2026

“If ConcurrentHashMap is thread-safe, why does this word counter lose updates?” is a favourite interview prompt, usually followed by a snippet that calls get, adds one and calls put. It probes a misconception that causes real outages: that using a thread-safe collection makes the code around it thread-safe.

Interviewers want to hear the distinction between atomic single operations and atomic compound operations, the correct API for each pattern, and the trade-offs between ConcurrentHashMap, Collections.synchronizedMap and CopyOnWriteArrayList.

Before you start

You should know the Map interface, lambdas, and what a race condition is: two threads interleaving reads and writes so the result depends on timing. Understand that HashMap is not safe for concurrent writes at all; concurrent puts can lose entries or corrupt the table during a resize.

The short answer

ConcurrentHashMap guarantees that each individual method call is atomic and that the map never corrupts, but a sequence like get then put is two operations, and another thread can run between them. For read-modify-write on one key, use the atomic methods: merge, compute, computeIfAbsent and putIfAbsent. These run the whole update while holding that key’s bin, so concurrent updates to the same key are serialized and none are lost.

How it works

Since Java 8, ConcurrentHashMap is an array of bins. Reads are lock-free and use volatile reads of the table. Writes use a compare-and-swap to insert into an empty bin, or lock the first node of a non-empty bin with synchronized. Different keys in different bins update in parallel; the same key always contends on the same bin.

The racy pattern:

ConcurrentHashMap<String, Integer> counts = new ConcurrentHashMap<>();

void record(String word) {
    Integer old = counts.get(word);                 // thread A reads 4
    counts.put(word, old == null ? 1 : old + 1);    // thread B also read 4; both write 5
}
java

Both calls are individually atomic, but nothing prevents another thread from updating the key between them. Two increments produce one.

The atomic version:

void record(String word) {
    counts.merge(word, 1, Integer::sum);
}
java

merge looks up the key, applies the function to the old and new values, and stores the result, all under the bin lock. If the key is absent it stores the given value. If the function returns null, the mapping is removed.

The other collections in java.util.concurrent make different trade-offs:

  • CopyOnWriteArrayList copies the whole array on each write. Reads and iteration never lock and see a stable snapshot. It suits listener lists that are read constantly and changed rarely.
  • ConcurrentLinkedQueue and LinkedBlockingQueue handle producer-consumer handoff; the blocking variant adds put and take that wait.
  • ConcurrentSkipListMap is the sorted, concurrent counterpart of TreeMap.
  • Collections.synchronizedMap wraps every method in one lock. It is correct for single calls but serializes all access, and you must hold the wrapper’s lock yourself while iterating.

Step-by-step walkthrough

Step 1: Identify the compound operation

Look for any code that reads a value from the map and writes a value based on it: counters, “create if missing”, “remove if equals”. Each of these is a race unless it is a single atomic method call.

Step 2: Choose the matching atomic method

// increment a counter
counts.merge(key, 1L, Long::sum);

// lazily create a per-key structure
Map<String, List<Event>> byUser = new ConcurrentHashMap<>();
byUser.computeIfAbsent(userId, id -> new CopyOnWriteArrayList<>()).add(event);

// insert only if absent, and learn who won
Session existing = sessions.putIfAbsent(id, newSession);
Session active = existing != null ? existing : newSession;

// conditional remove and replace
sessions.remove(id, expiredSession);          // only if still mapped to that value
prices.replace(sku, oldPrice, newPrice);      // compare-and-set on the value
java

Notice the computeIfAbsent example: the map creation is atomic, but the list stored in it is shared by every thread that touches that user, so the list itself must also be thread-safe.

Step 3: Keep the remapping function small

compute and friends hold the bin lock while your function runs. A slow function blocks other writers to every key in that bin, and a function that updates another key of the same map can deadlock or throw IllegalStateException: Recursive update. Do the expensive work outside, then publish the result:

Report report = buildReport(id);                 // slow, outside the lock
reports.merge(id, report, Report::newer);        // fast combine inside
java

Step 4: Understand iteration guarantees

for (Map.Entry<String, Long> e : counts.entrySet()) {
    process(e.getKey(), e.getValue());
}
java

Iterators over a ConcurrentHashMap are weakly consistent. They never throw ConcurrentModificationException, they reflect the state at some point at or after their creation, and they may or may not show updates made during iteration. size() is an estimate under contention; use mappingCount() for a long and do not build logic on an exact size while writers are active.

Worked scenario

A rate limiter tracks requests per API key in a ConcurrentHashMap<String, AtomicInteger>:

AtomicInteger counter = limits.get(apiKey);
if (counter == null) {
    counter = new AtomicInteger();
    limits.put(apiKey, counter);
}
if (counter.incrementAndGet() > 100) {
    reject();
}
java

Under a burst of first requests from a new key, several threads see null, each creates its own AtomicInteger, and the last put wins. Increments made on the discarded counters vanish, so the key gets far more than 100 requests in its first window. The bug only appears under concurrency, which is why it passed tests.

The fix makes creation atomic and returns the single winning counter:

AtomicInteger counter = limits.computeIfAbsent(apiKey, k -> new AtomicInteger());
if (counter.incrementAndGet() > 100) {
    reject();
}
java

Every thread now increments the same object. Resetting the window should also be atomic, for example limits.remove(apiKey, counter) so a reset never removes a counter that another thread just replaced.

Common mistake

  • “ConcurrentHashMap makes my code thread-safe.” It makes each call safe. Invariants that span several calls or several keys still need atomic methods or external locking.
  • Using containsKey followed by get or put. The answer to containsKey can be stale by the next line.
  • Storing a non-thread-safe value. computeIfAbsent(k, x -> new ArrayList<>()).add(v) races on the ArrayList.
  • Null keys or values. ConcurrentHashMap rejects both with NullPointerException, because null from get must unambiguously mean “absent”.
  • Heavy work inside compute. It holds a lock and can block unrelated keys in the same bin.

Verify the behavior

A small stress test makes the race visible. Run it several times; the broken version usually loses counts on a multi-core machine:

import java.util.concurrent.*;

public class CounterRace {
    public static void main(String[] args) throws Exception {
        var racy = new ConcurrentHashMap<String, Integer>();
        var safe = new ConcurrentHashMap<String, Integer>();
        try (ExecutorService pool = Executors.newFixedThreadPool(8)) {   // Java 19+ close()
            for (int t = 0; t < 8; t++) {
                pool.submit(() -> {
                    for (int i = 0; i < 100_000; i++) {
                        Integer old = racy.get("k");
                        racy.put("k", old == null ? 1 : old + 1);
                        safe.merge("k", 1, Integer::sum);
                    }
                });
            }
        }
        System.out.println("racy=" + racy.get("k") + " safe=" + safe.get("k"));
        // safe is always 800000; racy is usually lower
    }
}
java

On Java 17, replace the try-with-resources with pool.shutdown() and pool.awaitTermination(1, TimeUnit.MINUTES). For rigorous checks, the OpenJDK jcstress harness explores interleavings systematically.

Follow-up questions

Why does ConcurrentHashMap not allow null? In a concurrent map you cannot follow get returning null with containsKey without a race, so null is reserved to mean “no mapping”.

How is Java 8+ ConcurrentHashMap different from the Java 7 version? Java 7 used a fixed number of segments, each a lock. Java 8 locks individual bins, uses CAS for empty bins, and converts long bins to trees, which improves both concurrency and worst-case lookup.

When would you choose CopyOnWriteArrayList? When reads and iteration vastly outnumber writes, such as event listener registries. For write-heavy lists, each write copies the full array and becomes very expensive.

How do you update two keys atomically? You cannot with ConcurrentHashMap methods alone. Use an external lock around both updates, or redesign so one key holds an immutable value containing both pieces.

Interview exercise

A cache uses ConcurrentHashMap<String, Config> and loads missing entries with cache.computeIfAbsent(key, this::loadFromDatabase), where loading takes up to two seconds. What happens under load, and what would you change?

Answer and reasoning

The good part: concurrent requests for the same missing key wait for one load instead of each hitting the database, because computeIfAbsent runs the function at most once per absent key. The risk: the bin lock is held for up to two seconds, so writers to other keys that hash to the same bin also stall, and the Javadoc explicitly asks for short, simple mapping functions. If loadFromDatabase ever touches the cache, it can trigger a recursive update error.

A better design stores futures so the lock is held only long enough to insert a placeholder: cache.computeIfAbsent(key, k -> CompletableFuture.supplyAsync(() -> load(k), loaderPool)) with values of type CompletableFuture<Config>. Callers join the shared future outside the lock, and failed loads should be removed with cache.remove(key, failedFuture) so they are retried. Libraries such as Caffeine implement exactly this pattern with expiry.

Continue learning

Work through more concurrency questions in the Java interview questions and the Java MCQs. Related notes: synchronized blocks and invariants, ConcurrentModificationException and HashMap collisions. Read the atomicity notes in the ConcurrentHashMap API documentation.

More in Java

esc