Two threads run the very same code — and see different values of one variable. Not a compiler error and not a runtime bug: that is how modern hardware works. The Java Memory Model (JMM) is the set of rules defining when a write in one thread becomes visible to another.
Top: a plain field. The write does reach memory, but the loop keeps checking the copy the JIT put into a register, so it never ends. Bottom: the same field declared volatile. The write is pushed out through a barrier, every pass goes to memory — thread B sees false and leaves.
Why Threads Can See "Stale" Data
A modern processor does not reach main memory on every operation — too slow. Each core has its own cache (L1, L2), and a thread works with it.
Imagine: thread A writes running = false, while thread B keeps spinning as if true were still there — and may never learn about the change.
Why, if caches are coherent across cores? They are not the problem. Two reasons. First, a write lands in the core's store buffer and reaches shared memory with a delay. Second, and far more treacherous: the JIT sees a loop where nobody modifies running and optimizes it — reads the field once into a register and checks the register from then on. No write from another thread stops that loop.
Besides visibility there is reordering. The compiler and the processor reorder operations for optimization: within one thread the result is the same, another thread sees an unexpected order.
// Thread A
object = new SomeObject(); // (1) allocate memory, (2) write fields, (3) assign the reference
ready = true;
// Thread B (without synchronization)
if (ready) {
object.doWork(); // object may not be fully initialized yet!
}
happens-before: The Visibility Guarantee
happens-before is a relationship between operations: if X happens-before Y, then all writes made in X are guaranteed visible to Y.
It is not about clock order but about visibility: the JVM must ensure Y sees the up-to-date data.
A short formula: happens-before = "you will see everything I did up to this point."
The JMM has several built-in happens-before rules:
- A write to a
volatilevariable happens-before its subsequent read in any thread. - Exiting a
synchronizedblock happens-before entering that monitor from another thread. Thread.start()happens-before any operations inside the started thread.- All operations of a thread happen-before a
Thread.join()from another thread. - The relation is transitive: if X happens-before Y and Y happens-before Z, then X happens-before Z.
Without happens-before there is no visibility guarantee: a program may work on your machine and break on another with a different processor architecture.
volatile: What It Guarantees and What It Doesn't
The keyword volatile tells the JVM: "this variable may not be optimized." Every read is a real read from memory, not from a register; every write reaches other threads; barriers around the accesses forbid moving neighbouring operations across them. Usually shortened to "don't cache it," but the point is the ban on optimizations.
The classic example is a stop flag. Two identical loops: one checks a plain field, the other a volatile.
live example
public class VisibilityDemo {
static boolean plain = true;
static volatile boolean guarded = true;
public static void main(String[] args) throws InterruptedException {
System.out.println("plain field: stopped = " + spin(false));
System.out.println("volatile: stopped = " + spin(true));
}
static boolean spin(boolean useVolatile) throws InterruptedException {
Thread worker = new Thread(() -> {
if (useVolatile) { while (guarded) { } } else { while (plain) { } }
});
worker.setDaemon(true);
worker.start();
Thread.sleep(300);
if (useVolatile) guarded = false; else plain = false;
worker.join(2000);
return !worker.isAlive();
}
}
Run
Running examples is part of paid access. There the same code runs inside the article: editor, run and check next to the paragraph. Three free days →
The output is plain field: stopped = false and volatile: stopped = true. In the 300 ms the loop was spinning, the JIT put the plain field into a register: the write reached memory, but nobody reads it from there any more, and join(2000) expired for nothing. With volatile the loop exited right after the write.
The first line is not a promise: on another machine the thread may well stop. Neither outcome is guaranteed — that is the trouble.
What volatile does not guarantee — atomicity of compound operations.
The increment counter++ is three operations: read, add one, write. volatile makes each of them visible, but not the triple indivisible:
live example
public class CounterDemo {
static volatile int counter = 0;
public static void main(String[] args) throws InterruptedException {
Runnable job = () -> { for (int i = 0; i < 200_000; i++) counter++; };
Thread a = new Thread(job), b = new Thread(job);
a.start(); b.start();
a.join(); b.join();
System.out.println("expected 400000, got " + counter);
}
}
Run
Running examples is part of paid access. There the same code runs inside the article: editor, run and check next to the paragraph. Three free days →
It prints something like expected 400000, got 236844, a different number every run: both threads read the same value and wrote back the same result.
For an atomic increment use AtomicInteger or synchronized. volatile is for flags and single assignments: one thread writes, the rest read.
final Fields: Safe Publication
final fields carry a special JMM guarantee: values written to them in the constructor are visible to any thread that obtains a reference to the object — even without explicit synchronization.
This holds only while the reference does not "escape" from the constructor too early. Passing this outside shows even without a second thread:
live example
public class EscapeDemo {
static Config leaked;
static class Config {
final String host;
Config(String host) {
leaked = this; // wrong: this escaped before the constructor finished
System.out.println("inside the constructor host = " + leaked.host);
this.host = host;
}
}
public static void main(String[] args) {
new Config("db.internal");
System.out.println("after the constructor host = " + leaked.host);
}
}
Run
Running examples is part of paid access. There the same code runs inside the article: editor, run and check next to the paragraph. Three free days →
It prints inside the constructor host = null, then after the constructor host = db.internal: whoever got the reference too early saw an unfinished object. In one thread it shows at once; across threads, every other run — the final guarantee does not cover an escaped reference.
Immutable objects are thread-safe because all their fields are final, set in the constructor, and this never leaves it.
"Works on My Machine": Why It's Dangerous
Visibility problems almost never reproduce in debug mode:
- The processor architecture. x86 guarantees more than the JMM requires — some bugs never show up there. On ARM (Apple Silicon laptops, much of the cloud) the memory model is weaker, and the bug surfaces.
- The JIT compiler in interpreter mode (the first runs) skips aggressive optimizations — after warm-up the behavior changes.
- The debugger inserts breakpoints that create memory barriers and hide the races.
Code without explicit happens-before guarantees is incorrect, even if it has worked for years without visible errors.
In Short
- One thread's writes need not become visible to another right away: the store buffer and JIT optimizations get in the way.
- The compiler and the processor reorder operations: for one thread the order is correct, for its neighbour it is not.
- happens-before guarantees visibility: everything done before such an event is visible to everyone after it.
volatilegives visibility and happens-before on every access, but not the atomicity of compound operations.finalfields are published safely — providedthisdoes not escape from the constructor.- "Works on my machine" is not correctness: another architecture or a warmed-up JIT will reveal the bug.
What to Read Next
- Race Conditions — what happens when happens-before is broken, and how it looks in practice.
- synchronized and Monitors — how synchronized blocks create happens-before and protect compound operations.
- Atomic Operations —
AtomicInteger,AtomicReference, and CAS where you need atomicity. - Deadlock, livelock, and starvation — where hangs and lost updates in real code start.