What does 'happens-before' mean in the Java Memory Model, and why can code without synchronization see stale values across threads?
The JMM doesn't just govern instruction order — it decides whether a write from one thread is ever guaranteed to become visible to another at all.
LEARN BY DOINGPut your intuition to work3 steps
What does 'happens-before' mean in the Java Memory Model, and why can code without synchronization see stale values across threads?
Use the lesson’s examples and explanation to check your idea.
Personal reflection. Nothing is sent to an AI service.#Two Different Questions: Ordering and Visibility
Most developers think of concurrency bugs purely in terms of ordering — did thread A's write happen before thread B's read? The Java Memory Model (JMM) is actually answering a subtly different and stricter question: given two actions in different threads, is thread B guaranteed to observe the effect of thread A's action at all, and if so, is it guaranteed to see it in a particular order relative to other actions? Without an explicit guarantee, the JVM and the underlying hardware are both allowed to behave in ways that feel impossible if you're only thinking about program order.
#Why Reordering and Stale Reads Are Legal
Two independent mechanisms conspire to make unsynchronized code behave unpredictably across threads:
- Compiler and JIT reordering. As long as it doesn't change the observable behavior of a single thread, the compiler and the JIT are free to reorder, hoist, or even eliminate reads and writes. A loop that reads a field repeatedly with nothing in between that appears to modify it (from that thread's point of view) can have the read hoisted out of the loop entirely, turning
while (flag) { ... }into an effectively infinite loop ifflagis set by another thread. - CPU caching and store buffers. Each core can keep a value in a private cache line or store buffer rather than writing straight through to main memory. A write from one core is not guaranteed to be visible to another core's cache until some memory-ordering instruction forces a flush — and a plain field write doesn't emit one.
Neither of these is a JVM bug. They exist because they make single-threaded code fast, and the JMM's whole design is to say precisely which cross-thread guarantees you get without giving those optimizations up — as long as you use the right synchronization primitives.
#The happens-before Relation
"Happens-before" is not about wall-clock time. A happens-before B means: the effects of A are guaranteed visible to, and ordered before, B, from B's point of view. If no happens-before edge connects two actions in different threads, the JMM allows B to observe any interleaving of A's effects — including none at all.
| Rule | Guarantee |
|---|---|
| Program order rule | Each action in a thread happens-before every later action in that same thread's program order |
| Monitor lock rule | Unlocking a monitor happens-before every subsequent lock of that same monitor |
| Volatile variable rule | A write to a volatile field happens-before every subsequent read of that same field |
| Thread start rule | A call to Thread.start() happens-before any action inside the started thread |
| Thread join rule | Every action in a thread happens-before another thread's successful return from join() on it |
| Transitivity | If A happens-before B, and B happens-before C, then A happens-before C |
#A Real Visibility Bug
public class Worker implements Runnable {private boolean running = true; // BUG: not volatile@Overridepublic void run() {int i = 0;while (running) {i++;}System.out.println("stopped after " + i + " iterations");}public void stop() {running = false;}}public class Main {public static void main(String[] args) throws InterruptedException {Worker worker = new Worker();Thread t = new Thread(worker);t.start();Thread.sleep(1000);worker.stop();t.join(); // can hang indefinitely — this is legal under the JMM}}
There is no happens-before edge between worker.stop()'s write to running and the worker thread's read of running inside the loop. Depending on the JIT and the hardware, one of two things can go wrong: the JIT may hoist the read of running out of the loop entirely (nothing in the loop body appears, from the worker thread's perspective, to change it), turning the loop into while (true); or the write may simply sit in the main thread's store buffer / cache and never get flushed to where the worker's core can see it. Either way, t.join() can block forever, and this is not a rare hardware fluke — it is explicitly permitted by the specification whenever there is no synchronization.
#The Fix
public class Worker implements Runnable {private volatile boolean running = true; // FIX: volatile@Overridepublic void run() {int i = 0;while (running) {i++;}System.out.println("stopped after " + i + " iterations");}public void stop() {running = false;}}
Marking running volatile invokes the volatile variable rule: the write in stop() now happens-before every subsequent read of running, in every thread. That guarantees two things at once — visibility (the new value is guaranteed to be observed) and ordering (the compiler can no longer reorder other memory operations across that volatile access, which matters once more fields are involved). synchronized blocks around both the read and the write would fix the same bug through the monitor lock rule instead, and AtomicBoolean would fix it while also giving you atomic compound operations, which volatile alone does not.
#The Diagram
🧠 Check your understanding
0/3 · 0/3 answered1. What does it mean for action A to 'happen-before' action B in the Java Memory Model?
2. In the buggy Worker example, why can the while(running) loop run forever even though stop() sets running = false?
3. Why is marking a counter field volatile not enough to make counter++ thread-safe?