Syllabus · E1
Identifying shared mutable state — the two-question census
The idea
Two questions per field, and a guard column you have to fill
Under a twelve-minute clock you need a procedure, not a warning. Go down the class you wrote ten minutes ago, field by field, and ask two questions.
- Reach. Can two threads hold a reference to this at the same time?
- Write. Can anything change it after construction?
Yes to both puts the field on a list, and every entry has to name its guard: a lock, a concurrent structure, or a confinement rule. An entry whose guard column you cannot fill is the race. No to either question means leave it alone, because a guard there is cost with no invariant behind it, and when-not.md prices that at 15x on a real bench.
Ask question 1 about the API as well. Three sharings hide from a field list: a reference you return, a reference you were given and kept, and this handed to somebody before the constructor finishes. contrast/ mechanises that census by reflection: the same six fields for all three designs, and neither escape seen.
Then the number that makes this a reading skill, not a testing one. corpus/rate-limiter/reference/DECISION_LOG.md records two deliberately broken variants of its reference, and both pass its base suite 28 of 28. In contrast/, the tree with no guard on any field passes BaseTest 6 of 6. A green bar cannot see shared mutable state.
Which guard to use is E2, and how wide to make it is E3. Neither question can be answered about state you never noticed.
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 is deciding that a field needs nothing, and it is the half candidates skip. A lock on state no invariant touches is not caution. It is a bill, and STANDARD v1.0 has a tag for it: over-engineered is in D3's vocabulary in server/lib/rubric.mjs, and it routes to a when-not.md file exactly like this one.
There is a worse consequence than the score. A candidate who locks everything has avoided E1 rather than demonstrated it. The review question is "what does this lock protect?" — and "everything" is the answer of somebody who never classified anything.
The concrete bad example, measured
contrast/a-locked/Limiter.java is that candidate's work: one monitor, every method inside it, nothing else changed. It compiles clean, passes BaseTest 6 of 6, and passes the contended scenario that the unguarded tree fails. Then:
a-locked CurveballTest {"pass":1,"total":3,"green":false}
b CurveballTest {"pass":3,"total":3,"green":true}
The two scenarios it still fails run on one thread. audit() hands the dashboard the list the limiter appends to, so the dashboard's next iteration step throws java.util.ConcurrentModificationException. And configure 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 help, because neither failure is about atomicity. Both are about who can reach the reference.
And the locking it did add has a price. describe() reads two final fields. No answer it returns can be wrong, no matter how many threads call it. 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. Wall-clock numbers move with the machine; the ratio is the finding.
The other over-application: copying at a boundary that carries volume
The census's own fix has a cost too, and pretending otherwise would teach the same mistake in the other direction. b/'s audit() takes a snapshot under the guard. With a 200,000-line trail, same bench:
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). A defensive copy taken under the lock makes every waiting request pay for it, and 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. It does not tell you a snapshot is free. At that trail length the answer is a bounded page, or an immutable append-only structure the reader can hold, not a copy of everything on every poll.
What is not shared, said out loud
Say these verdicts in the round. They are the cheapest marks in the concurrency leg.
Locals and parameters. A local variable lives on one thread's stack, and a parameter is a copy of a reference. limitFor(caller) in worked/ needs nothing, ever. What the parameter points at can still be shared, which is how configure gets into trouble. The verdict is about the variable, not the object.
Immutable values. Rule and Receipt are records with final components and no setter. Any number of threads can read one. 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. The worker thread never reads the logger's own state.
Final fields of a fully constructed object. name and capacity are safely published, which is precisely why publishing this early is a bug: the guarantee starts when construction finishes.
Confined state. State one thread owns and nobody else can reach. E2's Drain.served is a plain long written by the worker and read only after close() observes the join, because Thread.join is a happens-before edge. Both corpus stress suites read their tallies that way, with no lock.
A single-threaded contract. Several corpus problems never promise concurrent callers, and their references contain no locks at all. Synchronisation added there defends against threads that do not exist and is paid for in clock — 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. The list is the demonstration.
The threshold, from both sides
Guard a field when both 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 and it crosses the class line — in or out. Leave alone anything that answered no: locals, parameters, immutable values, final fields, confined state, and state read after a join.
Then write the sentence. 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, 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.