Back to blog

Java memory model: volatile, atomic, synchronized and final, measured

volatile makes each read and each write visible, but it does not make hits++ one step. Three runnable JDK 27 experiments measure what volatile, AtomicInteger, synchronized and final each promise.

Lukas Grigis11 min read
javaconcurrencyjava-memory-modelvolatile
0:00 / 0:00
Two thumbs drawn in blue line-work press the button of a mechanical hand tally counter at the same instant, and amber sparks fly where they meet.
On this page

Key takeaways

  1. The Java memory model answers one question: which thread sees which write, and when. The rule the three experiments land on: One writer: volatile. New value from the old one: atomic. More than one line: synchronized.
  2. volatile makes each single read and each single write visible. It does not make hits++ one step: two threads at 20,000,000 increments each ended between 21,272,190 and 25,837,075 of 40,000,000 (internal, 5 runs).
  3. AtomicInteger.incrementAndGet() and a synchronized block both ended at exactly 40,000,000, in 5 of 5 runs. The atomic covers one variable; the lock covers every line inside the block.
  4. A spin loop on a plain boolean never saw the stop flag within 3000 ms, while the volatile flag was seen in 0.0404 to 0.3743 ms. A final field showed 0 default values in about 18.9M to 29.0M racing reads, the plain field 25 to 55.

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:

ToolWhat it promisesExperiment
volatileevery single read and write is visible, and a reader of a write also sees what the writer did before itstop flag
AtomicIntegerread, compute and store one variable as one stephit counter
synchronizedthe block runs as one step, and its writes are visible to the next lock holderhit counter
finala reader sees the value set in the constructor, with no lock on the reader's sidesettings 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:

javaHitCounter.javaL11-15
private int hits;

public void hit() {
    hits++;
}

The obvious fix is one keyword, and nothing else changes:

javaVolatileHitCounter.javaL11-15
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:

StepThread AThread Bhits in memory
1reads 77
2reads 77
3adds 1, writes 88
4adds 1, writes 88, 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:

javaLostUpdates.javaL59-67
final Runnable atTheGate = () -> {
    ready.countDown();
    while (!go) {
        // waits at the gate
    }
    for (int i = 0; i < N; i++) {
        hit.run();
    }
};
VariantFinal countRuns
plain int20,009,748 to 20,065,4985
volatile int21,272,190 to 25,837,0755
AtomicInteger40,000,0005 of 5
synchronized40,000,0005 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:

JavaDatabase counterpartLost updates?
plain fieldan app-side cache, written back lateyes, the last write-back wins
volatile fieldautocommit per statement: SELECT, then UPDATE, with a gapyes
AtomicInteger.incrementAndGet()UPDATE counter SET hits = hits + 1no, the increment is one step
synchronized blockSELECT ... FOR UPDATE inside a transactionno

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:

javaAtomicHitCounter.javaL11-15
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:

javaSynchronizedHitCounter.javaL12-22
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:

javaPoller.javaL13-23
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:

javaSettings.javaL12-16
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.

VariantReads of 0Total readsRuns
plain field25 to 55~18.1M to ~22.2M5
final field0~18.9M to ~29.0M5 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 with AtomicInteger, 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 final and 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.sleep in 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.
  • final pins the reference, not the object. A final List can still change underneath you. A List.copyOf in 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:

bash
git clone https://github.com/lukas-grigis/java-foundations
cd java-foundations
mise run memory-model

This is the first of the five recorded runs:

text
================= 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 reads

Each proof also runs on its own:

bash
mise run memory-model:lost-updates
mise run memory-model:stale-read
mise run memory-model:unsafe-publication

Without 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, 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.

Frequently asked questions

Does volatile make an operation atomic in Java?

No. volatile makes each single read and each single write of the field visible to every other thread, but a compound operation such as hits++ is still a read, an add and a store, and another thread can run between them. In five internal runs (Apple M2 Pro, OpenJDK 27), two threads doing 20,000,000 volatile increments each ended between 21,272,190 and 25,837,075 instead of 40,000,000. The only atomicity volatile adds is for single reads and writes of long and double values.

When is volatile enough in Java?

When every write either ignores the current value or comes from the only thread that ever writes the field, and the field takes part in no invariant with other fields. The classic case is a stop flag. The Java Language Specification says a write to a volatile variable synchronizes-with all subsequent reads of it by any thread. In five internal runs (Apple M2 Pro, OpenJDK 27), a spin loop on a plain boolean never saw the flag within 3000 ms; the volatile version saw it in 0.0404 to 0.3743 ms.

What is the difference between volatile and synchronized in Java?

volatile makes each single read and write of one field visible, and a thread that reads the new value also sees every write the writer made before it. It does not make hits++ one step. synchronized makes every line inside the block run as one step for threads holding the same lock, and makes those writes visible to the next thread that takes it. In five internal runs (Apple M2 Pro, OpenJDK 27), 40,000,000 increments ended between 21,272,190 and 25,837,075 with volatile and at exactly 40,000,000 with synchronized. AtomicInteger sits in between: one variable, one atomic step, no lock.

Does final guarantee visibility across threads in Java?

For the final fields themselves, yes, as long as the constructor does not let this escape. JLS §17.5 guarantees that a thread seeing a reference to a completely initialized object sees the correctly initialized values of its final fields, with no lock on the reading side. That covers what the constructor left behind. A final List field can still be changed through the reference later, and final makes no visibility promise about those later changes.

Resources

Take it to an assistant

It reads the article first, then argues with you.

Pass it on

Post the link, or copy it for later.

More posts

Enjoyed this article?

Subscribe to the newsletter

One email per new article. Unsubscribe anytime.