Chapter 15 of 18
15 — Synchronization
Data races, synchronized, LongAdder, and ConcurrentHashMap — correctness by construction.
Kata 1: a Counter that survives 16 threads × 1000 increments without losing a single one.
Kata 2: a Cache where a given key is loaded exactly once, even when 8 threads ask for it at the same moment.
New ideas: data races, synchronized, LongAdder, ConcurrentHashMap — and why “just add a lock” is not a design.
Both are katas — write the code yourself. If you’re following along inside the solutions repo, the finished classes also ship in
src/main/java/sync— don’t open them yet. Write your own, run the tests, and expect the REDs here to be non-deterministic. That uncertainty is the lesson.
Kata 1 — Step 1: the test that wants to break
16 threads each run 1000 increment() calls. The arithmetic says the answer is 16000:
@Test
void countsIncrementsFromManyThreadsWithoutLosingAny() {
Counter counter = new Counter();
CyclicBarrier start = new CyclicBarrier(16);
ExecutorService executor = Executors.newFixedThreadPool(16);
// 16 tasks, each waits on `start`, then runs counter.increment() 1000 times...
assertEquals(16 * 1000, counter.count());
}
The CyclicBarrier is the trick: every thread waits until all 16 are lined up, then they all hit
increment() at the same moment.
RED — run it against the lazy implementation
public void increment() {
count++;
}
count++ reads count, adds one, writes it back. Two threads can interleave right between the
read and the write, and both write the same number back — one increment is lost.
Run gradle test --tests "sync.CounterTest". This RED is not deterministic — that’s the whole
point of a data race:
CounterTest > countsIncrementsFromManyThreadsWithoutLosingAny() FAILED
org.opentest4j.AssertionFailedError: expected: <16000> but was: <15998>
Run it again and the number changes (on a single core the race may even hide) — because the
outcome depends on when threads happen to interleave. If it passes by luck, bump increments
to 100_000 and run again. A race that “never happens” is still a bug.
GREEN — synchronized: make read-modify-write atomic
public synchronized void increment() {
count++;
}
synchronized takes the object’s monitor for the whole body, so one thread owns the
read-modify-write at a time. And count() must be synchronized too — reading while another
thread writes needs the same guard to see a consistent value. Run it: GREEN, every time.
REFACTOR — the idiomatic counter
For a single running counter the idiomatic choice is LongAdder — built for exactly this
read-modify-write storm:
public final class Counter {
private final LongAdder count = new LongAdder();
public void increment() {
count.increment();
}
public long count() {
return count.sum();
}
}
Kata 2 — the cache: correctness by construction
Loading is expensive, so the same key must trigger the load once. The chapter’s test uses the barrier trick again — 8 threads all hit 4 keys at once, counting loads:
AtomicInteger loads = new AtomicInteger();
Cache cache = new Cache(key -> {
loads.incrementAndGet();
return "loaded:" + key;
});
// 8 threads, each waiting on a barrier, then cache.compute("a"/"b"/"c"/"d") for each key...
assertEquals(keys.size(), loads.get()); // exactly 4
RED — the check-then-put classic
The tempting “just make it safe” version:
public synchronized String compute(String key) {
if (!values.containsKey(key)) {
values.put(key, loader.apply(key));
}
return values.get(key);
}
Run gradle test --tests "sync.CacheTest":
CacheTest > computesEachKeyOnceWhenManyThreadsAskForTheSameKeys() FAILED
org.opentest4j.AssertionFailedError: expected: <4> but was: <N> // N > 4
synchronized made it safe, but the load still runs many times — because containsKey then
put is a check-then-act race between the two method calls. The lock didn’t make it wrong;
the shape is wrong. (And this version also serializes all four keys behind one lock.)
GREEN — computeIfAbsent, and the race disappears by design
public String compute(String key) {
return values.computeIfAbsent(key, loader);
}
ConcurrentHashMap.computeIfAbsent is atomic per key: concurrent callers of the same key
share the loaded value (the loader runs exactly once); callers of different keys proceed in
parallel. Run it: GREEN — exactly 4.
Java-specific notes
volatilebuys visibility (no stale read) but not atomicity: three threads can still race oncount++.synchronizedgives both.- “Just add a lock” fails on two axes: scope (lock too big → everything serialized) and granularity (one lock for all keys → needlessly slow reads).
- Prefer immutable data and the concurrent collection over hand-rolled locks: you write less code and the invariants come from the JDK rather than from your discipline.
Run it
gradle test --tests "sync.*"
Key takeaway: a data race is invisible until it isn’t. Barriers and many threads make it
appear; synchronized fixes this particular atomic path — but the better fix is choosing the
building block (LongAdder, ConcurrentHashMap) where correctness is built in, not bolted on.
Next: 16 — reflection — what frameworks do that you almost never should.