Anyone who has debugged a database knows the lost update: two transactions read 7, both write 8.
hits++ on a volatile field is the same bug, 40 million increments in, 21 to 26 million out.
volatile makes each single read and each single write of a field visible to every other thread. It
does not turn hits++ into one step. The increment is still a read, an add and a store, and another
thread can slip in between any two of them. The experiments below measure that gap.
I've written Java for years, and I type final every day: stable references, constructor injection
in Spring Boot, a compiler that makes sure every dependency gets assigned. I knew final also
guarantees visibility across threads. volatile I almost never use, and I look the memory model up
every time, because it never became second nature. Lost updates I know from databases. So I built
the memory-model brick of java-foundations to
have one runnable reference I can come back to. Writing this article is where I found out that I had
believed volatile made hits++ one step.
The Java memory model answers one question: who sees what, and when
The Java memory model lives in chapter 17 of the Java Language Specification: the rules for when a write in one thread becomes visible to a read in another. Without one of those rules connecting the two, it never has to. Four tools answer the question, and each makes a different promise:
| Tool | What it promises | Experiment |
|---|---|---|
volatile | every single read and write is visible, and a reader of a write also sees what the writer did before it | stop flag |
AtomicInteger | read, compute and store one variable as one step | hit counter |
synchronized | the block runs as one step, and its writes are visible to the next lock holder | hit counter |
final | a reader sees the value set in the constructor, with no lock on the reader's side | settings object |
This is part two of the Java Foundations guide, after
records, sealed interfaces and exhaustive switch.
Unless a line says otherwise, every number below is internal: one Apple M2 Pro (12 cores, macOS
26.5.1), OpenJDK 27+35, five full runs of mise run memory-model on 28 September 2026. JLS quotes
are from SE 26, the brick's --release target, the atomic package quote from the JDK 27 Javadoc.
Lost updates: hits++ is three steps, and volatile guards each one alone
The first experiment is a counter that looks fine in code review. One field, one increment:
private int hits;
public void hit() {
hits++;
}The obvious fix is one keyword, and nothing else changes:
private volatile int hits;
public void hit() {
hits++;
}JLS §15.14.2 spells out what ++ does: "the value 1 is added to the value of the variable and the
sum is stored back into the variable". A read, an add, a store. volatile makes the read see the
latest store, and makes the store visible to every later read. Nothing in it glues the three
together. Here's what two threads do with that gap:
| Step | Thread A | Thread B | hits in memory |
|---|---|---|---|
| 1 | reads 7 | 7 | |
| 2 | reads 7 | 7 | |
| 3 | adds 1, writes 8 | 8 | |
| 4 | adds 1, writes 8 | 8, not 9 |
Every read and every write in that table is volatile and correctly visible. One increment is gone
anyway. The proof, LostUpdates, lets two threads leave a start gate together, and each calls
hit() 20,000,000 times, so the target is 40,000,000:
final Runnable atTheGate = () -> {
ready.countDown();
while (!go) {
// waits at the gate
}
for (int i = 0; i < N; i++) {
hit.run();
}
};| Variant | Final count | Runs |
|---|---|---|
plain int | 20,009,748 to 20,065,498 | 5 |
volatile int | 21,272,190 to 25,837,075 | 5 |
AtomicInteger | 40,000,000 | 5 of 5 |
synchronized | 40,000,000 | 5 of 5 |
The plain row lands near half for a different reason, covered in the limits below. For the record,
JLS §17.7 does give volatile one atomicity promise: "Writes and reads of volatile long and double
values are always atomic." That covers a single read or a single write, never a read followed by a
write.
The database bridge: volatile is autocommit, not a transaction
Each Java variant has a rough counterpart in a database:
| Java | Database counterpart | Lost updates? |
|---|---|---|
| plain field | an app-side cache, written back late | yes, the last write-back wins |
volatile field | autocommit per statement: SELECT, then UPDATE, with a gap | yes |
AtomicInteger.incrementAndGet() | UPDATE counter SET hits = hits + 1 | no, the increment is one step |
synchronized block | SELECT ... FOR UPDATE inside a transaction | no |
This is the lost-update problem by name, and read committed doesn't save you from it: every
statement sees the latest committed value, and the gap between reading and writing is still there. A
volatile field has the same gap. The analogy stops there. The one-statement UPDATE avoids the
lost update because the database locks the row and computes hits + 1 from its current value;
AtomicInteger gets the same result without a lock. And synchronized is no transaction: nothing
rolls back when the block throws halfway, and a thread that reads without taking the lock can see
the block half done.
AtomicInteger and synchronized: two ways to make the increment one step
The atomic variant swaps the field for an AtomicInteger:
private final AtomicInteger hits = new AtomicInteger();
public void hit() {
hits.incrementAndGet();
}incrementAndGet() does the read, the add and the store as one atomic step, with no lock. The
package Javadoc of java.util.concurrent.atomic (JDK 27) puts the scope plainly: "A small toolkit
of classes that support lock-free thread-safe programming on single variables."
The lock variant keeps the plain field and guards both the write and the read:
public void hit() {
synchronized (lock) {
hits++;
}
}
public int hits() {
synchronized (lock) {
return hits;
}
}The reader takes the lock too, because the visibility edge in JLS §17.4.5 needs both sides: "An unlock on a monitor happens-before every subsequent lock on that monitor." Both variants ended at exactly 40,000,000 in all five runs.
Stale reads: with one writer, volatile is the whole fix
The second experiment flips the situation. One thread writes a flag, another only reads it:
private boolean stopped;
public void run() {
while (!stopped) {
// spins on the flag, see the class comment
}
}
public void shutdown() {
stopped = true;
}StaleRead starts the loop, calls shutdown() after 200 ms, and waits up to 3000 ms. The plain
loop never noticed, in five of five runs. The spec saw this coming in §17.3: "The compiler is free
to read the field this.done just once, and reuse the cached value in each execution of the loop.
This would mean that the loop would never terminate, even if another thread changed the value of
this.done."
VolatilePoller is the same class with the field made volatile. It noticed in 0.0404 to 0.3743
ms. JLS §17.4.4 is the rule that ties the write to the loop's read: "A write to a volatile variable
v (§8.3.1.4) synchronizes-with all subsequent reads of v by any thread (where 'subsequent' is
defined according to the synchronization order)." The spec promises an order, not a latency; the
milliseconds are this machine's. By §17.4.5 the write also happens-before those reads, so a reader
of the flag sees every write the writer made before setting it. Here volatile is enough, because
one thread writes the flag and nothing is computed from the old value.
Unsafe publication: final asks nothing of the reading thread
The third experiment is about handing an object to another thread. A settings object, built once, never touched again:
private int timeoutMs;
public Settings(int timeoutMs) {
this.timeoutMs = timeoutMs;
}UnsafePublication has a writer thread create 2,000,000 of them with timeoutMs 42 and drop them
into 64 array slots, with no lock. The main thread reads the slots as fast as it can and counts
every time it sees 0, the field's default.
| Variant | Reads of 0 | Total reads | Runs |
|---|---|---|---|
| plain field | 25 to 55 | ~18.1M to ~22.2M | 5 |
final field | 0 | ~18.9M to ~29.0M | 5 of 5 |
Nobody mutates Settings. It's immutable in practice, and a racing reader still sees an object
whose constructor hasn't landed yet. Immutable in practice is not safe publication; final is what
makes the value safe to read through a race. ImmutableSettings changes one word,
private final int timeoutMs;, and JLS §17.5 carries the rest: "A thread that can only see a
reference to an object after that object has been completely initialized is guaranteed to see the
correctly initialized values for that object's final fields." The same section names the writer's
one duty: "do not write a reference to the object being constructed in a place where another thread
can see it before the object's constructor is finished." In code, this must not escape the
constructor.
That makes final the only tool of the four that asks nothing of the reading thread: no lock, no
volatile read.
One writer, new value from the old, more than one line
Put the three experiments side by side and they collapse into one rule:
One writer:
volatile.
New value from the old one: atomic.
More than one line:synchronized.
The rule is a shortcut. The exact line for volatile: every write ignores the current value or
comes from the only thread that ever writes, and the field is part of no invariant with other
fields.
Each clause has its number:
- One writer is the stop flag: never seen in 3000 ms as a plain field, seen in 0.0404 to 0.3743 ms
as
volatile. - New value from the old one is the counter: 21,272,190 to 25,837,075 with
volatile, exactly 40,000,000 withAtomicInteger, five runs each. - More than one line is the lock's job, because the lock is the simple tool that covers whatever sits inside the block. The brick measures one line in it, 40,000,000 in five of five runs; the guarantee is the same for ten.
final sits before the rule. If a field never changes after construction, make it final and keep
this inside the constructor; readers then need nothing for that field.
Where the guarantee stops
The brick's README has a section called "When this demo would lie to you". The short version:
- The plain counter loses its updates to the JIT, not to interleaving. A separate isolated, timed run (JDK 26.0.1, 20 September 2026) points to C2 keeping the field in a register for the compiled loop and writing it back once, so one thread's final store overwrites almost all of the other's share. That's the app-side cache from the bridge table, and the "myriad of code transformations" JLS §17.4 allows, not the §15.14.2 read, add, store.
- The 0 for
finaland the 5 of 5 for atomic and lock are observations from five runs, reported as such. The four counters also share one JVM; run one per JVM on JDK 26.0.1, the volatile range narrowed. And one zero-valued slot can be counted twice, so read the zero-reads as races caught, not as a tally. - The volatile timing is an upper bound. It covers more than visibility: the gap between
shutdown()and the worker's next loop check, OS scheduling included. - The machine is ARM. The memory model permits the plain field's zero-reads on any CPU; if yours, x86 included, shows 0, that's no guarantee.
- A loop that happens to stop isn't safe. Put a
Thread.sleepin the plain loop and it often terminates. §17.3 uses exactly that sleeping loop as its broken example. - Rare races need another tool. A plain loop won't reliably surface a low-percent tail; jcstress, with forked processes and billions of samples, is built for that.
finalpins the reference, not the object. Afinal Listcan still change underneath you. AList.copyOfin a record's compact constructor, as in part one, is what protects it.
Run it yourself
The brick is plain JDK with no dependencies, and mise fetches the toolchain:
git clone https://github.com/lukas-grigis/java-foundations
cd java-foundations
mise run memory-modelThis is the first of the five recorded runs:
================= LostUpdates =================
plain int 20065498 hits
volatile int 21272190 hits
atomic int 40000000 hits
synchronized 40000000 hits
================= StaleRead =================
plain boolean 3000 ms (cap — still running)
volatile boolean 0.043959 ms
================= UnsafePublication =================
plain field 38 zero-reads / 20360876 reads
final field 0 zero-reads / 28982720 readsEach proof also runs on its own:
mise run memory-model:lost-updates
mise run memory-model:stale-read
mise run memory-model:unsafe-publicationWithout mise, run mvn -q compile in bricks/memory-model and start any proof class with
java -cp target/classes on JDK 26 or later.
Every file on GitHub:
- The counter:
HitCounter,VolatileHitCounter,AtomicHitCounter,SynchronizedHitCounterand the proofLostUpdates - The flag:
Poller,VolatilePollerand the proofStaleRead - The settings:
Settings,ImmutableSettingsand the proofUnsafePublication - The recorded runs and the full limits: the brick README
The counter, the flag and the settings object are illustrations. What carries over is the question you ask before you type a keyword: is this thread the only writer, does the new value come from the old one, does the invariant span more than one line? If a number doesn't reproduce on your machine, open an issue on the repo.
