LLD Dojo

Java Bridge · J12

Concurrency primitives, mapped — synchronized, volatile, Atomic*, and happens-before

The idea

volatile is not std::atomic

Appender.write is called for the same instance from many threads, and the logger never serialises those calls. Its contract says so outright. So a destination that counts the lines it took holds shared mutable state, and this is the version that gets written first:

private int written;
public void write(String line) { written++; }

200 threads, 1000 lines each, expecting 200000. Measured: got 121835 (lost 78165).

You know why that happens. The interesting part is the fix you reach for next, because Java's volatile is not the C++ keyword you learned to route around. In Java it is specified against the memory model: a write to it happens-before every later read. So:

private volatile int written;

Run it again: got 51048 (lost 148952). Worse on that run, and never correct on any of sixteen. volatile orders the load and the store; it never joins them. written++ is still a load, an add and a store, and another thread still lands between them.

What volatile does cover is one write read by somebody else, with no arithmetic in the middle. Curveball 01 runs asynchronous destinations on a background thread. A flag telling that thread to stop is exactly that shape, and nothing else is.

An increment is a read-modify-write, so it needs one operation that does all three:

private final AtomicInteger written = new AtomicInteger();
written.incrementAndGet();

got 200000 (lost 0). That is fetch_add, the same instruction you already know, with no lock. volatile qualifies a field. Atomic* gives you an operation.


Coming from C++

From C++ — the same guarded destination, and where the mapping stops being one-to-one

You know this material as std::mutex, std::lock_guard and std::atomic, and most of it transfers. Three things do not, and each one produces working-looking Java that a 200-thread stress suite takes apart. This page is the mapping first, then those three.

The same destination, both languages

A destination that keeps two numbers about itself: how many lines it took, and how many characters those lines came to. Every line in the demo is 38 characters, so the two numbers are related, and bytes == lines * 38 is the invariant.

C++ — a mutex you declared, and a guard with a scope

class TailAppender {
public:
    void write(const std::string& line) {
        std::lock_guard<std::mutex> guard(mutex_);
        ++lines_;
        bytes_ += line.size();
    }

    Tally tally() const {
        std::lock_guard<std::mutex> guard(mutex_);
        return Tally{lines_, bytes_};
    }

private:
    mutable std::mutex mutex_;
    int lines_ = 0;
    long bytes_ = 0;
};

Java — a lock you declared, and a block with a scope

public static final class Guarded implements Appender {

    private final Object lock = new Object();
    private int lines;
    private long bytes;

    @Override
    public void write(String formattedLine) {
        synchronized (lock) {
            lines++;
            bytes += formattedLine.length();
        }
    }

    public Tally tally() {
        synchronized (lock) {
            return new Tally(lines, bytes);
        }
    }
}

Line for line, the two are the same design. synchronized (lock) acquires on entry and releases on every exit, including the exit an exception takes, so it is the lock_guard rather than the lock()/unlock() pair. new Object() is the whole allocation: there is no std::mutex type to name, because every Java object already has a monitor.

Note what the Java version does not need. lines and bytes are plain fields with no volatile and no atomic wrapper. Releasing the monitor and re-acquiring it is itself the ordering guarantee, so the next thread in reads both writes. Adding volatile to a field already guarded by a lock buys nothing and tells a reader you were unsure which mechanism was doing the work.

The delta table

C++JavaWatch for
std::mutex m; as a membernothing to declare; every object has a monitorthe monitor is public, see trap 2
std::lock_guard<std::mutex>synchronized (lock) { … }releases on exception too, no finally needed
std::unique_lock with manual lock/unlockReentrantLock with lock() and unlock()the unlock() must be in a finally, see below
m.try_lock()reentrantLock.tryLock(), and the timed overloadsynchronized has no try form at all
std::recursive_mutexthere is no other kind; both Java locks are reentranttrap 3
std::atomic<int>, fetch_addAtomicInteger.incrementAndGet / getAndAddvolatile int is not this, see trap 1
compare_exchange_strongAtomicReference.compareAndSetreturns boolean, does not write back the seen value
compare_exchange_weak in a retry loopupdateAndGet(fn) / accumulateAndGetthe loop is written for you
std::atomic<T> for a small structAtomicReference<T> swapping an immutable objecta reference swap, never a lock-free struct
volatile (near-useless for threads)volatile gives visibility and ordering, no atomicitytrap 1, the highest-cost confusion here
std::memory_order_relaxed and friendsno per-operation parameter existstrap 4
std::condition_variablelock.wait() / notifyAll(), or Conditionspurious wakeups in both, so loop on the predicate
std::shared_mutexReentrantReadWriteLock, or StampedLockrarely worth it below heavy read contention
std::lock(a, b) deadlock-free acquireno equivalent; you order the locks by handone lock is the answer that scores, see the end
std::thread joinThread.join(), or an ExecutorServiceboth give a happens-before edge
thread_localThreadLocal<T>needs remove() on pooled threads, or it leaks
a data race is undefined behavioura data race gives a stale or unexpected valueno UB, so failures look like wrong numbers
std::map needs external lockingConcurrentHashMap locks per bin, internallyper-operation only, see trap 5

Trap 1 · Three different meanings of one word

volatile is the word that means three things across the two languages, so it is worth writing all three down.

C++ volatile. Stops the compiler eliding a load or a store, and does nothing about ordering between threads or about atomicity. Correct for a memory-mapped register, wrong for anything shared between threads. You already ignore it for that reason.

Java volatile. A real threading tool. A write to a volatile field happens-before every later read of it, and the compiler and CPU may not reorder around it. So a reader never sees a stale value, and never sees writes that came before it out of order.

std::atomic / Java Atomic*. Adds the thing neither volatile gives: read-modify-write as one indivisible operation.

The measurement, from worked/, 200 threads incrementing 1000 times each:

--- 1. int written; written++
  expected 200000, got 121835  (lost 78165)

--- 2. volatile int written; written++
  expected 200000, got 51048  (lost 148952)

volatile lost more on that run, not fewer, and the honest version of that claim needs the spread. Over sixteen runs the plain field landed between 77175 and 196507, and the volatile field between 47113 and 177229. The volatile field lost more than the plain one in 11 of the 16. Neither reached 200000 once.

The direction is not the lesson and it is not stable, so do not learn it as a rule. What is stable is that adding the keyword changed nothing about correctness. A plain field lets each core keep the counter in its own cache; a volatile field forces every access out to shared memory. Both leave the same window between the load and the store.

So volatile is right for a single write read elsewhere, with no arithmetic in between. curveballs/01-async-appenders/ runs asynchronous destinations on one background thread, and a flag telling that thread to stop is that shape. Block 9 of worked/ runs both versions of it:

--- 9. the one job volatile has: a stop flag
  boolean stopped          reader still spinning 2s after stop(): true
  volatile boolean stopped reader still spinning 2s after stop(): false

The plain version never stops. The reader's loop hoisted the field read out of the loop, which it is entitled to do, because nothing in that thread writes the field. Fifteen runs, true every time. The volatile version exited inside the two seconds on all sixteen.

The threshold, since volatile is cheap enough to over-apply. Three conditions have to hold. The field is written by one party and read by others. The write does not depend on the current value. No other field has to change with it. Miss any one and you need an atomic or a lock. volatile on a field you also guard with a lock is noise; volatile on anything you ++ is a bug.

Trap 2 · The lock you did not declare is public

Every Java object has a monitor. synchronized on a method is synchronized (this) around the body, and this is a reference your callers hold. So any caller can take your lock, from anywhere, and you cannot see the code that does it:

synchronized (exposed) {          // legal, on any object, from any class
    // your appender's write() now blocks until this block ends
}

Block 6 of worked/ does that to an ExposedMonitor whose write is a synchronized method:

--- 6. synchronized(this): an outside caller takes your lock
  write() finished while an outsider held the monitor: false (waited 500ms)
  after the outsider let go                          : true, lines=1

No exception, no warning, no way to find the culprit from inside the class. A private final lock object cannot be reached by name, so this failure mode does not exist:

private final Object lock = new Object();

The threshold, because synchronized(this) is not always wrong. Use a private final lock object when any one of these holds:

  1. A reference to the object reaches code you did not write. A registered listener, an injected callback, a lambda a caller handed you, or anything you return from a getter.
  2. The class is public and not final. A subclass adding synchronized methods silently shares your lock, and the two of you can now deadlock without either file mentioning the other.
  3. There is more than one independent invariant, so you want more than one lock. this gives you exactly one.

If none of the three holds, synchronized on this is defensible and shorter. That case is a final class with a narrow surface and one lock over all of its state, which is what corpus/lru-cache/reference/src/LruCache.java chose. Say which of the two you picked and why, and either answer holds up.

Trap 3 · Re-locking a lock you already hold

std::mutex m;
m.lock();
m.lock();     // undefined behaviour, usually a hang

You have learned to avoid that, which usually means avoiding a public method calling another public method of the same class. Java removes the hazard: both the object monitor and ReentrantLock count re-entries by the owning thread. writeWhileHolding in worked/ takes the lock and calls write, which takes it again:

  hold count seen inside write, called under lock: 2

ReentrantLock.getHoldCount() reports 2 and the call returns normally. So the C++ habit of splitting a class into locking public methods and non-locking private helpers is optional here. It is still worth doing, for a different reason. A public method that takes the lock and calls another public method that takes it again holds the lock for the length of both. Neither author chose that critical section.

Trap 4 · There is no memory order to choose

You will look for the parameter. It is not there.

counter.fetch_add(1, std::memory_order_relaxed);

Every Java volatile access and every Atomic* method behaves as sequentially consistent. There is no relaxed, no acquire-release pair, and no fence type to pick per operation. That is a language decision rather than an omission. The cost of the strong default is paid on every atomic access. What it buys is that no Java concurrency review argues about memory orders.

Two honest footnotes. VarHandle, since Java 9, does expose getAcquire, setRelease and getOpaque, which is where the weaker modes went. No interview asks about it, and the reference solutions in this corpus have no use for it. Second, JIT compilers do elide the barriers a particular platform does not need, so the strong default is not always a machine-level cost.

Trap 5 · A ConcurrentHashMap makes operations atomic, not sequences

The name suggests it takes the whole problem away. What it takes away is exactly one thing: each individual call is atomic and safe from many threads at once. A sequence of calls is yours to make correct, the same as it was with std::map under a mutex.

Counting how often each message arrived, on a ConcurrentHashMap:

Integer seen = counts.get(message);
counts.put(message, seen == null ? 1 : seen + 1);

The get is atomic. The put is atomic. The window between them is where every other thread's get also lands. 200 threads, 1000 records each, on a ConcurrentHashMap:

--- 7. ConcurrentHashMap: 200 threads, one message, 1000 records each
  get(k) then put(k, v+1)   count("retrying") = 18803
  merge(k, 1, Integer::sum) count("retrying") = 200000

18803 of 200000, and over sixteen runs the get-then-put version landed between 7617 and 36064. Its best run kept 18% of the increments, and nothing ever threw. merge returned exactly 200000 on all sixteen, because the remapping function runs while the map holds that bin's lock. There is no window to lose. The one-call family is merge, compute, computeIfAbsent, computeIfPresent, putIfAbsent and replace. If your update reads the old value, one of those six is the answer.

ConcurrentHashMap is also not a Collections.synchronizedMap(new HashMap<>()). A synchronized map takes one lock for every operation, which serialises readers against each other. The concurrent map locks per bin and lets readers run with no lock at all. That is the same reasoning behind CopyOnWriteArrayList in corpus/logger/reference/src/Logger.java: pick the structure by which side is hot.

The memory model, in one page

Java defines a partial order over actions called happens-before. If A happens-before B, then B sees everything A did. If neither orders the other and both touch the same non-volatile field, that is a data race and you get no guarantee about what is read.

The edges worth memorising, because they are the ones a design actually uses:

EdgeThe rule
Program orderWithin one thread, an earlier statement happens-before a later one
MonitorReleasing a monitor happens-before any later acquire of the same monitor
volatileA write to a volatile field happens-before every later read of that field
Thread.start()Everything the starting thread did happens-before the new thread's first action
Thread.join()Everything the joined thread did happens-before join() returning
final fieldsA correctly constructed object's final fields are visible with no lock
TransitivityA before B and B before C gives A before C

Four consequences that change how you write Java, rather than how you describe it.

A data race is not undefined behaviour. In C++ it licences anything. In Java you get a stale value or an unexpected interleaving, and the program keeps running. That is worse to debug and better to survive, and it is why the failures in this lesson are wrong numbers rather than crashes.

Reads and writes of most fields are indivisible anyway. No int, no reference and no smaller primitive can be observed half-written, whatever the interleaving. The two exceptions are non-volatile long and double, which the specification permits to be read as two halves from two different writes. Declaring those volatile removes that, which is one thing volatile gives you that has no C++ analogue.

final is the safe-publication mechanism J4 pointed here for. A Tariff with three final long fields, published to another thread after construction, is readable with no lock and no volatile. The guarantee has one condition, and it is J4's constructor trap: this must not escape the constructor. Leak it from a constructor and the guarantee is void.

Both stress suites in this corpus rely on join, not on locks, to read their results. Block 1 of worked/ reads a plain int field from the main thread after the writers finish. That read needs no synchronization, because the pool's threads completing happens-before the main thread observing them. The same edge is why an ArrayList filled by one thread and read after join() is safe.

That is the whole model you need for an LLD round. There is more of it: causality requirements, and what a correctly synchronized program is allowed to observe. None of that changes a design decision at this level.

What the graded concurrency leg is measuring

The suites are real, they run 200 threads, and they were tuned against deliberately broken versions of the reference. corpus/lru-cache/reference/DECISION_LOG.md records the tuning: with get unsynchronized, CROWD = 4 caught the bug 3 times in 10 attempts, CROWD = 200 caught it 6 to 9 times in 10. The shipped 20 repetitions caught it on the first repetition in three consecutive runs.

What scores is not the count of synchronized keywords. It is a sentence naming the state and its lock. LruCache's own javadoc is that sentence:

Two pieces of mutable state: values, the key→value map, and whatever bookkeeping the injected EvictionPolicy keeps internally. They change together, so they share exactly one lock.

Two consequences follow from having written it down. get is synchronized too, because LruCacheApi says a read mutates eviction standing, so a read is a write to the state that lock protects. And listeners is deliberately outside that lock, as a CopyOnWriteArrayList, because it is read on every eviction and written almost never.

Both variants that skipped this were built and measured, and the decision log records what they did. An unsynchronized get produced capacity must still be the hard ceiling under a mixed read/write storm ==> expected: <20> but was: <119>. A plain ArrayList for the listeners threw ConcurrentModificationException at a caller who only wanted to insert a key.

The habit that costs you the mark is spreading synchronized across methods until the suite goes green. It usually does go green. Then the question arrives: what does that lock protect? Being able to answer it is the reason synchronized (this) versus a private lock is a design decision rather than a style choice.


Worked walkthrough

NOTES — seven files, nine measurements, and three of them are supposed to be wrong

Run it first

..\..\..\..\.toolchain\jdk-21\bin\javac.exe -Xlint:all -d out *.java
..\..\..\..\.toolchain\jdk-21\bin\java.exe -cp out Main

javac -Xlint:all prints nothing and exits 0. Every class here compiles clean, which is the first thing to notice: a lost update is not a compile error and there is no flag that finds one.

Real output, verbatim, from one run:

line length = 38 chars, crowd = 200 threads, 1000 writes each, expected total = 200000

--- 1. int written; written++
  expected 200000, got 90460  (lost 109540)

--- 2. volatile int written; written++
  expected 200000, got 138353  (lost 61647)

--- 3. AtomicInteger.incrementAndGet()
  expected 200000, got 200000  (lost 0)

--- 4. two fields, one invariant: bytes == lines * 38
  two atomics, one per field   inconsistent reads: 23905, final tally Tally[lines=200000, bytes=7600000] consistent: true
  one private lock over both   inconsistent reads: 0, final tally Tally[lines=200000, bytes=7600000] consistent: true

--- 5. ReentrantLock: tryLock, and re-entering a lock you hold
  tryWrite while another thread holds the lock : false
  tryWrite once it is free                     : true
  hold count seen inside write, called under lock: 2

--- 6. synchronized(this): an outside caller takes your lock
  write() finished while an outsider held the monitor: false (waited 500ms)
  after the outsider let go                          : true, lines=1

--- 7. ConcurrentHashMap: 200 threads, one message, 1000 records each
  get(k) then put(k, v+1)   count("retrying") = 26443
  merge(k, 1, Integer::sum) count("retrying") = 200000

--- 8. collapsing duplicates: 200 threads, the identical key, one delivery allowed
  getAndSet        accepted: 1
  get() then set() accepted: 2

--- 9. the one job volatile has: a stop flag
  boolean stopped          reader still spinning 2s after stop(): true
  volatile boolean stopped reader still spinning 2s after stop(): false

Which of those numbers you can rely on, measured over sixteen runs

This matters more than any single run, because a demonstration of a race that only sometimes races teaches the wrong thing.

LineOver 16 runsReliable?
block 1, plain int77175 to 196507, never 200000yes, always wrong
block 2, volatile int47113 to 177229, never 200000yes, always wrong
block 3, AtomicInteger200000, every runyes, always right
block 2 versus block 1volatile lost more in 11 of 16no — direction is not stable
block 4, two atomics23905 to 1714878 inconsistent readsyes, always non-zero
block 4, one lock0 inconsistent reads, every runyes, always zero
block 6, blocked writefalse then true, every runyes
block 7, get-then-put7617 to 36064, best run kept 18%yes, always wrong
block 7, merge200000, every runyes, always right
block 8, getAndSetexactly 1, every runyes, and it is a guarantee
block 8, get-then-set1, 2, 3 and 7 all observedno — it was correct in 1 of 16
block 9, plain flagstill spinning, every runyes

Two rows say no, and they are worth more than the rows that say yes.

Block 2's direction is a hardware artefact, so do not learn "volatile is slower and worse". What is stable is that the keyword changed nothing about correctness.

Block 8's get-then-set version returned the right answer on one run in sixteen. That is what a race demonstration looks like when it is honest. Broken code passing once is the same phenomenon corpus/lru-cache/reference/DECISION_LOG.md records at length. An earlier draft of that suite's read-storm scenario passed against a known-broken cache every single time. It was replaced rather than shipped.


Appender.java

void write(String formattedLine);

One method, and the contract's sentence above it is the whole reason this lesson exists. The logger calls this for the same instance from many threads and never serialises the calls. So the first question about any Appender with a field is what guards that field. corpus/logger's reference has no such field, which is why its own stress suite passes: Logger.offer builds a fresh String from a local and touches nothing shared.


LineCounters.java

Unguarded

private int written;

@Override
public void write(String formattedLine) {
    written++;
}

Nothing here is guaranteed at all. Two threads can read 41, both add one, and both store 42. It compiles with -Xlint:all clean, passes any single-threaded test, and loses between 2% and 61% of its increments under the corpus's crowd of 200.

written++ is three operations. Load, add, store. That is the whole bug, and it is the reason the next class does not fix it.

VolatileField

private volatile int written;

What this line does guarantee. Any thread reading written sees the most recent write to it, and reads and writes of it cannot be reordered against the accesses around them. The read is never stale and never hoisted out of a loop.

What it does not guarantee, and this is the correction worth carrying out of this lesson. Atomicity of anything. written++ is still load, add, store, with the same window between the first and the last. Java's volatile is a stronger thing than C++'s, and still not std::atomic.

What breaks without the keyword is not this. Compare block 1 with block 2: removing volatile from a counter changes the size of the loss and not its existence. The failure volatile actually prevents is block 9's, and that failure is invisible here.

Atomic

private final AtomicInteger written = new AtomicInteger();

written.incrementAndGet();

One operation, so there is no window. On x86 this compiles to a lock xadd, the same instruction std::atomic<int>::fetch_add gives you, with no monitor and no blocking. Block 3 returns 200000 on every run, and that is a guarantee rather than an observation.

final on the field, and it is load-bearing. The reference must never be replaced, or two threads end up counting into different objects. final also means the field needs no volatile: J4's page on safe publication is the rule, and this is the case it covers.

What it cannot do is block 4. An atomic makes one field's update indivisible. It says nothing about two fields being updated together, which is the next class.


TailAppender.java

Tally, and why the two numbers are read as one value

public record Tally(int lines, long bytes) {

    public boolean consistentFor(int lineLength) {
        return bytes == (long) lines * lineLength;
    }
}

The invariant is written down as code, which is what makes block 4 measurable. Every line in the demo is 38 characters, so bytes == lines * 38 has to hold for any tally that describes a real moment. A tally that fails this describes a moment that never happened.

The cast is on the left operand. (long)(lines * lineLength) would multiply two ints, overflow at 2.1 billion, and widen the wrong answer. Not reachable here; the habit costs nothing.

TwoAtomics — correct per field, wrong across fields

private final AtomicInteger lines = new AtomicInteger();
private final AtomicLong bytes = new AtomicLong();

public void write(String formattedLine) {
    lines.incrementAndGet();
    bytes.addAndGet(formattedLine.length());
}

Both increments are atomic and no count is ever lost. Block 4's final tally is Tally[lines=200000, bytes=7600000], consistent, on every run. Everything about this class is correct once the writers have stopped.

The bug is only visible to a reader arriving mid-flight.

public Tally tally() {
    return new Tally(lines.get(), bytes.get());
}

Two atomic reads with a gap between them. A writer landing in that gap gives a tally that counts a line whose characters have not arrived. Measured: 23905 broken reads on the run above, and between 23905 and 1714878 across sixteen runs. Never zero.

This is the shape that makes people distrust concurrency. Every individual operation is correct, the end state is correct, and the class is still wrong, because the invariant spans two fields and no per-field mechanism can span anything. It is also why an interviewer's "why a lock and not an atomic?" has a real answer: an atomic guards one field, a lock guards a rule.

Guarded — the shape to reach for

private final Object lock = new Object();
private int lines;
private long bytes;

private means the lock cannot be named from outside, so nothing outside can take it. Compare ExposedMonitor, where the lock is this and block 6 shows a stranger holding it. final means the reference cannot be repointed, so two threads can never end up synchronizing on different objects while both believe they are guarding the same fields. Drop final and you get a lock that compiles, runs, and protects nothing.

Plain int and long, with no volatile and no atomic wrapper. This is the part that looks like an omission and is not. Releasing this monitor happens-before the next acquisition of it, so the next thread in sees both writes. Adding volatile here would be noise, and it would suggest to a reader that you were not sure which mechanism was doing the work.

long bytes without volatile is safe only because of the lock. A non-volatile long may be read as two halves from two different writes, which is the one tearing hazard Java has. Every read of this field is under the lock, so no unsynchronized read exists.

public void write(String formattedLine) {
    synchronized (lock) {
        lines++;
        bytes += formattedLine.length();
    }
}

Both mutations inside one block is the entire design. Two blocks, one per field, would be TwoAtomics with more syntax. The block is the unit of atomicity, and its boundaries are the statement of what moves together.

formattedLine.length() is called inside the block and could have been called outside it. Moving it out shortens the critical section by one call on an immutable String. Left in because the block already reads at a glance; if this were a formatter call that could throw or block, out is the correct answer.

public Tally tally() {
    synchronized (lock) {
        return new Tally(lines, bytes);
    }
}

The read is under the same lock, and that is what makes block 4 print 0. A tally() without the keyword would be exactly as broken as TwoAtomics, and would still return the right answer after the writers stopped. This is corpus/lru-cache's get in miniature. A read that participates in an invariant is not a free operation. Leaving it unguarded is the single mistake that problem's stress suite exists to catch.

synchronized releases on the exception path too. No finally, and nothing to forget. That is std::lock_guard, not std::mutex::lock.

Explicitstd::mutex's shape, and the two things it can do that a monitor cannot

private final ReentrantLock lock = new ReentrantLock();

public void write(String formattedLine) {
    lock.lock();
    try {
        lines++;
        bytes += formattedLine.length();
        holdCountSeenInsideWrite = lock.getHoldCount();
    } finally {
        lock.unlock();
    }
}

The finally is not optional and nothing warns you. Java has no destructor and no scope guard, so an exception between lock() and unlock() leaves the lock held forever and every other thread blocked. J9's try-with-resources does not apply: ReentrantLock is not an AutoCloseable. The one-line rule is that lock() is immediately followed by try, always.

public boolean tryWrite(String formattedLine) {
    if (!lock.tryLock()) {
        return false;
    }

std::mutex::try_lock, and the reason to choose ReentrantLock over synchronized at all. There is no way to write this with the synchronized keyword; a monitor has no non-blocking acquire. Block 5 prints false while another thread holds the lock and true once it is free, with the hand-off coordinated by latches so the output is the same on every run.

The other capabilities in the same family, none of which a monitor has: tryLock(timeout, unit), lockInterruptibly(), newCondition() for more than one wait set, and a fair-ordering constructor. Absent a reason from that list, synchronized is the smaller thing to read.

public int writeWhileHolding(String formattedLine) {
    lock.lock();
    try {
        write(formattedLine);

This is the line that would be undefined behaviour in C++. write takes a lock the calling method already holds. std::mutex gives you a hang or worse; std::recursive_mutex exists for exactly this. In Java both lock kinds are reentrant and there is no non-reentrant option, so the call returns normally and block 5 prints the hold count as 2.

Reentrancy is a hazard of a different kind, not the absence of one. The critical section is now as long as both methods together, and neither method's author chose that. The C++ habit of keeping locking public methods and non-locking private helpers apart is still the better structure here.

public void holdUntil(CountDownLatch held, CountDownLatch release) throws InterruptedException {

Demo scaffolding, and it is here rather than in Main for one reason. The lock is private, so only this class can hold it while Main probes tryWrite. That is the same property that makes a private lock worth having, seen from the inside.


ExposedMonitor.java

public synchronized void write(String formattedLine) {
    lines++;
}

synchronized on a method is synchronized (this) around the whole body. Two things follow, and only the first is intended. The mutation is guarded. The lock guarding it is now reachable by anyone holding a reference to the appender.

What breaks is not visible in this file. Main block 6 does this, from a completely different class:

synchronized (exposed) {
    // exposed.write() now blocks until this block ends
}
  write() finished while an outsider held the monitor: false (waited 500ms)
  after the outsider let go                          : true, lines=1

A destination that stops accepting lines for as long as some unrelated code holds a lock you did not know you published. No exception, no warning, and nothing in this file to read that would tell you. The fix is one field: private final Object lock = new Object().

When synchronized (this) is still the right answer. corpus/lru-cache/reference/src/LruCache.java uses it on every method, deliberately. It is defensible there for two reasons. The class is one unit whose entire state moves under one lock, and no reference to it reaches code that could lock it. The threshold, with the three triggers spelled out, is in from-cpp.md.


RepeatCounts.java

GetThenPut

private final Map<String, Integer> counts = new ConcurrentHashMap<>();

public void record(String message) {
    Integer seen = counts.get(message);
    counts.put(message, seen == null ? 1 : seen + 1);
}

The map is a ConcurrentHashMap and it is not the problem. The get is atomic. The put is atomic. The line between them is where every other thread's get also lands, and the map has no way to know those two calls were meant to be one.

Measured: 26443 of 200000, and between 7617 and 36064 over sixteen runs. That is a worse loss rate than the unsynchronized int in block 1, because the window is two map operations wide rather than three instructions.

This is the highest-frequency mistake in a graded concurrency leg. It survives review because the declared type contains the word Concurrent, and the reviewer's eye stops there.

Merge

counts.merge(message, 1, Integer::sum);

One call, so there is no window to lose. The remapping function runs while the map holds that bin's lock, which is the guarantee get followed by put cannot offer at any level of care. 200000 on every run.

The family, and it is worth memorising as a set. merge, compute, computeIfAbsent, computeIfPresent, putIfAbsent, replace. If the new value depends on the old one, the answer is one of those six.

The cost, so it is not applied blindly. The remapping function runs while a bin lock is held, so it must be short, must not block, and must not touch the same map. A slow function inside compute serialises every thread hashing to that bin.


RepeatFilter.java

This class is the corpus's real answer, not an illustration. corpus/logger/curveballs/02-collapse-repeats/reference-patch/ absorbs the collapse requirement in 10 added lines in one file, measured, and the whole of it is the Atomic version below.

Atomic

private final AtomicReference<String> lastKey = new AtomicReference<>();

public boolean accept(String key) {
    return !key.equals(lastKey.getAndSet(key));
}

getAndSet replaces the key and returns the previous one, with nothing in between. So the question "was I the first to see this key?" gets a truthful answer per thread, even when 200 threads ask at once. Block 8 prints accepted: 1 on every run, and that is a guarantee: of a crowd all offering the identical key, exactly one thread can receive a different previous value.

std::atomic<T>::exchange is the same operation. AtomicReference swaps a reference and never the object behind it, which is why the value stored has to be immutable. A String is; a StringBuilder would give you a published object two threads can both mutate.

Why one field and not two. The real requirement keys on level and message together. Two fields, lastLevel and lastMessage, are TwoAtomics again: a thread can read one and have the other change under it. The patch composes them into one String key, which turns compare-and-replace into a single indivisible step. That reasoning is stated in the patch's own notes.

GetThenSet

String previous = lastKey.get();
lastKey.set(key);
return !key.equals(previous);

Both calls are atomic, the field underneath is volatile, and nothing here is stale or torn. The bug is the gap, and it is the same gap as RepeatCounts.GetThenPut. Observed accepted counts across sixteen runs: 2, 3, 7, and once the correct answer of 1.

A duplicate delivered twice is a requirement violation, not a rounding error. Curveball 02's suite asserts that a repeat reaches no destination at all, so a second delivery fails the leg.


StopFlag.java

private boolean stopped;

public long spinUntilStopped() {
    long spins = 0;
    while (!stopped) {
        spins++;
    }
    return spins;
}

Nothing in this thread writes stopped, so the JIT may read it once and cache it in a register. That is a legal transformation for a non-volatile field, and it is why the loop never exits after stop() is called from another thread. Block 9, all sixteen runs:

  boolean stopped          reader still spinning 2s after stop(): true

The spinner is a daemon thread, which is the only reason the program terminates. A non-daemon thread in that state keeps the JVM alive after main returns. Worth knowing, because a background worker with a plain stop flag is a process that will not shut down.

private volatile boolean stopped;

One keyword and the loop exits. The write happens-before every later read, so the read cannot be hoisted out and cannot be stale. false on all sixteen runs.

This is volatile's whole job, and the shape is narrow. One writer, one plain assignment that does not read the current value, no other field changing with it. curveballs/01-async-appenders/ adds one background thread serving asynchronous destinations, and a flag telling that thread to stop would be this. Anything you increment needs an atomic; anything with a sibling field needs a lock.


Main.java

private static void contend(int threads, Runnable task) {
    ...
    ready.await();
    go.countDown();

Three latches, so the threads collide instead of drifting past each other. Every worker counts down ready and then blocks on go, so no thread starts work until all 200 are at the line. Start 200 threads without this and the early ones finish before the late ones begin, and every counter prints 200000 while still being broken. This is the same design as harness/DojoConcurrency.java, which the graded suites use.

    observer.join();
    return result.get(0);

The observer writes to a plain ArrayList and Main reads it after join(). No lock, no concurrent collection, and it is correct. Thread.join() is a happens-before edge: everything the joined thread did is visible to the joiner. Blocks 1 to 3 rely on the same edge to read a plain int field after the pool drains.

So join is the cheapest synchronization in Java, and it is the one people forget they are using. If a value is only read after the threads that wrote it have finished, it needs nothing at all. The moment a reader arrives while writers are still running, you are back to block 4.


Worked source

The 8 files of the worked design

Every file below is the one the app opens, verbatim. This is the part worth reading slowly: the prose above argues for a shape, and these are the lines that have it.

worked/src/Appender.java16 lines

// Appender.java
//
// corpus/logger/contract/Appender.java, trimmed to the one method and the one sentence of its
// javadoc that this lesson turns on. The real file says more; nothing here contradicts it.
public interface Appender {

    /**
     * Writes one already-formatted line.
     *
     * The logger calls this for the same Appender instance from many threads at once and never
     * serialises those calls for you. One call to write is the unit of atomicity. If your
     * destination needs a lock to hold that, take it inside write; the logger will not take it
     * for you and does not know it exists.
     */
    void write(String formattedLine);
}

worked/src/ExposedMonitor.java19 lines

// ExposedMonitor.java
//
// The same counting destination, guarded by the monitor every Java object already has. There is
// no field to declare and no lock to construct, which is exactly the problem: the lock is part
// of the public surface, and any caller holding a reference can take it.
public final class ExposedMonitor implements Appender {

    private int lines;

    /** `synchronized` on a method is `synchronized (this)` around the whole body. */
    @Override
    public synchronized void write(String formattedLine) {
        lines++;
    }

    public synchronized int lines() {
        return lines;
    }
}

worked/src/LineCounters.java64 lines

// LineCounters.java
//
// Three destinations that do the same job: count the lines they were handed. Every one of them
// is a legal Appender, every one compiles, and only the last one is correct. Side by side in one
// file because the only interesting thing about them is the difference.
import java.util.concurrent.atomic.AtomicInteger;

public final class LineCounters {

    private LineCounters() {}

    /** What you write first. One field, one increment, no keyword anywhere. */
    public static final class Unguarded implements Appender {

        private int written;

        @Override
        public void write(String formattedLine) {
            written++;
        }

        public int written() {
            return written;
        }
    }

    /**
     * The C++ reflex, translated wrongly. `volatile` in Java is real — it is not the near-useless
     * C++ keyword — but what it buys is visibility and ordering, never atomicity. `written++` is
     * still a read, an add and a write, and nothing stops a second thread landing between them.
     */
    public static final class VolatileField implements Appender {

        private volatile int written;

        @Override
        public void write(String formattedLine) {
            written++;
        }

        public int written() {
            return written;
        }
    }

    /**
     * The read-modify-write done as one operation. AtomicInteger.incrementAndGet is
     * std::atomic&lt;int&gt;::fetch_add with the result already added, on hardware it is the same
     * lock-free instruction, and it is what `volatile` cannot express.
     */
    public static final class Atomic implements Appender {

        private final AtomicInteger written = new AtomicInteger();

        @Override
        public void write(String formattedLine) {
            written.incrementAndGet();
        }

        public int written() {
            return written.get();
        }
    }
}

worked/src/RepeatCounts.java49 lines

// RepeatCounts.java
//
// How many times each message arrived. Curveball 02 collapses consecutive duplicates; the
// operations team also wanted to know which message was doing the flooding, which is a count per
// message. Both classes below hold the same ConcurrentHashMap, so the container is never the
// difference between them.
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

public final class RepeatCounts {

    private RepeatCounts() {}

    /**
     * Read the current count, add one, put it back. Every single call into the map is atomic and
     * thread-safe. The sequence of three is not, and no map can make it so, because the window
     * between the get and the put belongs to the caller.
     */
    public static final class GetThenPut {

        private final Map<String, Integer> counts = new ConcurrentHashMap<>();

        public void record(String message) {
            Integer seen = counts.get(message);
            counts.put(message, seen == null ? 1 : seen + 1);
        }

        public int count(String message) {
            return counts.getOrDefault(message, 0);
        }
    }

    /**
     * One call, so there is no window. merge applies the remapping function while holding the
     * bin's lock, which is why it can promise what a get followed by a put cannot.
     */
    public static final class Merge {

        private final Map<String, Integer> counts = new ConcurrentHashMap<>();

        public void record(String message) {
            counts.merge(message, 1, Integer::sum);
        }

        public int count(String message) {
            return counts.getOrDefault(message, 0);
        }
    }
}

worked/src/RepeatFilter.java45 lines

// RepeatFilter.java
//
// Curveball 02's rule: a call at the same level with the same message as the call immediately
// before it is dropped. The key is level.name() + " " + message in the real patch; here it
// arrives already composed, because the enum adds nothing to the point.
//
// corpus/logger/curveballs/02-collapse-repeats/reference-patch/ is the Atomic version below,
// measured at 10 added lines in one file.
import java.util.concurrent.atomic.AtomicReference;

public final class RepeatFilter {

    private RepeatFilter() {}

    /**
     * getAndSet is one operation: it replaces the stored key and hands back what was there, with
     * no window between the two. So of a crowd all logging the identical line, exactly one thread
     * can see a different previous key, and exactly one record is delivered. std::atomic's
     * exchange is the same instruction.
     */
    public static final class Atomic {

        private final AtomicReference<String> lastKey = new AtomicReference<>();

        public boolean accept(String key) {
            return !key.equals(lastKey.getAndSet(key));
        }
    }

    /**
     * The same two steps written apart. Both are atomic on their own and the field is volatile
     * underneath, so nothing here is stale or torn — the bug is the gap between the read and the
     * write, which is where every other thread's read also happens.
     */
    public static final class GetThenSet {

        private final AtomicReference<String> lastKey = new AtomicReference<>();

        public boolean accept(String key) {
            String previous = lastKey.get();
            lastKey.set(key);
            return !key.equals(previous);
        }
    }
}

worked/src/StopFlag.java45 lines

// StopFlag.java
//
// The one job volatile is for. The async destinations in curveball 01 run on a background thread;
// telling that thread to stop is a single write read by a single other thread, with no
// read-modify-write anywhere. That is the whole shape volatile covers.
public final class StopFlag {

    private StopFlag() {}

    /** No keyword. The writer's store is never ordered against the reader's load. */
    public static final class Plain {

        private boolean stopped;

        public void stop() {
            stopped = true;
        }

        public long spinUntilStopped() {
            long spins = 0;
            while (!stopped) {
                spins++;
            }
            return spins;
        }
    }

    /** One keyword. The write happens-before every later read of the same field. */
    public static final class Volatile {

        private volatile boolean stopped;

        public void stop() {
            stopped = true;
        }

        public long spinUntilStopped() {
            long spins = 0;
            while (!stopped) {
                spins++;
            }
            return spins;
        }
    }
}

worked/src/TailAppender.java140 lines

// TailAppender.java
//
// A destination that keeps two numbers about itself: how many lines it took, and how many
// characters those lines came to. Every line the demo writes is the same length, so the two
// numbers have a relationship: bytes == lines * lineLength. That relationship is an invariant,
// and an invariant spanning two fields is where per-field atomics stop being enough.
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicLong;
import java.util.concurrent.locks.ReentrantLock;

public final class TailAppender {

    private TailAppender() {}

    /** Both numbers, read as one answer. Whoever reads it can check the invariant. */
    public record Tally(int lines, long bytes) {

        public boolean consistentFor(int lineLength) {
            return bytes == (long) lines * lineLength;
        }
    }

    /**
     * Two atomics, one per field. Every increment is atomic and no count is ever lost, so the
     * final numbers are always right. A reader arriving mid-flight is the problem: it reads
     * lines, then reads bytes, and another thread's write can land between those two reads.
     */
    public static final class TwoAtomics implements Appender {

        private final AtomicInteger lines = new AtomicInteger();
        private final AtomicLong bytes = new AtomicLong();

        @Override
        public void write(String formattedLine) {
            lines.incrementAndGet();
            bytes.addAndGet(formattedLine.length());
        }

        public Tally tally() {
            return new Tally(lines.get(), bytes.get());
        }
    }

    /**
     * One lock over both fields, and the lock is a private object nobody else can name. Plain
     * int and long fields need no volatile: releasing this monitor and acquiring it is the
     * happens-before edge that publishes both writes to the next thread in.
     */
    public static final class Guarded implements Appender {

        private final Object lock = new Object();
        private int lines;
        private long bytes;

        @Override
        public void write(String formattedLine) {
            synchronized (lock) {
                lines++;
                bytes += formattedLine.length();
            }
        }

        public Tally tally() {
            synchronized (lock) {
                return new Tally(lines, bytes);
            }
        }
    }

    /**
     * The same design with std::mutex's shape instead: an explicit lock, an explicit unlock in a
     * finally, and tryLock for the caller who would rather not wait. Also where reentrancy is
     * visible, because getHoldCount says out loud what the monitor version does silently.
     */
    public static final class Explicit implements Appender {

        private final ReentrantLock lock = new ReentrantLock();
        private int lines;
        private long bytes;
        private int holdCountSeenInsideWrite;

        @Override
        public void write(String formattedLine) {
            lock.lock();
            try {
                lines++;
                bytes += formattedLine.length();
                holdCountSeenInsideWrite = lock.getHoldCount();
            } finally {
                lock.unlock();
            }
        }

        /** std::mutex::try_lock. Returns false instead of waiting when someone else holds it. */
        public boolean tryWrite(String formattedLine) {
            if (!lock.tryLock()) {
                return false;
            }
            try {
                lines++;
                bytes += formattedLine.length();
                return true;
            } finally {
                lock.unlock();
            }
        }

        /** Takes the lock, then calls a method that takes it again. std::mutex would deadlock. */
        public int writeWhileHolding(String formattedLine) {
            lock.lock();
            try {
                write(formattedLine);
                return holdCountSeenInsideWrite;
            } finally {
                lock.unlock();
            }
        }

        /** Demo scaffolding, so Main can probe tryWrite with the lock provably held elsewhere. */
        public void holdUntil(CountDownLatch held, CountDownLatch release) throws InterruptedException {
            lock.lock();
            try {
                held.countDown();
                release.await();
            } finally {
                lock.unlock();
            }
        }

        public Tally tally() {
            lock.lock();
            try {
                return new Tally(lines, bytes);
            } finally {
                lock.unlock();
            }
        }
    }
}

worked/src/Main.java237 lines

// Main.java
//
// Nine blocks, in the order the lesson needs them. Every number printed is measured on the run,
// not asserted, because the whole point of blocks 1 and 2 is that the number is wrong.
//
//   ..\..\..\..\.toolchain\jdk-21\bin\javac.exe -d out *.java
//   ..\..\..\..\.toolchain\jdk-21\bin\java.exe -cp out Main
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.Supplier;

public final class Main {

    /** The number both stress suites in the corpus use. Same one here. */
    private static final int CROWD = 200;

    private static final int PER_THREAD = 1_000;

    private static final String LINE = "10:00:00 INFO  worker-1 request served";

    public static void main(String[] args) throws Exception {
        System.out.println("line length = " + LINE.length() + " chars, crowd = " + CROWD
                + " threads, " + PER_THREAD + " writes each, expected total = "
                + (CROWD * PER_THREAD));

        System.out.println();
        System.out.println("--- 1. int written; written++");
        LineCounters.Unguarded unguarded = new LineCounters.Unguarded();
        contend(CROWD, () -> {
            for (int i = 0; i < PER_THREAD; i++) unguarded.write(LINE);
        });
        report(unguarded.written());

        System.out.println();
        System.out.println("--- 2. volatile int written; written++");
        LineCounters.VolatileField volatileField = new LineCounters.VolatileField();
        contend(CROWD, () -> {
            for (int i = 0; i < PER_THREAD; i++) volatileField.write(LINE);
        });
        report(volatileField.written());

        System.out.println();
        System.out.println("--- 3. AtomicInteger.incrementAndGet()");
        LineCounters.Atomic atomic = new LineCounters.Atomic();
        contend(CROWD, () -> {
            for (int i = 0; i < PER_THREAD; i++) atomic.write(LINE);
        });
        report(atomic.written());

        System.out.println();
        System.out.println("--- 4. two fields, one invariant: bytes == lines * " + LINE.length());
        TailAppender.TwoAtomics twoAtomics = new TailAppender.TwoAtomics();
        long badTwoAtomics = observeWhileWriting(twoAtomics::tally, () -> contend(CROWD, () -> {
            for (int i = 0; i < PER_THREAD; i++) twoAtomics.write(LINE);
        }));
        System.out.println("  two atomics, one per field   inconsistent reads: " + badTwoAtomics
                + ", final tally " + twoAtomics.tally()
                + " consistent: " + twoAtomics.tally().consistentFor(LINE.length()));

        TailAppender.Guarded guarded = new TailAppender.Guarded();
        long badGuarded = observeWhileWriting(guarded::tally, () -> contend(CROWD, () -> {
            for (int i = 0; i < PER_THREAD; i++) guarded.write(LINE);
        }));
        System.out.println("  one private lock over both   inconsistent reads: " + badGuarded
                + ", final tally " + guarded.tally()
                + " consistent: " + guarded.tally().consistentFor(LINE.length()));

        System.out.println();
        System.out.println("--- 5. ReentrantLock: tryLock, and re-entering a lock you hold");
        TailAppender.Explicit explicit = new TailAppender.Explicit();
        CountDownLatch held = new CountDownLatch(1);
        CountDownLatch release = new CountDownLatch(1);
        Thread holder = new Thread(() -> {
            try {
                explicit.holdUntil(held, release);
            } catch (InterruptedException interrupted) {
                Thread.currentThread().interrupt();
            }
        }, "holder");
        holder.start();
        held.await();
        System.out.println("  tryWrite while another thread holds the lock : " + explicit.tryWrite(LINE));
        release.countDown();
        holder.join();
        System.out.println("  tryWrite once it is free                     : " + explicit.tryWrite(LINE));
        System.out.println("  hold count seen inside write, called under lock: "
                + explicit.writeWhileHolding(LINE));

        System.out.println();
        System.out.println("--- 6. synchronized(this): an outside caller takes your lock");
        ExposedMonitor exposed = new ExposedMonitor();
        CountDownLatch hijacked = new CountDownLatch(1);
        CountDownLatch letGo = new CountDownLatch(1);
        Thread hijacker = new Thread(() -> {
            synchronized (exposed) {          // legal, from anywhere, on any object
                hijacked.countDown();
                try {
                    letGo.await();
                } catch (InterruptedException interrupted) {
                    Thread.currentThread().interrupt();
                }
            }
        }, "hijacker");
        hijacker.start();
        hijacked.await();
        AtomicBoolean wroteThrough = new AtomicBoolean();
        Thread writer = new Thread(() -> {
            exposed.write(LINE);
            wroteThrough.set(true);
        }, "writer");
        writer.start();
        writer.join(500);
        System.out.println("  write() finished while an outsider held the monitor: " + wroteThrough.get()
                + " (waited 500ms)");
        letGo.countDown();
        hijacker.join();
        writer.join();
        System.out.println("  after the outsider let go                          : " + wroteThrough.get()
                + ", lines=" + exposed.lines());

        System.out.println();
        System.out.println("--- 7. ConcurrentHashMap: " + CROWD + " threads, one message, "
                + PER_THREAD + " records each");
        RepeatCounts.GetThenPut getThenPut = new RepeatCounts.GetThenPut();
        contend(CROWD, () -> {
            for (int i = 0; i < PER_THREAD; i++) getThenPut.record("retrying");
        });
        System.out.println("  get(k) then put(k, v+1)   count(\"retrying\") = " + getThenPut.count("retrying"));
        RepeatCounts.Merge merge = new RepeatCounts.Merge();
        contend(CROWD, () -> {
            for (int i = 0; i < PER_THREAD; i++) merge.record("retrying");
        });
        System.out.println("  merge(k, 1, Integer::sum) count(\"retrying\") = " + merge.count("retrying"));

        System.out.println();
        System.out.println("--- 8. collapsing duplicates: " + CROWD
                + " threads, the identical key, one delivery allowed");
        AtomicInteger acceptedByExchange = new AtomicInteger();
        RepeatFilter.Atomic exchange = new RepeatFilter.Atomic();
        contend(CROWD, () -> {
            if (exchange.accept("INFO retrying")) acceptedByExchange.incrementAndGet();
        });
        System.out.println("  getAndSet        accepted: " + acceptedByExchange.get());
        AtomicInteger acceptedByGetThenSet = new AtomicInteger();
        RepeatFilter.GetThenSet getThenSet = new RepeatFilter.GetThenSet();
        contend(CROWD, () -> {
            if (getThenSet.accept("INFO retrying")) acceptedByGetThenSet.incrementAndGet();
        });
        System.out.println("  get() then set() accepted: " + acceptedByGetThenSet.get());

        System.out.println();
        System.out.println("--- 9. the one job volatile has: a stop flag");
        StopFlag.Plain plain = new StopFlag.Plain();
        Thread plainSpinner = new Thread(plain::spinUntilStopped, "plain-spinner");
        plainSpinner.setDaemon(true);
        plainSpinner.start();
        Thread.sleep(100);
        plain.stop();
        plainSpinner.join(2_000);
        System.out.println("  boolean stopped          reader still spinning 2s after stop(): "
                + plainSpinner.isAlive());

        StopFlag.Volatile flagged = new StopFlag.Volatile();
        Thread flaggedSpinner = new Thread(flagged::spinUntilStopped, "volatile-spinner");
        flaggedSpinner.setDaemon(true);
        flaggedSpinner.start();
        Thread.sleep(100);
        flagged.stop();
        flaggedSpinner.join(2_000);
        System.out.println("  volatile boolean stopped reader still spinning 2s after stop(): "
                + flaggedSpinner.isAlive());
    }

    private static void report(int actual) {
        int expected = CROWD * PER_THREAD;
        System.out.println("  expected " + expected + ", got " + actual
                + "  (lost " + (expected - actual) + ")");
    }

    /** Every task released from one latch, so the threads collide instead of drifting past. */
    private static void contend(int threads, Runnable task) {
        ExecutorService pool = Executors.newFixedThreadPool(threads);
        CountDownLatch ready = new CountDownLatch(threads);
        CountDownLatch go = new CountDownLatch(1);
        CountDownLatch done = new CountDownLatch(threads);
        try {
            for (int i = 0; i < threads; i++) {
                pool.submit(() -> {
                    ready.countDown();
                    try {
                        go.await();
                        task.run();
                    } catch (InterruptedException interrupted) {
                        Thread.currentThread().interrupt();
                    } finally {
                        done.countDown();
                    }
                });
            }
            ready.await();
            go.countDown();
            done.await(60, TimeUnit.SECONDS);
        } catch (InterruptedException interrupted) {
            Thread.currentThread().interrupt();
        } finally {
            pool.shutdownNow();
        }
    }

    /**
     * Runs the writers while one reader probes the tally in a loop, and counts how many probes
     * came back breaking the invariant. Nothing here is asserted; the count is the measurement.
     */
    private static long observeWhileWriting(Supplier<TailAppender.Tally> probe, Runnable writers)
            throws InterruptedException {
        AtomicBoolean running = new AtomicBoolean(true);
        List<Long> result = new ArrayList<>();
        Thread observer = new Thread(() -> {
            long bad = 0;
            while (running.get()) {
                if (!probe.get().consistentFor(LINE.length())) bad++;
            }
            result.add(bad);
        }, "observer");
        observer.start();
        writers.run();
        running.set(false);
        observer.join();
        return result.get(0);
    }
}

The faded stage is not here, on purpose

In the app, the third stage of a lesson hands you the worked design with a few lines replaced by // GAP: markers, then compiles your completion and runs a JUnit suite against it. That needs javac, and a static site has no compiler — so rather than show a control that cannot work, this page stops at the worked source.

Run the app for the drill: it is the download in the header, and it works offline once unpacked.

← J11 · Modern Java a C++ dev has not met — sealed, record, switch patterns, and the rest A1 · Enum carrying state and behaviour — a name, a value, or a seam →

← all lessons