Learn Java with Tests

A TDD course for Java, in the spirit of Learn Go with Tests.

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

  • volatile buys visibility (no stale read) but not atomicity: three threads can still race on count++. synchronized gives 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.