Syllabus · E1
Identifying shared mutable state — the two-question census
The idea
The census: two questions per field, and a guard column you have to fill
Sprint is a rate limiter with six fields: name, capacity, used, admitted, audit, and rules. Call tryAcquire from one thread only, and it behaves exactly as written. It reads used, checks whether the request still fits under capacity, and if so increments both used and admitted. Nothing about that code is wrong, and there is no reason to change it while only one thread calls it.
Then a second caller arrives, and two threads now call tryAcquire on the same Sprint at once. tryAcquire touches used in two separate steps: a read that decides, then a write that charges. Nothing in the code makes those two steps happen as one. Two threads can both read the same value of used, both see room under capacity, and both charge it, so more requests get admitted than the limiter was ever supposed to allow. Measured under load, 64 threads against a capacity of 16: on the worst of fifteen runs, all 64 threads charged the map, while the field meant to count admissions read only 18. Run the same scenario fifteen times and every run reports a different pair of numbers; one run lost none of its increments and a different run lost six. A race that only sometimes shows itself passes a test suite that is only run once, which is why the corpus repeats every concurrency scenario twelve times before it trusts the result.
The actual skill under a clock is not spotting this one race after it has already shown itself. It is finding every field shaped like used before a second thread ever calls in. Go down the class field by field and ask two questions.
Reach. Can two threads hold a reference to this field at the same time? A field belonging to an object more than one thread uses answers yes, and so does anything reachable through that field.
Write. Can anything change this field's value after construction? A non-final field can. So can a final reference to something mutable, such as a List or a Map, because the reference never changes but the object behind it does.
A field that answers yes to both questions goes on a list. Every entry on that list needs a named guard: a lock, an atomic operation, or a rule that confines the field to one thread. An entry whose guard column you cannot fill in is the race you found. A field that answers no to either question gets left alone, because guarding it protects no invariant and still costs something — when-not.md prices that cost at roughly 15x on a real bench.
The field list still misses two kinds of sharing, because they live in what a method hands out or takes in, not in a field's own declaration. Sprint.audit() returns its internal list by reference, so every caller shares that exact list with the limiter from then on. Sprint.configure() keeps the exact List object a caller passed in, so the caller and the limiter now share one list without either side asking for it. A third kind does not look like a field at all: handing this to another object before the constructor finishes shares an object that does not meet its own invariants yet. contrast/ mechanises the field half of this census by reflection and reports the same six fields for all three designs it compares, which is the point. Reflection sees the fields, but it cannot see a returned list or a kept reference, since neither lives in a field's own declaration.
corpus/rate-limiter/reference/DECISION_LOG.md records two deliberately broken variants of its reference solution, and both pass the base suite 28 of 28. In contrast/, the design with no guard on any field passes BaseTest 6 of 6 as well. A green bar cannot see shared mutable state. It only proves that a single thread, run once, reached the right answer.
Which guard a field that needs one should get is E2. How wide to make that guard, once a class needs more than one, is E3. Neither question can be answered about a field the census never found.
Worked walkthrough
NOTES — six fields, two questions each, and the three sharings the sprint never wrote down
Run it first
..\..\..\..\.toolchain\jdk-21\bin\javac.exe -Xlint:all -d %TEMP%\e1-worked src\*.java
..\..\..\..\.toolchain\jdk-21\bin\java.exe -cp %TEMP%\e1-worked Main
Compile outside the lesson directory. Class files checked in next to sources are the one kind of mess this repo has no way to tell from an artifact.
javac -Xlint:all prints nothing and exits 0. Real output, verbatim, from one run:
1. the unguarded map: 64 threads, capacity 16
allowed = 18 (over-admitted by 2)
receipts refused by their own constructor = 46
admitted field says 62, threads that got a yes = 18, every thread returned = true
2. the live list, one thread only
java.util.ConcurrentModificationException on the very next iteration step
3. the kept reference, one thread only
third request with the rule in force = false
same request after the caller cleared ITS list = true (the limit is gone; nothing threw)
4. the half-built object, one thread only
registry filed : limiter edge capacity 0 rules 0
finished object: limiter edge capacity 100 rules 0
5. the census expires when work is deferred
expected "#1 cache miss" then "#2 cache hit"; the worker wrote:
#2 cache miss
#2 cache hit
Four of the five demonstrations run on one thread, or on a pinned schedule, and print the same thing every run. That is the first thing to internalise: sharing is a property of who can reach a reference, and most of it is visible with no race at all.
The census
For each field, two questions, then a third column you must be able to fill in:
- Reach — can two threads hold a reference to this at the same time? For a field of an object that more than one thread uses, yes. So can everything reachable through it.
- Write — can anything change it after construction? A non-final field can. So can a final reference to a mutable object: a
List, aMap, aStringBuilder, an array.
Yes to both puts the field on the list. Every list entry must then name its guard: the lock, the confinement rule, or the concurrent structure that makes its touches atomic. An entry whose guard column you cannot fill in is the race. No to either question means leave it alone; a guard on it is cost with no invariant behind it, and when-not.md prices that.
Question 1 has three answers people miss, because the field looks private. A reference you return is shared with every caller from then on. A reference you were given and kept is shared with whoever passed it. And this handed out before the constructor finishes shares an object that does not meet its own invariants yet. Blocks 2, 3 and 4 are those three, one each.
The six fields of Sprint, as the census reads them
| Field | Reach | Write | Guard named | Verdict |
|---|---|---|---|---|
name | yes | no — final, immutable type | not needed | safe |
capacity | yes | no — final long | not needed | safe |
used | yes | yes — every request | none | the race |
admitted | yes | yes — admitted++ | none | lost updates |
audit | yes, and returned live | yes — every request | none | shared with strangers |
rules | yes, and the caller kept it | yes — via the caller's alias | none | shared by aliasing |
Six rows. Two are safe on sight, and saying why they are safe is half the demonstration. An interviewer who hears "these two are final references to immutable values, so they need nothing" knows you ran the census. Sprinkled locks say the opposite.
Block 1 — the unguarded map, and the two species of failure
tryAcquire touches used twice: a read that decides, then a write that charges. Nothing makes those one step. 64 threads and a capacity of 16, across fifteen consecutive runs on one machine:
- Requests that charged the map: 17 to 64, against a capacity of 16. The
admittedfield is the honest count, and on one run all 64 threads got past the check. - Callers that received a yes: 16 to 20. That line undercounts, and the reason is the next bullet. One run printed exactly 16 and still charged the map 64 times.
- Receipts refused by their own constructor: 0 to 48. The message is
IllegalArgumentException: remaining cannot be negative, got -1, fromReceipt's compact constructor and not from any test. - Lost updates: 0 to 6 of 64. Compare
admittedwith the yes count plus the refusals; the shortfall isadmitted++losing increments.
Read those four numbers together, because the pairing is the lesson. A torn receipt throws after its request has already charged the map, so the more visibly the race breaks, the smaller the over-admission looks. The run that reported 16 admissions was the worst run of the fifteen.
And the spread matters as much as the numbers. Nothing here is "every run": one run lost no increments at all, and one produced no torn receipt at all. A race that shows itself on 14 of 15 runs would pass review on the fifteenth, which is why corpus/rate-limiter's stress suite repeats every scenario 12 times.
The torn receipts deserve a second look, because they are A5 paying E1's bill. The arithmetic produced an impossible answer, and the value type refused to exist. The corpus saw exactly this at full scale: stress runs against the unlocked rate-limiter variant failed with remaining cannot be negative, got -3 thrown by Decision's compact constructor (corpus/rate-limiter/reference/DECISION_LOG.md). An invariant in the constructor converts a silent race into a loud one. Without it, every failure here would be the first kind: a wrong number travelling on.
Blocks 2, 3, 4 — the three escapes, each on one thread
Returned live (block 2). audit() returns the list itself. The caller iterates; a routine tryAcquire appends; the iterator throws ConcurrentModificationException one step later. One thread. The exception is Java telling you the list has two owners.
Stored without copying (block 3). configure kept the caller's own ArrayList. The caller clears it later, as owners of lists do. The limit is silently gone: the third request read false, then true. Nothing threw, and no suite that configures with a fresh list can see it.
this before the constructor returns (block 4). The registry's register calls describe() synchronously, replay-on-subscribe style. The registry filed capacity 0 — a final field, observed before its assignment ran. A final field is only safe after construction completes; publishing this early forfeits that guarantee. javac's this-escape lint says nothing here, because the class is final and the lint is about subclass initialisation. The census does not delegate to the compiler.
Block 5 — the census expires
The scoreboard's seq field was safe yesterday: one thread wrote it, one thread read it. Then rendering moved onto a worker, because a sink got slow. The task reads seq when it runs, not when it was submitted, and both lines printed #2. Deterministically — the worker only had to be busy for a moment.
Asynchrony re-runs the census for you, with production traffic. corpus/logger's first curveball is this exact moment at full scale: a single-threaded logger gains async appenders. Every one of its 36 added lines exists to hand the worker a finished, immutable LogRecord instead of a reference into the logger's own state. When a design goes async, re-read every field the deferred code can reach.
The census against the corpus, because a procedure you cannot check is a slogan
Run the two questions over corpus/rate-limiter/reference/src/RateLimiter.java. Four fields. clock, algorithms and scopes are final references, never written: safe. budgets is shared and mutated, guard named in the field's own declaration — a ConcurrentHashMap, atomic per single-entry swap. The mutable counters live one level down in KeyBudget, which names its own guard and checks it (requireLocked throws AssertionError on an unlocked touch). That is the same two-piece list its DECISION_LOG.md opens the concurrency section with, under the heading "The shared mutable state, named out loud". The census reproduces the reference's own analysis, row for row.
Same exercise on corpus/lru-cache: two entries. The value map and the eviction policy's internals move together, one monitor, get included, because a read mutates recency standing. The listener list is written rarely and read on every eviction: a CopyOnWriteArrayList. Its DECISION_LOG.md records the broken alternatives being caught: an unsynchronized get produced expected: <20> but was: <119>, and a plain ArrayList registry produced ConcurrentModificationException under the stress suite.
Now the number that makes E1 a reading skill rather than a testing one. The corpus built the rate-limiter reference with all locking across check-then-charge removed, and ran the base suite: 28 of 28, green. Reproduced while writing this lesson, from the same gutted variant. The base suite reported {"pass":28,"total":28,"green":true}. The concurrency suite then failed 3 of its 4 scenarios on their first repetitions. Those failure lines are on disk, recorded against that variant in corpus/rate-limiter/problem.json under concurrency.proven_against: the tight rule is the ceiling, and it is a hard one ==> expected: <8> but was: <10>. Every base test is single-threaded, and a single thread cannot lose a race against itself. If you do not find shared mutable state by reading, a green bar will tell you it is not there.
contrast/ reproduces that result at lesson scale, with one command (node lessons/E1/contrast/measure.mjs). Three limiters: no guard anywhere, every method locked, and the census run. All three pass BaseTest 6 of 6. The curveball suite then scores them 0, 1 and 3 out of 3. The two scenarios the locked tree still fails run on one thread, because a lock cannot un-share a reference that already left the class.
What the fix is not, yet
This lesson ends at the named list. Choosing the guard is E2, E4 and E3, in that order: a monitor, an atomic, a concurrent collection, or a lock per key. faded/ here uses the plainest correct answer, one monitor, on purpose. The skill being drilled is producing the six-row table above under a clock, with the guard column honest. Two rows of it say "safe, and here is why", which matters as much as the four that say "unguarded". when-not.md shows what happens to a candidate who cannot tell the difference.
When not to
When the census says leave it alone
Half of the census from idea.md is deciding that a field needs nothing, and that half is the one candidates skip under a clock. A lock on state that no invariant touches is not caution. It is a bill with nothing purchased, and STANDARD v1.0 has a tag for exactly this: over-engineered sits in D3's vocabulary in server/lib/rubric.mjs, and it routes straight to a when-not.md file like this one.
There is a worse consequence than the score. A candidate who locks every field has avoided the census rather than run it. The question an interviewer actually asks is "what does this lock protect?", and "everything" is the answer of somebody who never classified a single field.
The concrete bad example, measured
contrast/a-locked/Limiter.java is what that candidate writes: one monitor, every method inside it, nothing else changed from the design in idea.md. It compiles clean, passes BaseTest 6 of 6, and it even passes the contended scenario that the unguarded version fails. From the outside, locking everything looks like it worked.
Then run the two scenarios it still fails, and both run on one thread:
a-locked CurveballTest {"pass":1,"total":3,"green":false}
b CurveballTest {"pass":3,"total":3,"green":true}
audit() still hands the dashboard the same list the limiter appends to, so the dashboard's next iteration step throws java.util.ConcurrentModificationException, regardless of how many methods are synchronized. And configure still stores the config service's own list, so that service's later clear() deletes the limiter's only rule and the caller silently inherits a capacity of a million. Locking harder does not touch either failure, because neither one is about atomicity. Both are about which object can reach which reference, and a monitor does not change who holds one.
The locking that candidate added still has a price, even on calls that needed no protection at all. describe() reads two final fields, and no answer it returns can be wrong no matter how many threads call it, because final fields of a finished object need no lock. Measured from node lessons/E1/contrast/measure.mjs, eight request threads and one dashboard thread over a 300ms window, five repetitions:
a-locked/ describe() calls: reps [1133938, 1317868, 840754, 1348664, 1313287] median 1313287
b/ describe() calls: reps [15947448, 18878851, 20267084, 20034470, 20224547] median 20034470
About 15x fewer completed calls on this machine. The dashboard now queues behind every request to read a String and a long that nothing can change.
The other over-application: a copy at the wrong place
The census's own fix has a cost too, and pretending otherwise would teach the same mistake pointed the other way. b/'s audit() fixes the shared-list problem by taking a snapshot under the guard before handing it to the caller, which is correct for a short list. With a 200,000-line trail, the same bench tells a different story:
live view, no copy polls: median 774986 requests sent in the window: median 6853613
b/ snapshot under the guard polls: median 710 requests sent in the window: median 28184
A defensive copy is O(n), so copying it while holding the lock makes every waiting request pay for that copy too. The request count collapsed by roughly 243x, spread 2,378 to 94,611 across five repetitions. The census tells you audit is shared and must not escape unguarded. It does not tell you that copying it on every read is free. At that length, the answer is a bounded page or an immutable append-only structure the reader can hold, not a fresh copy on every poll.
What is not shared, said out loud
Four verdicts are the cheapest marks available in the concurrency leg, because each one needs a sentence rather than a lock.
A local variable lives on one thread's stack, and a parameter is a copy of a reference, so limitFor(caller) in worked/ needs nothing, ever. What the parameter points at can still be shared, which is exactly how configure gets into trouble — the verdict is about the variable, never about the object it names.
An immutable value needs no guard either. Rule and Receipt are records with final components and no setter, so any number of threads can read one with nothing protecting it. corpus/logger uses the same move on purpose. Its async appenders receive a finished LogRecord, built once per call and handed unchanged to every destination, so a worker thread never reads the logger's own mutable state.
A final field of a fully constructed object needs nothing too, and that is exactly why publishing this early is a bug in the first place. The guarantee only starts once construction finishes, and name and capacity rely on it.
Confined state needs nothing either. E2's Drain.served is a plain long, written only by the worker thread and read only after close() observes the join, because Thread.join is a happens-before edge. Both corpus stress suites read their tallies exactly this way, with no lock anywhere near them.
A single-threaded contract needs nothing at all. Several corpus problems never promise concurrent callers, and their reference solutions contain no locks. Synchronising there defends against threads that do not exist, and it is paid for in clock, because interviewers run main first. Say "this is single-threaded by contract, and here is the list I would guard if that changed," then spend the minutes on the driver instead. The list itself is the demonstration.
The threshold, from both sides
Guard a field when both census questions answer yes, and guard it with the narrowest thing that spans the invariant. Copy or wrap a reference at the boundary when it points at mutable state that crosses the class line, in or out. Do that only when the volume crossing that boundary stays small enough for a copy to be cheap; the previous section showed what happens when it is not. Leave everything else alone: locals, parameters, immutable values, final fields, confined state, and state read only after a join.
Then write the sentence a reviewer is actually listening for. corpus/rate-limiter's KeyBudget javadoc is the model:
The algorithm instances are the shared mutable state of this problem, and this object's lock is what guards them. The list is not shared mutable state; the counters inside the algorithms are.
Two clauses, one naming what is guarded and one naming what is not. If you cannot write the second clause, you reached for a keyword instead of running the census.
The contrast pair
Three limiters, one census, and a base suite that cannot tell them apart
Three versions of the same per-caller quota limiter. a/ is what a twelve-minute sprint produces: six fields, no guard anywhere, and correct on one thread. a-locked/ is the candidate who heard "make it thread-safe" and put every method inside one monitor. b/ is the census run: three fields guarded, and the two references that leave the class copied at the boundary.
All three compile clean under -Xlint:all. All three pass BaseTest.java, the suite the requirements produce. Reproduce everything below with one command:
node lessons/E1/contrast/measure.mjs
The requirement, in the interviewer's words
Two things before we move on. Our dashboard polls
audit()every second while traffic is running, and it can't blow up. And the config service reuses the rule list it hands you, so treat that list as borrowed. Oh, and this sits behind the gateway: two hundred worker threads, and a capacity is a hard ceiling.
Nothing in the specification changed. The deployment is the one the specification always implied, which is what makes a concurrency curveball different from a seam curveball.
The base suite is blind, measured
a compiled=true {"pass":6,"total":6,"green":true}
a-locked compiled=true {"pass":6,"total":6,"green":true}
b compiled=true {"pass":6,"total":6,"green":true}
Six tests, six passes, on the tree with no guard on any field. Every base test is single-threaded, and a single thread cannot lose a race against itself. The corpus records the same result at full scale. corpus/rate-limiter/reference/DECISION_LOG.md reports two deliberately broken variants of its reference: one with the lock across check-then-charge gutted, one with a plain HashMap for the budgets. Both pass the base suite 28 of 28. That is the sentence to remember from this lesson. A green bar is not evidence that shared mutable state is absent. Reading is.
The curveball suite, and what a lock does not buy
a {"pass":0,"total":3,"green":false}
a-locked {"pass":1,"total":3,"green":false}
b {"pass":3,"total":3,"green":true}
a/ fails all three, with real output:
FAILED the dashboard walks the audit trail while requests keep arriving
java.util.ConcurrentModificationException
FAILED the config service goes on using the list it handed over
the rule of 2 was the caller's list ... ==> expected: <false> but was: <true>
FAILED every limiter admits exactly its capacity when the crowd arrives at once
56 of 64 threads threw. First: java.lang.IllegalArgumentException:
remaining cannot be negative, got -52
That third failure is A5 paying E1's bill. Nothing in the suite went looking for a torn count. The arithmetic produced an impossible number and Receipt's compact constructor refused to carry it, which is exactly what corpus/rate-limiter's Decision did during stress tuning: remaining cannot be negative, got -3, quoted in its own decision log.
a-locked/ is the interesting column. One monitor around every method fixed the crowd scenario completely — and changed nothing about the other two. Both of those failures happen on one thread. audit() still hands out the list this class appends to, and configure still stores a list the caller keeps using. A lock makes touches of state atomic. It cannot un-share a reference that has already left the building. Sharing is decided by who can reach a reference, and that question is answered in the method signatures, not in the synchronisation.
The field census, mechanised, and why it is not enough
Census.java runs the two questions over whichever tree is on the classpath, by reflection. A field counts as writable if it is non-final, or if it is a final reference to a List, Map, array or StringBuilder. Its output is identical for all three trees:
state fields to classify: 6, answered yes twice: 4, cleared on sight: 2
references this program can see escaping: 0
Six fields to classify, and only two clear on sight. That is the size of the reading job, and it is the same job in every tree — which is the point. A field-level census finds the race, and it cannot see either escape, because both live in what a method returns or stores. The two questions have to be asked about the API surface as well as the field list, and no tool asks them for you.
The D4 instrument, and the ranking it produces
measureChange from server/lib/diff.mjs, the same function that scores extension under fire:
| Change | diffLines | Files |
|---|---|---|
a/ → a-locked/ — lock every method | 13 | 1 |
a/ → b/ — guard three fields, copy two boundaries | 15 | 1 |
The wrong answer is two lines cheaper. It also passes the base suite, and it fails two of the three curveball scenarios. This measurement was not arranged to come out that way, and the number is reported rather than replaced. A line count is the wrong instrument for this lesson, and it says so honestly. What separates the two designs is which of them still fails, not which of them was longer to write. C3 hit the same wall from the other side — its injected clock bought nothing on diffLines and everything on a test suite's runtime.
The wrong way, measured on an instrument that can see it
The census's answer has a price, and the price is not lines. Bench.java runs eight request threads against one limiter for a 300ms window while a dashboard thread polls, five repetitions per tree.
Guarding what the census cleared. describe() reads two final fields, so no answer it gives can be wrong. a-locked/ locks it anyway:
a-locked/ describe() calls: reps [1133938, 1317868, 840754, 1348664, 1313287] median 1313287
b/ describe() calls: reps [15947448, 18878851, 20267084, 20034470, 20224547] median 20034470
About 15x fewer completed calls, because the dashboard now waits in the request queue for state it never reads.
Copying on the way out, at scale. Same storm, with an audit trail already 200,000 lines long. b/ takes a snapshot under the guard; a-locked-live/ hands back an unmodifiable view of a CopyOnWriteArrayList:
a-locked-live-style live view polls: median 774986 requests sent: median 6853613
b/ snapshot under the guard polls: median 710 requests sent: median 28184
Three orders of magnitude fewer polls. Requests in the same window fell from 6.85M to 28,184, a spread of 2,378 to 94,611 across five repetitions. The copy holds the lock for as long as it takes to copy. A defensive copy is O(n), and a defensive copy under the guard makes every waiting request pay for it. That is the cost when-not.md prices, and at this trail length the right answer is a bounded page or an immutable append-only structure, not a snapshot.
The off-axis requirement, priced by the diff: the dashboard now wants to see lines as they land instead of a snapshot each second. a-locked/ → a-locked-live/ costs 11 lines. b/ → b-live/ costs 11 lines. An exact tie, on one file each. The encapsulated design has to reopen the boundary it built, and the leaky design was already there. The diff cannot see that difference, and the tie is reported as a tie.
What this pair does not settle
Which guard to use is E2's subject, and how wide to make it is E3's. Every tree here uses one monitor per limiter, because the argument is about which state needs a guard at all. b/ also does not solve the polling cost above; it names it. And a-locked/ is not a careless design — it is the design of somebody who took concurrency seriously and skipped the census, which is exactly the candidate this lesson is for.
Worked source
The 5 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/Receipt.java27 linesworked/src/Registry.java23 linesworked/src/Rule.java16 linesworked/src/Sprint.java118 linesworked/src/Main.java162 lines
worked/src/Receipt.java27 lines
/**
* The answer to one request, shaped like {@code corpus/rate-limiter}'s {@code Decision}: the
* consistency check lives in the compact constructor, so no torn answer can exist as an object.
*
* <p>That check is doing E1's work as well as A5's. When two threads interleave inside
* {@code Sprint.tryAcquire}, the arithmetic can produce a remaining of -1 — and instead of a
* quietly wrong number travelling on, this constructor throws
* {@code remaining cannot be negative, got -1} at the moment of the tear. The corpus stress runs
* saw exactly this: several failures against the unlocked rate-limiter variant were not
* assertions but {@code Decision}'s own constructor refusing a torn count.
*/
public record Receipt(boolean allowed, long remaining) {
public Receipt {
if (remaining < 0) {
throw new IllegalArgumentException("remaining cannot be negative, got " + remaining);
}
}
static Receipt allowed(long remaining) {
return new Receipt(true, remaining);
}
static Receipt denied() {
return new Receipt(false, 0);
}
}
worked/src/Registry.java23 lines
import java.util.HashMap;
import java.util.Map;
/**
* A metrics index. {@link #register} asks the limiter to describe itself <b>immediately</b>,
* the way an event bus replays sticky events or a metrics library polls a gauge at registration.
*
* <p>That synchronous callback is what makes leaking {@code this} from a constructor observable
* on one thread: whatever state the constructor has not assigned yet, {@code describe()} reads
* anyway, and the half-built answer is filed in the index for good.
*/
public final class Registry {
private final Map<String, String> index = new HashMap<>();
public void register(Sprint limiter) {
index.put(limiter.name(), limiter.describe());
}
public String entryFor(String name) {
return index.get(name);
}
}
worked/src/Rule.java16 lines
import java.util.Objects;
/**
* One caller's allowance. A record: two final fields, validated at construction, no setter
* anywhere. In the census this is the easiest verdict on the sheet — shared freely, mutable
* never, so it needs no guard and gets none.
*/
public record Rule(String caller, long limit) {
public Rule {
Objects.requireNonNull(caller, "caller");
if (limit < 0) {
throw new IllegalArgumentException("limit cannot be negative, got " + limit);
}
}
}
worked/src/Sprint.java118 lines
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* A per-caller quota limiter, exactly as a 12-minute sprint writes it. Every method is correct
* on one thread, and a single-threaded suite for this class is green — that is what makes it
* worth studying rather than a straw man.
*
* <p>The census in NOTES.md walks the six fields below with two questions each: <i>can two
* threads reach it at once, and can anything change it after construction?</i> Three fields pass
* both questions with no guard named anywhere. Each comment records the verdict; Main.java shows
* each verdict producing its real failure.
*/
public final class Sprint {
/** Census: shared, never written after construction, String is immutable. Safe. No guard. */
private final String name;
/** Census: same verdict as {@link #name} — a final long nobody writes. Safe. */
private final long capacity;
/**
* Census: reachable from every public method, mutated by every allowed request, and no line
* in this file names a guard for it. This is the race. The check in {@code tryAcquire} and
* the charge are two separate touches of this map, and nothing makes them one.
*/
private final Map<String, Long> used = new HashMap<>();
/**
* Census: shared and mutated — {@code admitted++} is a read, an add and a write, so two
* threads can land inside it and one increment vanishes. J12 measured this exact shape
* losing 78,165 of 200,000 increments.
*/
private long admitted;
/**
* Census: mutated here on every request, and {@link #audit()} hands the live reference out.
* After one call to {@code audit()}, this list is shared with code this class has never
* heard of, on whatever thread that code runs.
*/
private final List<String> audit = new ArrayList<>();
/**
* Census: {@code configure} stores the caller's own list. The caller still holds it, so
* every mutation the caller makes from now on is a mutation of this limiter's rule set —
* sharing by aliasing, with not one thread started.
*/
private List<Rule> rules = List.of();
public Sprint(String name, long capacity) {
this.name = name;
this.capacity = capacity;
}
/**
* "Wire the metrics up while you are here." This is the constructor that leaks {@code this}:
* the registry calls {@link #describe()} back before the next line runs, so it reads
* {@link #capacity} while that final field still holds its default 0.
*
* <p>javac has a lint named {@code this-escape}, and it says nothing here — it warns about
* subclasses observing initialisation, and this class is final. The census is not a job you
* can hand to {@code -Xlint}.
*/
public Sprint(Registry registry, String name, long capacity) {
this.name = name;
registry.register(this); // describe() runs NOW, against a half-built limiter
this.capacity = capacity; // assigned after the registry already asked
}
/** The caller's own list, stored as-is. See the census verdict on {@link #rules}. */
public void configure(List<Rule> rules) {
this.rules = rules;
}
/**
* Check, then charge. Two touches of {@link #used} with a decision between them, and no
* guard making them one step. On one thread that is invisible; NOTES.md has the numbers for
* what it does under a crowd.
*/
public Receipt tryAcquire(String caller) {
long limit = limitFor(caller);
if (used.getOrDefault(caller, 0L) >= limit) {
return Receipt.denied();
}
long charged = used.merge(caller, 1L, Long::sum); // the charge, decided by a stale read
admitted++;
audit.add(name + " allowed " + caller + " #" + charged);
return Receipt.allowed(limit - charged); // negative under a race: Receipt throws
}
/** The live list. The one-line version of the A7 mistake, and an E1 sharing in disguise. */
public List<String> audit() {
return audit;
}
public long admitted() {
return admitted;
}
public String name() {
return name;
}
public String describe() {
return "limiter " + name + " capacity " + capacity + " rules " + rules.size();
}
private long limitFor(String caller) {
for (Rule rule : rules) {
if (rule.caller().equals(caller)) {
return rule.limit();
}
}
return capacity;
}
}
worked/src/Main.java162 lines
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.AtomicInteger;
/**
* Five demonstrations, one per census verdict. Blocks 2 to 5 are single-threaded or pinned and
* behave identically on every run — sharing is a property of who can reach a reference, and most
* of it is visible with no race at all. Block 1 is the one genuine race, so its digits move
* between runs; NOTES.md reports the spread across ten runs.
*
* <p>Every worker thread is a daemon behind a bounded await, so this program always terminates.
*/
public final class Main {
public static void main(String[] args) throws Exception {
blockOneTheUnguardedMap();
blockTwoTheLiveList();
blockThreeTheKeptReference();
blockFourTheHalfBuiltObject();
blockFiveTheCensusExpires();
}
/** The race the census points at: check and charge are two touches of one unguarded map. */
private static void blockOneTheUnguardedMap() throws Exception {
int crowd = 64;
int capacity = 16;
Sprint sprint = new Sprint("edge", capacity);
AtomicInteger allowed = new AtomicInteger();
AtomicInteger tornReceipts = new AtomicInteger();
CountDownLatch go = new CountDownLatch(1);
CountDownLatch done = new CountDownLatch(crowd);
ExecutorService pool = Executors.newFixedThreadPool(crowd, runnable -> {
Thread thread = new Thread(runnable);
thread.setDaemon(true);
return thread;
});
try {
for (int i = 0; i < crowd; i++) {
pool.submit(() -> {
try {
go.await();
if (sprint.tryAcquire("acme").allowed()) {
allowed.incrementAndGet();
}
} catch (IllegalArgumentException torn) {
tornReceipts.incrementAndGet(); // Receipt refused a negative remaining
} catch (InterruptedException interrupted) {
Thread.currentThread().interrupt();
} finally {
done.countDown();
}
});
}
go.countDown();
boolean returned = done.await(20, TimeUnit.SECONDS);
System.out.println("1. the unguarded map: " + crowd + " threads, capacity " + capacity);
System.out.println(" allowed = " + allowed.get()
+ " (over-admitted by " + (allowed.get() - capacity) + ")");
System.out.println(" receipts refused by their own constructor = " + tornReceipts.get());
System.out.println(" admitted field says " + sprint.admitted()
+ ", threads that got a yes = " + allowed.get()
+ ", every thread returned = " + returned);
} finally {
pool.shutdownNow();
}
}
/** audit() hands out the live list; iterating it while the limiter appends needs one thread. */
private static void blockTwoTheLiveList() {
Sprint sprint = new Sprint("edge", 100);
sprint.tryAcquire("acme");
System.out.println();
System.out.println("2. the live list, one thread only");
try {
for (String line : sprint.audit()) {
sprint.tryAcquire("acme"); // a routine call, but it mutates the list being walked
}
System.out.println(" (no exception — did the list stop being live?)");
} catch (RuntimeException e) {
System.out.println(" " + e.getClass().getName() + " on the very next iteration step");
}
}
/** configure() kept the caller's list, so the caller's tidy-up rewrites the rule set. */
private static void blockThreeTheKeptReference() {
Sprint sprint = new Sprint("edge", 1_000_000);
List<Rule> mine = new ArrayList<>();
mine.add(new Rule("acme", 2));
sprint.configure(mine);
sprint.tryAcquire("acme");
sprint.tryAcquire("acme");
boolean thirdBefore = sprint.tryAcquire("acme").allowed();
mine.clear(); // the caller tidies up its own list
boolean thirdAfter = sprint.tryAcquire("acme").allowed();
System.out.println();
System.out.println("3. the kept reference, one thread only");
System.out.println(" third request with the rule in force = " + thirdBefore);
System.out.println(" same request after the caller cleared ITS list = " + thirdAfter
+ " (the limit is gone; nothing threw)");
}
/** Registering this from the constructor lets the registry read a half-built limiter. */
private static void blockFourTheHalfBuiltObject() {
Registry registry = new Registry();
Sprint sprint = new Sprint(registry, "edge", 100);
System.out.println();
System.out.println("4. the half-built object, one thread only");
System.out.println(" registry filed : " + registry.entryFor("edge"));
System.out.println(" finished object: " + sprint.describe());
}
/**
* A scoreboard whose rendering is deferred to a worker. Single caller, single worker, and
* the worker held busy for a moment — exactly a slow sink. Both lines render with the final
* sequence number, because the task reads the field at run time, not at submit time. This is
* the shape corpus/logger's async curveball makes safe: build the value first, hand the task
* a finished record.
*/
private static void blockFiveTheCensusExpires() throws Exception {
ExecutorService worker = Executors.newSingleThreadExecutor(runnable -> {
Thread thread = new Thread(runnable);
thread.setDaemon(true);
return thread;
});
List<String> written = new ArrayList<>();
long[] seq = {0}; // safe yesterday: one thread, one field
CountDownLatch busy = new CountDownLatch(1);
worker.submit(() -> {
try {
busy.await(20, TimeUnit.SECONDS); // the slow sink the worker is stuck on
} catch (InterruptedException interrupted) {
Thread.currentThread().interrupt();
}
});
for (String message : List.of("cache miss", "cache hit")) {
seq[0]++; // still incremented by the caller
worker.submit(() -> written.add("#" + seq[0] + " " + message));
}
busy.countDown();
worker.submit(() -> { }).get(20, TimeUnit.SECONDS); // flush: wait for the queue to drain
System.out.println();
System.out.println("5. the census expires when work is deferred");
System.out.println(" expected \"#1 cache miss\" then \"#2 cache hit\"; the worker wrote:");
for (String line : written) {
System.out.println(" " + line);
}
worker.shutdownNow();
}
}
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.