ConcurrentHashMap internals and CopyOnWriteArrayList
How ConcurrentHashMap gets thread safety without locking the whole map, and when CopyOnWriteArrayList is the right trade.
Concurrent collections — ConcurrentHashMap internals, CopyOnWriteArrayList
Virtual memory — paging, page tables, MMU, TLB
Closes the collections block.
ConcurrentHashMapinternals are a common follow-up once you've answered Day 048 well.
Part 1 — L-053 · Concurrent collections
Why HashMap fails under concurrency
Day 048's demo lost entries. Two distinct failure modes:
Lost updates — two threads compute the same bucket index, both create a node, one overwrites the other. Ordinary read-modify-write racing (Day 040's B-06).
The Java 7 infinite loop — resizing rehashed entries and reversed chain order. Two threads
resizing simultaneously could link node A → B and B → A, creating a cycle. A later get() on that
bucket would spin forever at 100% CPU. Java 8's order-preserving split (Day 048) removed this
specific disaster, but HashMap remains unsafe.
The wrong fixes
Map<K,V> m = new Hashtable<>(); // 💀 legacy
Map<K,V> m = Collections.synchronizedMap(new HashMap<>()); // 💀 barely better
Both lock the entire map on every operation. Two problems:
- No concurrency at all — one thread at a time, even for reads on different keys.
- Compound operations are still broken:
synchronized (map) { // you must lock EXTERNALLY anyway
if (!map.containsKey(k)) map.put(k, v);
}
Per-method synchronization gives you the cost without the benefit (Day 045). Each call is atomic; your sequence of calls is not.
ConcurrentHashMap
Java 7: lock striping
The map was divided into 16 segments, each with its own lock. Threads touching different segments never contended.
Concurrency level 16 — better than one lock, but coarse and memory-hungry.
Java 8: per-bucket locking + CAS
The segments were removed entirely. The current design:
| Operation | Mechanism |
|---|---|
Read (get) |
Completely lock-free — volatile reads of the table and nodes |
| Insert into an empty bucket | CAS — a single atomic compare-and-swap, no lock |
| Insert into an occupied bucket | synchronized on that bucket's first node only |
| Resize | Cooperative — multiple threads help transfer |
table
┌────┐
0 │ ●──┼──▶ [A] ← thread 1 locks THIS node
├────┤
1 │null│ ← thread 2 CASes a node in here — no lock at all
├────┤
2 │ ●──┼──▶ [B]─▶[C] ← thread 3 locks THIS node
└────┘
Three threads, three buckets, zero contention.
Effective concurrency is now the number of buckets, not 16. On a table of 1024 buckets, up to 1024 writers can proceed simultaneously.
Reads never block. get() is a volatile read walk — no lock, no CAS. Since most workloads are
read-heavy, this is the single biggest win.
Cooperative resizing
When a resize is in progress, a thread that arrives to write helps transfer buckets rather than
blocking. A ForwardingNode marks transferred buckets so readers are redirected to the new table.
Parallel resizing under load — one of the more elegant things in the JDK.
Why nulls are banned
Day 045 gave the argument; state it precisely:
map.get(k) // null
Absent, or present-and-null? On HashMap you resolve it with containsKey. On a concurrent map
that's useless — the mapping can change between the two calls. The ambiguity is unresolvable, so
nulls are forbidden for both keys and values.
The atomic compound operations — the actual API
This is what you should be using, and it's what most people miss:
map.putIfAbsent(k, v); // atomic check-then-put
map.computeIfAbsent(k, key -> expensive()); // atomic — the function runs ONCE
map.computeIfPresent(k, (key, v) -> v + 1);
map.compute(k, (key, v) -> v == null ? 1 : v + 1);
map.merge(k, 1, Integer::sum); // ✅ the idiomatic counter
map.replace(k, oldV, newV); // atomic CAS on the value
map.getOrDefault(k, 0);
// 💀 NOT atomic even on ConcurrentHashMap — two calls, race between them
if (!map.containsKey(k)) map.put(k, compute());
// ✅ atomic
map.computeIfAbsent(k, key -> compute());
// 💀 lost updates
map.put(k, map.getOrDefault(k, 0) + 1);
// ✅
map.merge(k, 1, Integer::sum);
merge for counters is the idiom to remember. It's atomic, concise, and correct.
One caveat: the function passed to computeIfAbsent runs while holding the bucket lock. Keep
it short, and never have it modify the same map — that deadlocks or corrupts state.
CopyOnWriteArrayList
Every mutation copies the entire backing array.
public boolean add(E e) {
synchronized (lock) {
Object[] es = getArray();
Object[] newEs = Arrays.copyOf(es, es.length + 1); // FULL COPY
newEs[es.length] = e;
setArray(newEs);
return true;
}
}
| Cost | |
|---|---|
| Read / iterate | O(1), completely lock-free |
add / remove |
O(n) — copies the whole array |
Use only when reads vastly outnumber writes. The canonical case is a listener list: registered once at startup, iterated on every event.
Its iterator holds a snapshot of the array at creation, so it never throws
ConcurrentModificationException (Day 052) — but it also never sees later changes, and it.remove()
throws UnsupportedOperationException.
The decision table
| Need | Use |
|---|---|
| Concurrent map | ConcurrentHashMap |
| Concurrent sorted map | ConcurrentSkipListMap |
| Read-mostly list (listeners) | CopyOnWriteArrayList |
| Concurrent set | ConcurrentHashMap.newKeySet() |
| Producer-consumer | ArrayBlockingQueue / LinkedBlockingQueue |
| Lock-free queue | ConcurrentLinkedQueue |
| Atomic counter | LongAdder (better than AtomicLong under contention) |
ConcurrentSkipListMap is the concurrent sorted map — a skip list rather than a red-black tree,
because skip lists are far easier to make lock-free (rebalancing a tree concurrently is very hard).
LongAdder over AtomicLong for high-contention counters: it maintains per-thread cells and sums
them on read, avoiding CAS contention on a single memory location. Day 068.
Type this yourself
import java.util.*;
import java.util.concurrent.*;
public class ConcurrentDemo {
public static void main(String[] args) throws Exception {
int threads = 8, perThread = 100_000;
// ---- 1. HashMap loses data ----
Map<Integer,Integer> unsafe = new HashMap<>();
runAll(threads, t -> { for (int i = 0; i < perThread; i++) unsafe.put(t * perThread + i, i); });
System.out.println("--- correctness ---");
System.out.printf(" HashMap: expected %,d got %,d%n",
threads * perThread, unsafe.size());
Map<Integer,Integer> safe = new ConcurrentHashMap<>();
runAll(threads, t -> { for (int i = 0; i < perThread; i++) safe.put(t * perThread + i, i); });
System.out.printf(" ConcurrentHashMap: expected %,d got %,d%n",
threads * perThread, safe.size());
// ---- 2. Throughput: synchronizedMap vs ConcurrentHashMap ----
Map<Integer,Integer> sync = Collections.synchronizedMap(new HashMap<>());
long start = System.currentTimeMillis();
runAll(threads, t -> { for (int i = 0; i < perThread; i++) sync.put(t * perThread + i, i); });
long syncTime = System.currentTimeMillis() - start;
Map<Integer,Integer> chm = new ConcurrentHashMap<>();
start = System.currentTimeMillis();
runAll(threads, t -> { for (int i = 0; i < perThread; i++) chm.put(t * perThread + i, i); });
long chmTime = System.currentTimeMillis() - start;
System.out.printf("%n--- %d threads × %,d puts ---%n", threads, perThread);
System.out.printf(" synchronizedMap: %,5d ms ← ONE lock%n", syncTime);
System.out.printf(" ConcurrentHashMap: %,5d ms ← per-bucket%n", chmTime);
// ---- 3. Compound operations still race ----
Map<String,Integer> counter = new ConcurrentHashMap<>();
runAll(threads, t -> {
for (int i = 0; i < 50_000; i++)
counter.put("k", counter.getOrDefault("k", 0) + 1); // NOT atomic
});
System.out.printf("%n--- counter ---%n get-then-put: expected %,d got %,d ← LOST UPDATES%n",
threads * 50_000, counter.get("k"));
Map<String,Integer> counter2 = new ConcurrentHashMap<>();
runAll(threads, t -> { for (int i = 0; i < 50_000; i++) counter2.merge("k", 1, Integer::sum); });
System.out.printf(" merge(): expected %,d got %,d ← ATOMIC%n",
threads * 50_000, counter2.get("k"));
// ---- 4. computeIfAbsent runs once ----
Map<String,String> cache = new ConcurrentHashMap<>();
var calls = new java.util.concurrent.atomic.AtomicInteger();
runAll(threads, t -> {
for (int i = 0; i < 1000; i++)
cache.computeIfAbsent("key", k -> { calls.incrementAndGet(); return "value"; });
});
System.out.printf("%n computeIfAbsent called the function %d time(s) across %d threads%n",
calls.get(), threads);
// ---- 5. CopyOnWriteArrayList ----
System.out.println("\n--- CopyOnWriteArrayList ---");
List<Integer> cow = new CopyOnWriteArrayList<>();
start = System.currentTimeMillis();
for (int i = 0; i < 20_000; i++) cow.add(i);
System.out.printf(" 20,000 adds: %,5d ms ← O(n) copy each time%n",
System.currentTimeMillis() - start);
start = System.nanoTime();
long sum = 0;
for (int i = 0; i < 200; i++) for (int v : cow) sum += v;
System.out.printf(" 200 full iterations: %,d ms ← lock-free (sum=%d)%n",
(System.nanoTime() - start) / 1_000_000, sum);
// ---- 6. nulls ----
System.out.println("\n--- nulls ---");
try { new ConcurrentHashMap<String,String>().put("k", null); }
catch (NullPointerException e) {
System.out.println(" rejected — get() returning null must mean ABSENT");
}
}
interface Task { void run(int threadId); }
static void runAll(int n, Task task) throws Exception {
Thread[] ts = new Thread[n];
for (int i = 0; i < n; i++) { int id = i; ts[i] = new Thread(() -> task.run(id)); ts[i].start(); }
for (Thread t : ts) t.join();
}
}
Four results to record:
HashMaploses entries;ConcurrentHashMapdoesn't.ConcurrentHashMapoutperformssynchronizedMap, often by a large factor — that's per-bucket versus global locking.getOrDefault+putloses updates even on aConcurrentHashMap. Individual operations are atomic; your sequence is not.mergefixes it.computeIfAbsentcalls the function exactly once across eight threads and eight thousand calls. That's what "atomic" buys you.
Part 2 — B-10 · Virtual memory
The idea
Every process sees a private, contiguous address space. The hardware translates to scattered physical frames.
Virtual (per process) Page table Physical RAM
┌──────────┐ ┌────────┐ ┌──────────┐
│ page 0 │ ──────────────▶ │ frame 7│ ─────────▶ │ frame 0 │
│ page 1 │ ──────────────▶ │ frame 2│ │ frame 1 │
│ page 2 │ ──────────────▶ │ on disk│ │ frame 2 │◀─ page 1
│ page 3 │ ──────────────▶ │ frame 0│ │ ... │
└──────────┘ └────────┘ │ frame 7 │◀─ page 0
└──────────┘
Three things it buys:
- Isolation — a process can only reach its own mapped pages (Day 003, Day 023's kernel mode)
- No external fragmentation — any free frame fits any page (Day 050's B-09)
- More virtual memory than physical — unused pages live on disk
Address translation
32-bit virtual address, 4 KB pages:
┌──────────────────────┬───────────────┐
│ page number (20) │ offset (12) │
└──────────────────────┴───────────────┘
│ │
▼ │
page table lookup │
│ │
▼ ▼
┌──────────────────────┬───────────────┐
│ frame number │ offset │ physical address
└──────────────────────┴───────────────┘
The offset passes through unchanged — only the page number is translated. That's why the page size determines the split.
The MMU and the TLB
The MMU (Memory Management Unit) is hardware that performs translation on every single memory access.
The problem: a page-table lookup is itself a memory access. So every access would cost two — one to read the table, one to read the data. That doubles memory latency.
The TLB (Translation Lookaside Buffer) is a small, very fast cache of recent translations — typically 64–1024 entries, fully associative.
Access an address
│
├─ TLB HIT (~99%) → translate in ~1 cycle
│
└─ TLB MISS (~1%) → walk the page table (~100+ cycles), then cache it
TLB hit rates above 99% are normal because of locality — the same reason CPU caches work (Day 001).
And this is why a context switch is expensive (Day 033's B-04): switching processes changes the page table, so TLB entries are invalidated and the new process starts with cold translations. Switching threads within a process keeps the same page table, so the TLB survives — which is exactly why thread switches are cheaper.
Page faults
Accessing a page not currently in RAM traps to the kernel:
1. MMU finds the page-table entry marked "not present" → page fault
2. Kernel checks whether the access is legal
→ illegal? SIGSEGV
3. Find a free frame (evict one if necessary — Day 057's B-11)
4. Read the page from disk
5. Update the page table, restart the instruction
| Type | Meaning |
|---|---|
| Minor | The page is in memory but not mapped for this process — cheap |
| Major | Must be read from disk — ~100 µs, roughly 1000× a RAM access (Day 001) |
Thrashing — so many major faults that the system spends more time paging than computing. Throughput collapses. Day 057's B-11 covers replacement policies.
Why this matters for the JVM
- Heap pages are allocated lazily —
-Xmsreserves address space, but physical frames are committed on first touch. Which is why-Xms=-Xmx(Day 027) helps: the reservation happens once. - A major page fault on heap memory is catastrophic for GC. A collector scanning a swapped-out heap generates thousands of disk reads, so a pause that should be 50 ms becomes seconds. This is why you never let a JVM swap — configure the container so the heap fits in RAM.
- Huge pages (2 MB instead of 4 KB) reduce TLB pressure for large heaps —
-XX:+UseLargePagesis a real tuning option for multi-gigabyte heaps. MaxRAMPercentage(Day 027) exists so the heap plus Metaspace plus thread stacks plus direct buffers all fit within the cgroup limit — otherwise the OOM killer terminates the process with no Java-level error at all.
Interview questions
Q: How does ConcurrentHashMap achieve thread safety?
In Java 8 and later, reads are entirely lock-free —
volatilereads of the table and nodes. Writes into an empty bucket use a single CAS. Writes into an occupied bucket synchronize on that bucket's first node only, so threads touching different buckets never contend. Resizing is cooperative: arriving writers help transfer buckets rather than blocking. Java 7 used sixteen segment locks; the current design's effective concurrency is the number of buckets.
Q: Why is Collections.synchronizedMap worse?
It wraps every method in a lock on the whole map, so only one thread proceeds at a time even for reads on unrelated keys. And it still doesn't make compound operations atomic — a
containsKeyfollowed by aputis two separately-locked calls with a race between them, so you have to lock externally anyway.
Q: Is ConcurrentHashMap fully thread-safe?
Each individual operation is atomic, but a sequence of operations isn't.
map.put(k, map.getOrDefault(k,0)+1)loses updates because the read and the write are separate. You have to use the atomic compound methods —merge,compute,computeIfAbsent,putIfAbsent.
Q: Why does ConcurrentHashMap forbid nulls?
A null return from
getwould be ambiguous between absent and mapped-to-null, and unlikeHashMapyou can't disambiguate withcontainsKeybecause the mapping can change between the two calls.
Q: When would you use CopyOnWriteArrayList?
Only when reads vastly outnumber writes, because every mutation copies the entire array. The canonical case is a listener registry populated at startup and iterated on every event. Iteration is lock-free over a snapshot, so it never throws
ConcurrentModificationException— but it also never sees changes made after the iterator was created.
Q: What is the TLB and why does it matter?
A hardware cache of virtual-to-physical page translations. Without it every memory access would need a page-table lookup, itself a memory access, doubling latency. Hit rates above 99% are typical due to locality. It's also why process context switches cost more than thread switches — changing the address space invalidates TLB entries, while threads in one process share a page table.
Q: Why must a JVM never swap?
Garbage collection scans large regions of the heap, so if pages have been swapped out the collector triggers thousands of major page faults at roughly 100 microseconds each. A pause that should be tens of milliseconds becomes seconds. The heap must fit in physical memory.
Mini task
- Run
ConcurrentDemo. Record all four results, especially the lost-update counter. - Rewrite a
synchronizedMap-based cache usingcomputeIfAbsent. Compare throughput. - Find every non-atomic compound operation in some code you have and convert it.
- Measure
CopyOnWriteArrayListadd versus iterate at 1k, 10k and 50k elements. - Check your machine's TLB size (
cpuidon Linux, orGet-ComputerInfo) and page size.
Exit questions
- Give two ways
HashMapfails under concurrency. - Describe
ConcurrentHashMap's Java 8 design — reads, empty-bucket writes, occupied-bucket writes, resize. - What is the effective concurrency level, and how does it differ from Java 7?
- Why is
synchronizedMapinsufficient — two reasons? - Show a compound operation that races on a
ConcurrentHashMap, and fix it. - Why are nulls forbidden?
- When is
CopyOnWriteArrayListappropriate? - Explain virtual address translation and what the offset does.
- What is the TLB, and how does it explain the process-vs-thread switch cost difference?
- Why must a JVM heap never be swapped out?
Articulation drill
Two minutes: "How does ConcurrentHashMap differ from HashMap and from synchronizedMap?"
Lock-free reads, per-bucket write locking, cooperative resize — then the caveat that compound operations still need the atomic methods. That caveat is what separates a good answer from a recited one.
Previous: Day 052 · Next: Day 054 — generics (not yet written — see Days index)
Collections block complete: Days 045–053. You can now explain every major implementation's internals, choose correctly between them, and whiteboard
HashMap— which is your Stage 1 exit-gate item and the most-asked Java interview question.