ExerciseProduction incident
Production incident
The cache that computed everything twice
45 minintermediate3–12 yrs
A real incident: symptom first, cause hidden, tradeoff at the end.
The incident
A read-through cache sits in front of an expensive lookup. It is backed by a
ConcurrentHashMap, so concurrency was considered handled and nobody looked
again.
Two reports arrive in the same week:
1. The database sees roughly three times more queries than there are
distinct keys. The cache is working — the hit rate looks fine — and the
query count is still wrong.
2. A memory alert fires. The cache is configured with a capacity of a
thousand entries and has been observed holding over two thousand.
What the team found:
1. Every method on the map is documented as atomic, and every one of them
is being used correctly on its own.
2. Wrapping the cache methods in synchronized fixed both numbers and
halved throughput, so it was reverted.
3. size() sometimes disagrees with a count of the keys.
The two defects are not the same kind of problem and do not have the same
kind of fix. Find both, fix both, and be able to say which one
ConcurrentHashMap could never have solved for you.
What this teaches
- Every method is atomic; a sequence of them is not
- computeIfAbsent does the check and the insert inside the bin's lock
- size() is an estimate, so it cannot support a decision
- A capacity bound is a whole-map invariant and needs a lock, not an atomic
- Serialising the write path while reads stay lock-free is often the right trade
Starter
Starter.java
import java.util.*;
import java.util.concurrent.*;
import java.util.concurrent.atomic.*;
/**
* Production: the cache that computed everything twice.
*
* A read-through cache in front of an expensive lookup. It uses a
* ConcurrentHashMap, so the team considered concurrency handled.
*
* Two independent claims are measured separately, because a bounded cache is
* SUPPOSED to reload an evicted key and mixing the two would hide which defect
* you fixed:
*
* Phase 1 — capacity far above the key count, so nothing is evicted. Every
* key should be loaded exactly once.
* Phase 2 — a real bound, deliberately smaller than the key count. The map
* should never hold more than the capacity.
*
* Run it. Both fail.
*/
public class Starter {
static final int THREADS = 8;
static final int KEYS = 4_000;
static final int MAX_ENTRIES = 1_000;
static class Cache {
private final ConcurrentHashMap<Integer, String> entries = new ConcurrentHashMap<>();
private final AtomicInteger lookups = new AtomicInteger();
private final int capacity;
Cache(int capacity) {
this.capacity = capacity;
}
/** Stands in for the database call the cache exists to avoid. */
private String load(int key) {
lookups.incrementAndGet();
return "row-" + key;
}
/**
* DEFECT 1: containsKey then put. Both calls are atomic; the pair is
* not, so every thread that passes the check calls load().
*/
String get(int key) {
if (!entries.containsKey(key)) {
entries.put(key, load(key));
}
return entries.get(key);
}
/**
* DEFECT 2: a bound over the whole map, enforced with check-then-act
* on a size() that is only an estimate.
*/
void enforceCapacity() {
if (entries.size() > capacity) {
Iterator<Integer> it = entries.keySet().iterator();
if (it.hasNext()) entries.remove(it.next());
}
}
int size() {
return entries.size();
}
int lookups() {
return lookups.get();
}
}
public static void main(String[] args) throws Exception {
boolean ok = true;
System.out.println("── phase 1: " + THREADS + " threads, " + KEYS
+ " keys, nothing evicted ──");
Cache unbounded = new Cache(KEYS * 2);
hammer(id -> {
for (int k = 0; k < KEYS; k++) unbounded.get(k);
});
System.out.println(" distinct keys : " + KEYS);
System.out.println(" lookups made : " + unbounded.lookups());
ok &= check("each key was loaded exactly once", unbounded.lookups() == KEYS);
System.out.println();
System.out.println("── phase 2: the same keys against a capacity of "
+ MAX_ENTRIES + " ──");
Cache bounded = new Cache(MAX_ENTRIES);
AtomicInteger peak = new AtomicInteger();
hammer(id -> {
for (int k = 0; k < KEYS; k++) {
bounded.get(k);
bounded.enforceCapacity();
peak.accumulateAndGet(bounded.size(), Math::max);
}
});
System.out.println(" capacity : " + MAX_ENTRIES);
System.out.println(" peak entries : " + peak.get());
ok &= check("the cache never exceeded its capacity", peak.get() <= MAX_ENTRIES);
System.out.println();
System.out.println(ok ? "PASS" : "FAIL");
}
static void hammer(java.util.function.IntConsumer body) throws Exception {
Thread[] threads = new Thread[THREADS];
CountDownLatch start = new CountDownLatch(1);
for (int i = 0; i < THREADS; i++) {
final int id = i;
threads[i] = new Thread(() -> {
try {
start.await();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return;
}
body.accept(id);
});
threads[i].start();
}
start.countDown();
for (Thread t : threads) t.join();
}
static boolean check(String what, boolean passed) {
System.out.println((passed ? " ok " : " FAIL ") + what);
return passed;
}
}Run it locally:
cd exercises/java/concurrency/concurrenthashmap-internals/03-production
javac Starter.java -d /tmp/out && java -cp /tmp/out StarterHints
Hint 1
Write out the map calls get() makes. Two calls, one gap.
Hint 2
The lookup count is far above the key count even though no key is evicted in phase one. Where did the extra calls come from?
Hint 3
'No more than N entries exist' — is that a statement about one key, or about the map?
Hint 4
If eviction and insertion are not in the same critical section, what can happen between them?
Done when
- Phase one loads each key exactly once
- Phase two never exceeds the configured capacity, at any instant
- Reads still take no lock on the hit path
- A comment names which defect is per-key and which is whole-map, and why that decides the fix
Solution
Show the solution — try it yourself first
Solution.java
import java.util.*;
import java.util.concurrent.*;
import java.util.concurrent.atomic.*;
/**
* Solution: the cache that computed everything twice.
*
* Two defects needing two different kinds of fix — which is the whole lesson.
* ConcurrentHashMap gives you atomicity PER KEY. It cannot give you an
* invariant over the map as a whole, and no combination of its methods will.
*
* Defect 1 — containsKey then put. Each call is atomic, the pair is not, so
* every thread that passed the check called load(). Four thousand keys, more
* than thirteen thousand lookups. This defect is per-key, so a per-key
* atomic operation fixes it: computeIfAbsent performs the check and the
* insert inside the bin's lock.
*
* Defect 2 — the capacity bound. "No more than N entries exist" is an
* invariant over the whole map. size() is only an estimate, and even an
* exact one would not help, because check-then-act lets any number of
* threads pass the check together. No per-key operation can express a
* whole-map invariant, so this needs one lock covering the check, the
* eviction and the insert together.
*
* That is why get() has two shapes below. Unbounded, computeIfAbsent is
* enough and nothing blocks. Bounded, the write path is serialised — load()
* runs one at a time — while reads stay lock-free on the fast path, which is
* where the traffic is.
*
* A cache that needs both a bound and concurrent loads wants per-key locking
* or a real cache library. See the stretch.
*/
public class Solution {
static final int THREADS = 8;
static final int KEYS = 4_000;
static final int MAX_ENTRIES = 1_000;
static class Cache {
private final ConcurrentHashMap<Integer, String> entries = new ConcurrentHashMap<>();
private final AtomicInteger lookups = new AtomicInteger();
private final Object writeLock = new Object();
private final int capacity;
private final boolean bounded;
Cache(int capacity, boolean bounded) {
this.capacity = capacity;
this.bounded = bounded;
}
private String load(int key) {
lookups.incrementAndGet();
return "row-" + key;
}
String get(int key) {
if (!bounded) {
// FIX 1: check and insert inside the bin's lock. Exactly one
// load per key, and no lock on any other bin.
return entries.computeIfAbsent(key, this::load);
}
// Fast path: no lock. null is unambiguous here, because a
// ConcurrentHashMap cannot hold a null value.
String hit = entries.get(key);
if (hit != null) return hit;
synchronized (writeLock) {
// Re-check: another thread may have loaded it while we waited.
String again = entries.get(key);
if (again != null) return again;
// FIX 2: evict BEFORE inserting, both inside the lock, so the
// bound holds at every instant rather than on average.
if (entries.size() >= capacity) {
Iterator<Integer> it = entries.keySet().iterator();
if (it.hasNext()) entries.remove(it.next());
}
String value = load(key);
entries.put(key, value);
return value;
}
}
int size() {
return entries.size();
}
int lookups() {
return lookups.get();
}
}
public static void main(String[] args) throws Exception {
boolean ok = true;
System.out.println("── phase 1: " + THREADS + " threads, " + KEYS
+ " keys, nothing evicted ──");
Cache unbounded = new Cache(KEYS * 2, false);
hammer(id -> {
for (int k = 0; k < KEYS; k++) unbounded.get(k);
});
System.out.println(" distinct keys : " + KEYS);
System.out.println(" lookups made : " + unbounded.lookups());
ok &= check("each key was loaded exactly once", unbounded.lookups() == KEYS);
System.out.println();
System.out.println("── phase 2: the same keys against a capacity of "
+ MAX_ENTRIES + " ──");
Cache bounded = new Cache(MAX_ENTRIES, true);
AtomicInteger peak = new AtomicInteger();
hammer(id -> {
for (int k = 0; k < KEYS; k++) {
bounded.get(k);
peak.accumulateAndGet(bounded.size(), Math::max);
}
});
System.out.println(" capacity : " + MAX_ENTRIES);
System.out.println(" peak entries : " + peak.get());
ok &= check("the cache never exceeded its capacity", peak.get() <= MAX_ENTRIES);
System.out.println();
System.out.println(ok ? "PASS" : "FAIL");
}
static void hammer(java.util.function.IntConsumer body) throws Exception {
Thread[] threads = new Thread[THREADS];
CountDownLatch start = new CountDownLatch(1);
for (int i = 0; i < THREADS; i++) {
final int id = i;
threads[i] = new Thread(() -> {
try {
start.await();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return;
}
body.accept(id);
});
threads[i].start();
}
start.countDown();
for (Thread t : threads) t.join();
}
static boolean check(String what, boolean passed) {
System.out.println((passed ? " ok " : " FAIL ") + what);
return passed;
}
}Stretch
The bounded fix serialises every miss, so one slow lookup blocks all of
them. Design the version that does not: a lock per key rather than per map.
Say where those locks live, how they are cleaned up, and why the naive
version of that idea leaks. Then argue whether you would ship it or take a
cache library instead.