Checkout has a map of open orders. The HTTP thread writes it. The warehouse worker reads it. HashMap is not safe to share. Hashtable and Collections.synchronizedMap compile, then serialize every get behind one lock on the whole table.

ConcurrentHashMap is the shared map in java.util.concurrent.

Share a map with ConcurrentHashMap, not with a lock around HashMap. The series hub owns fail-fast vs weakly consistent vs snapshot. This post is the Java 8+ table: bins, no nulls, atomic per-key compute / merge, and the compound actions that are still your bug.

The lab is the same checkout domain as the hub: Order.id is the key, LineItem.sku is a stock key.

public record LineItem(String sku, int quantity, BigDecimal unitPrice) {}

public record Order(
        String id,
        String customerEmail,
        List<LineItem> items,
        BigDecimal total,
        boolean active) {}

A sample order for the snippets that follow:

Order order = new Order(
        "o-100",
        "ada@ex.com",
        List.of(new LineItem("SKU-1", 2, new BigDecimal("9.99"))),
        new BigDecimal("19.98"),
        true);

The whole-table lock you do not want

Hashtable synchronizes every method on the table itself. Two threads cannot get two different keys at the same time. Collections.synchronizedMap wraps a HashMap in the same story: one mutex, every call.

Map<String, Order> table = new Hashtable<>();
table.put("o-100", order); // locks the whole table

Map<String, Order> wrapped = Collections.synchronizedMap(new HashMap<>());
wrapped.put("o-100", order); // same: one mutex

Iteration is worse on the wrapper. You must lock the map yourself, or the iterator is a race:

synchronized (wrapped) {
    for (Order o : wrapped.values()) {
        System.out.println(o.id());
    }
}

Skip that lock and you can see a ConcurrentModificationException or a torn view. synchronizedMap does not make a for-each safe unless you hold the mutex.

Hashtable is the legacy whole-table lock. The legacy collections post is why it still compiles. It is not the shared map you want in 2026.

What ConcurrentHashMap actually is (Java 8+)

Java 8 rewrote the type. The mental model is an array of bins, not sixteen segments.

  • The table is an array of nodes. A key hashes to a bin.
  • An uncontended put into an empty bin is a CAS on that slot.
  • A contended update synchronizes on the bin head — that bin, not the map.
  • A long collision chain becomes a tree bin (same idea as HashMap). Writers lock that tree, still not the table.

Reads do not take that lock. A get is a volatile read of the table and a walk of one bin. Two warehouse threads can look up o-100 and o-101 without queuing behind each other.

Java 5–7 ConcurrentHashMap did stripe the table into segments (default 16) and lock a segment at a time. Interviewers still say “sixteen segments.” That is history. Java 8+ concurrency is per bin. Mention segments only to say they are gone as the locking model.

ConcurrentMap<String, Order> byId = new ConcurrentHashMap<>();
byId.put("o-100", order);
Order found = byId.get("o-100");

Program to ConcurrentMap (or Map) on the field. new the ConcurrentHashMap. ConcurrentMap adds putIfAbsent, replace, and the atomic remove(key, value) that Map later copied as defaults.

No null keys, no null values

HashMap allows one null key and null values. ConcurrentHashMap allows neither. A null is thrown at the call, not stored.

byId.put(null, order);          // NullPointerException
byId.put("o-100", null);        // NullPointerException
byId.get(null);                 // NullPointerException

Absence is “the key is missing,” not a null value. Use get + containsKey, putIfAbsent, or computeIfAbsent — do not smuggle “unknown order” as null. Hashtable also forbids nulls; that similarity is not a reason to use Hashtable.

Note: Returning null from compute / computeIfAbsent / merge means “do not store” (or “remove”). That is how you delete under the atomic methods. It is not how you store a null.

Atomic per key: putIfAbsent, compute, merge

Single-key updates that used to be check-then-act races are methods on the map.

Two HTTP threads can both see a missing order id and both put. putIfAbsent keeps the first write:

Order loadOrder(String id) {
    return new Order(id, "ada@ex.com", List.of(), BigDecimal.ZERO, true);
}

Order incoming = loadOrder("o-101");
Order previous = byId.putIfAbsent("o-101", incoming);
if (previous != null) {
    // another thread already installed o-101
}

replace and remove(key, value) are the compare-and-set forms. They succeed only if the current mapping is still the one you read:

Order packed = byId.get("o-100");
if (packed != null) {
    Order inactive = new Order(
            packed.id(), packed.customerEmail(), packed.items(), packed.total(), false);
    boolean swapped = byId.replace("o-100", packed, inactive);
}

remove("o-100", packed) is the same idea for delete: it returns false if another thread already replaced that mapping. Do not chain replace and remove on the old value — after a successful replace, the old packed instance is no longer the current mapping.

That is still one key. It is not a transaction across o-100 and o-101.

computeIfAbsent is the usual “load once” path. The mapping function runs at most once per key while that key is being installed:

Order created = byId.computeIfAbsent("o-101", id ->
        new Order(id, "grace@ex.com", List.of(), BigDecimal.ZERO, true));

computeIfAbsent is atomic per key. This is not:

if (!byId.containsKey("o-101")) {
    byId.put("o-101", loadOrder("o-101")); // two threads both load and both put
}

Stock by SKU is a merge. Each packed LineItem adds quantity:

ConcurrentMap<String, Integer> stock = new ConcurrentHashMap<>();

void receive(LineItem item) {
    stock.merge(item.sku(), item.quantity(), Integer::sum);
}

receive(new LineItem("SKU-1", 2, new BigDecimal("9.99")));
receive(new LineItem("SKU-1", 3, new BigDecimal("9.99")));
int onHand = stock.get("SKU-1"); // 5

compute replaces the value from the current one. Return null to remove:

byId.compute("o-100", (id, current) -> {
    if (current == null) {
        return null;
    }
    return new Order(
            current.id(),
            current.customerEmail(),
            current.items(),
            current.total(),
            false);
});

These methods lock (or CAS) that key’s bin. They do not lock the rest of the map. Two merge calls on SKU-1 and SKU-2 proceed together.

Note: Do not call back into the same ConcurrentHashMap from a compute* / merge function. The bin can be locked. Recursion on the same key deadlocks; mutating other keys is a surprise for readers. Keep the function a pure mapping.

Iterators: weakly consistent, no CME

A keySet / values / entrySet iterator on ConcurrentHashMap is weakly consistent. It does not throw ConcurrentModificationException. It may observe a mapping that another thread installed after the iterator was created, and it may miss a mapping that was removed.

for (Order order : byId.values()) {
    System.out.println(order.id()); // no extra synchronized; a put from another thread may or may not appear
}

That is the iterator contract, not a memory barrier for checkout. If the worker must see a particular put, that put has to happen-before the iteration — a queue hand-off, a join, or a volatile publication you designed. The iterator will not invent that edge.

You do not wrap the for-each in synchronized (byId). There is no single mutex to grab.

keySet() and newKeySet() are concurrent sets, not snapshot lists. A SKU registry that grows with every packed line is a hash set:

Set<String> skus = ConcurrentHashMap.newKeySet();
for (LineItem item : order.items()) {
    skus.add(item.sku());
}

That set is CHM-backed: weakly consistent iterators, no nulls, expected O(1) contains. It is not a CopyOnWriteArraySet.

size() is an estimate, and it is not HashMap.size

HashMap.size() reads a field. ConcurrentHashMap.size() sums LongAdder-style counters (baseCount plus cells). Under concurrent writes the number can lag a put that has not finished counting.

int n = byId.size();                 // int; estimate if other threads are writing
long nLong = byId.mappingCount();    // same estimate, long

size() is not a snapshot of the map. isEmpty and containsValue have the same caveat: useful when the map is quiet; transient when it is not. Prefer mappingCount() if you might grow past Integer.MAX_VALUE. Do not use size() == 0 as a substitute for “no thread will put.”

Compound actions across two keys are still your problem

Atomic per key is not atomic per pair. Debiting o-100 and crediting o-101 is two operations:

Order from = byId.get("o-100");
Order to = byId.get("o-101");
if (from != null && to != null) {
    BigDecimal five = new BigDecimal("5.00");
    // another thread can still mutate either mapping here
    byId.put("o-100", new Order(from.id(), from.customerEmail(), from.items(),
            from.total().subtract(five), from.active()));
    byId.put("o-101", new Order(to.id(), to.customerEmail(), to.items(),
            to.total().add(five), to.active()));
}

ConcurrentHashMap will not make that transfer atomic. You need a different design: one key that represents the transfer, a queue of commands, or explicit locking you own. Per-key atomicity is not a transaction.

The same trap: if (byId.containsKey(a) && byId.containsKey(b)) then remove both. Each call is safe. The pair is not.

A stock move between SKUs has the same hole:

stock.compute("SKU-1", (sku, n) -> n == null || n < 2 ? n : n - 2);
stock.merge("SKU-2", 2, Integer::sum);

SKU-1 can drop without SKU-2 rising if the thread dies between the two lines. CHM will not roll that back. Model the move as one key (a transfer id) or a queue of commands a single consumer applies.

Parallel forEach, search, reduce

ConcurrentHashMap adds bulk methods that can run on ForkJoinPool.commonPool() when the map is larger than a parallelism threshold. The first argument is that threshold: below it, the call runs on the caller’s thread.

byId.forEach(1L, (id, order) ->
        System.out.println(id + " " + order.total()));

Order expensive = byId.search(1L, (id, order) ->
        order.active() && order.total().compareTo(new BigDecimal("50")) > 0
                ? order : null);

BigDecimal booked = byId.reduceValues(1L, Order::total, BigDecimal::add);

There is no identity/basis overload for object values. An empty map yields null from that reduceValues. search treats a null return as “keep looking.”

Use these when you already have a large shared map and a scan that is safe to split. They are not a reason to pick ConcurrentHashMap over a Stream on a local HashMap. Spliterator and Collectors is the series follow-up for splitting collections in general. The methods exist; the threshold is a size hint; the pool is the common pool.

Sizing: initialCapacity matters, concurrencyLevel does not stripe

new ConcurrentHashMap<>() starts like HashMap at a small table (capacity 16 after rounding). If you know you will hold a hundred thousand order ids, pass initialCapacity so the table is not resized on the hot path.

ConcurrentMap<String, Order> byId = new ConcurrentHashMap<>(10_000);

The three-arg constructor still takes concurrencyLevel so Java 5 code compiles:

new ConcurrentHashMap<String, Order>(256, 0.75f, 32);

In Java 8+ that third argument is not a segment count. It is a sizing hint: if initialCapacity is smaller than concurrencyLevel, the table is grown to at least that many bins. It does not create 32 locks. Size with initialCapacity. Ignore concurrencyLevel in new code.

Load factor is accepted and used in that sizing arithmetic. You rarely need to pass it.

HashMap vs ConcurrentHashMap vs ConcurrentSkipListMap

TypeThreadsNullsOrderHot path
HashMapConfine to one threadone null key, null valuesnonedefault map
HashtableWhole-table synchronizednononedo not pick
Collections.synchronizedMapOne mutexdepends on the backing mapdependsrare; iterators still need the lock
ConcurrentHashMapPer-bin updates, concurrent readsnononeshared unsorted map
ConcurrentSkipListMapConcurrent skip listnosorted by keyshared sorted map

One thread: HashMap. Shared unsorted map: ConcurrentHashMap. Shared sorted keys belong in ConcurrentSkipListMap — that type is the concurrent queues and skip lists post, not this one. CHM wins on plain get / put. The skip list wins when you need ceiling / subMap under writers.

A HashMap published to two threads is a bug even if you never saw it fail in tests. Confine it, or switch the type.

Virtual threads do not change the pick. Loom makes blocking cheaper; it does not make HashMap safe to share. ConcurrentHashMap is still the shared map when many virtual threads hit the same orders-by-id table.

Still not a Queue

ConcurrentHashMap is a Map. It is not a Queue. There is no blocking take, no FIFO of values, no producer-consumer backpressure. Dumping work into put(id, order) and polling values() from workers will lose the hand-off contract.

Work that must wait for the next order id belongs on a BlockingQueue. A non-blocking end belongs on ConcurrentLinkedQueue. Keep the catalog in a ConcurrentHashMap.

Interview lens

Interviewers want the lock story, the null policy, the iterator contract, and computeIfAbsent — not a segment diagram from 2006.

QuestionHonest answer
Why not Hashtable?Whole-table lock on every call. CHM allows concurrent reads and per-bin writes.
Nulls?No null keys, no null values. HashMap allows them; CHM does not.
Iterator contract?Weakly consistent: no CME, may see later updates. Hub owns the definition.
Is computeIfAbsent atomic?Yes, per key. Check-then-put is not. Do not re-enter the map from the function.
What does size() return under writers?An estimate from striped counters. It is not HashMap.size, and not a snapshot.
Java 8 vs segments?Segments were the Java 5–7 model. Java 8+ locks bins (and tree bins). concurrencyLevel is not a stripe count.
Is it a Queue?No. No take, no FIFO of values. Use BlockingQueue / ConcurrentLinkedQueue.

Wrong answer: “ConcurrentHashMap is just a synchronized HashMap.” A synchronized HashMap is Collections.synchronizedMap — one mutex, and you still lock for iteration. CHM is a different table.

Cheat sheet

Type           ConcurrentHashMap — shared unsorted Map
Locking        Java 8+: per-bin (CAS empty, synchronized bin head); segments are history
Nulls          no keys, no values
Iterators      weakly consistent; no CME; may observe later puts
size()         estimate under concurrency; mappingCount() for long
Atomic / key   putIfAbsent, replace, remove(k,v), compute*, merge
Not atomic     two keys, check-then-act across contains + put
Sizing         initialCapacity; concurrencyLevel is not a segment count
Parallel bulk  forEach / search / reduce (threshold, common pool)
Not a Queue    no take, no hand-off — use BlockingQueue

Do             ConcurrentHashMap for a shared map
               computeIfAbsent / merge instead of check-then-put
Don't          Hashtable or synchronizedMap as the default shared map
               HashMap published to two threads
               null as a stored value

Do:

  • Use ConcurrentHashMap when more than one thread mutates or reads the same map.
  • Prefer computeIfAbsent / merge / putIfAbsent over contains-then-put.
  • Treat iterators as weakly consistent; do not wait on them for a happens-before.

Don’t:

  • Wrap a HashMap in synchronizedMap and call it concurrent.
  • Store nulls, or use size() == 0 as a shutdown signal.
  • Assume two compute calls on two keys are one transaction.

Wrap-up

ConcurrentHashMap is the JDK shared map: bins instead of a whole-table lock, no nulls, weakly consistent iterators, and atomic per-key compute / merge. Hashtable and Collections.synchronizedMap still compile. They serialize the table. HashMap stays the default when one thread owns the map. When the hot path is iteration of a tiny listener list, that is a different concurrent type.

Next optional step in the series When reads dominate and every write may copy the array. Copy-On-Write: Snapshot Iterators When Reads Dominate