Skip to content

Java Memory Model Explained: Happens-Before in Practice

Why one thread can't see another thread's write. The Java Memory Model, reordering, and every happens-before rule explained with code you can actually test.

java-concurrency thread-safety java-memory-model
Gopi Gorantala
Reading Progress

On This Page

Every explanation of the Java Memory Model that starts with "each thread has its own cache" is wrong in a way that matters, and I believed it for years.

Both threads read zero, about 1 round in 8

Two threads, two plain fields, one million rounds. Thread A does x = 1; r1 = y;. Thread B does y = 1; r2 = x;. Whoever runs first, at least one of them must see the other's write. Right? Walk through every interleaving by hand and you can't produce r1 == 0 && r2 == 0.

$ java SbLab.java plain
plain    r1=0 && r2=0 in 143,684 of 1,000,000 rounds
$ java SbLab.java volatile
volatile r1=0 && r2=0 in 0 of 1,000,000 rounds

OpenJDK 21.0.12.1, Linux x86_64, 2 vCPUs. Nine runs of the plain version gave between 21,394 and 165,438 impossible rounds out of a million. Same code with volatile semantics: zero, every time.

That's the Java memory model in one picture. This article is about why the first number isn't zero, which rules make it zero, and how to argue about your own code without hoping a test catches it. The focus is the rules, so the API tour is short on purpose.

I'll admit what I thought going in. I'd written "the other thread has a stale cached copy" in code review comments. Hardware caches are coherent, so that's not it. Working out what is going on took me well over a day: a lab that didn't work, a lab that ran for three minutes and died, the JLS chapter 17 read twice, and an -Xint run that killed my favourite theory. It's written down so you can skip that day.

The short version

The JMM (the Java Memory Model, specified in JLS chapter 17) doesn't describe caches or CPUs. It describes which executions of a program are legal. The central relation is happens-before: if action A happens-before action B, then B is guaranteed to see everything A did.

If two threads touch the same variable, at least one touching it with a write, and no happens-before chain connects them, that's a data race. For a program with no data races, the JLS promises every execution looks sequentially consistent, as if all threads' actions ran one at a time in some single order consistent with each thread's own program. A program with a data race gets no such promise. My lab is a program with a data race. Nothing in the spec forbids 0, 0.

So the working rule is boring and strict: every shared mutable variable needs a happens-before edge between each write and each read that should see it. Everything else in this article is about where those edges come from.

A lab you can run in five minutes

Setup: OpenJDK 21.0.12.1 (Ubuntu build), x86_64, 2 vCPUs. Not run on JDK 22 or later, and not run on ARM. I'll flag where that matters.

I wanted jcstress, the OpenJDK tool built for exactly this, and its SB-style tests are the standard way to prove a result is allowed or forbidden. Maven Central was blocked in my sandbox, so I couldn't run it. What follows is a plain single-file harness. Treat it as a demo with honest numbers, not a proof. If you can, rerun the same shape in jcstress and trust its outcome table over mine (use the current jcstress release and its archetype from the project's README; I haven't verified a version number here).

// SbLab.java: run with  java SbLab.java plain|acqrel|volatile
import java.lang.invoke.MethodHandles;
import java.lang.invoke.VarHandle;
import java.util.concurrent.atomic.AtomicInteger;

public class SbLab {
    static final int N = 1_000_000, STRIDE = 16;   // one cache line per round, so no false sharing
    static final int[] x = new int[N * STRIDE], y = new int[N * STRIDE];
    static final int[] r1 = new int[N * STRIDE], r2 = new int[N * STRIDE];
    static final VarHandle AV = MethodHandles.arrayElementVarHandle(int[].class);
    static final AtomicInteger arrived = new AtomicInteger();
    static String mode;

    public static void main(String[] args) throws Exception {
        mode = args.length > 0 ? args[0] : "plain";
        Thread b = new Thread(() -> run(false), "thread-B");
        b.start();
        run(true);                                  // main plays thread A
        b.join();
        int both0 = 0;
        for (int i = 0; i < N; i++) if (r1[i * STRIDE] == 0 && r2[i * STRIDE] == 0) both0++;
        System.out.printf("%-8s r1=0 && r2=0 in %,d of %,d rounds%n", mode, both0, N);
    }

    static void run(boolean isA) {
        for (int i = 0; i < N; i++) {
            int k = i * STRIDE;
            arrived.incrementAndGet();              // start gate: both threads leave together
            while (arrived.get() < 2 * (i + 1)) Thread.onSpinWait();
            int[] mine = isA ? x : y, theirs = isA ? y : x, out = isA ? r1 : r2;
            switch (mode) {
                case "volatile" -> { AV.setVolatile(mine, k, 1); out[k] = (int) AV.getVolatile(theirs, k); }
                case "acqrel"   -> { AV.setRelease(mine, k, 1);  out[k] = (int) AV.getAcquire(theirs, k); }
                default         -> { mine[k] = 1;                out[k] = theirs[k]; }  // the two lines that matter
            }
        }
    }
}

Steps:

Step 1 — java SbLab.java plain      (run it 3+ times)
Expected: a six-figure count of "impossible" rounds. Mine: 50,581 / 143,684 / 165,438 in three runs.

Step 2 — java SbLab.java volatile
Expected: 0 of 1,000,000. Every run. I did three.

Step 3 — java SbLab.java acqrel
Expected: NOT zero. Mine: 124,470 / 137,932 / 134,582.

Step 4 — java -Xint SbLab.java plain
Expected: still non-zero, just smaller: 21,394 to 36,105 in my three runs.

The forcing levers: the start gate (arrived) makes both threads begin each round within nanoseconds of each other, which is what opens the window. The stride keeps each round on its own cache line. Without a gate you'll mostly see 0, 1 or 1, 0 and conclude the model is fine.

What confirms you saw it: a non-zero count in step 1 and exactly zero in step 2. If step 1 is zero for you, you probably have one core (a single-core container serialises the threads and masks it) or the threads aren't starting together. Check nproc.

Environment differences, since the numbers will wander: -XX:ActiveProcessorCount and the machine matter a lot. On ARM the plain version will show more behaviours, and step 3 may differ too; I didn't run ARM. A debugger or a println in the loop changes timing enough to hide it. And a run that prints 0 on plain doesn't mean you're safe. Cleanup: nothing to clean, it's one process.

Why my first lab died, and what -Xint killed

My first harness had three threads: A, B, and a coordinator that reset the round and spun waiting for both. On 2 vCPUs, three busy-waiting threads fight for two cores. The run didn't fail, it just didn't finish. Three minutes, killed by timeout, nothing printed. The coordinator was starving the participants. Two threads on two cores, with the start gate inside the participants, fixed it.

The second thing was my theory. I assumed the compiler had reordered the store and the load, like in the last article's hoisted loop. C2, the optimizing JIT compiler, is certainly allowed to. So I ran with -Xint, interpreter only, no JIT. The 0, 0 outcomes dropped by about four times but didn't go away. The interpreter doesn't reorder anything. So the reordering I was looking at happens below the JVM.

That's the x86 store buffer. A CPU core doesn't wait for a store to reach the cache before running the next instruction; it parks the write in a small queue and keeps going. Thread A's x = 1 is sitting in A's queue while A's next instruction reads y. B does the same. Each core reads a value that's still the old one, because the other's write hasn't left the queue yet. Hardware people call the rule x86 follows TSO (total store order). The visible effect is store-then-load reordering, and it's the one reordering x86 does allow.

So "each thread has its own cache" is a vague echo of something real: a per-core write queue. It isn't a stale cache line, and it's not what the model is built on.

Happens-before, rule by rule

This is the section to bookmark. The JMM's rules, in JLS 17.4.5 and the java.util.concurrent package summary under "Memory Consistency Properties", all say the same thing in different clothes: this action makes that later action's view of memory legal.

Define the vocabulary once. Synchronization actions are volatile reads and writes, monitor locks and unlocks, and thread start and join. A synchronizes-with edge is the JLS's name for "this release is paired with that later acquire" (a volatile write with the next read of the same variable, an unlock with the next lock of the same monitor). A synchronizes-with edge gives you a happens-before edge. And happens-before is transitive: if A hb B and B hb C, then A hb C.

The rules you'll actually use:

  • Program order. Within one thread, each action happens-before the next one in the source. This is the only guarantee that holds without anyone else's help, and note it says "as if". The compiler may reorder if the thread can't tell.
  • Monitor. An unlock of a monitor happens-before every later lock of that same monitor. That's why everything written inside synchronized is visible to the next thread that enters the same lock, and why different locks buy you nothing.
  • Volatile. A write to a volatile field happens-before every later read of that field. Combined with program order and transitivity, writing plain data and then a volatile flag publishes the data.
  • Thread start. A call to start() happens-before everything in the started thread. Anything you set up before start() is visible to it. No volatile needed.
  • Thread termination. Everything a thread does happens-before another thread's successful return from join() on it (or from isAlive() returning false, per the Javadoc). Which is why reading a result field after join() is safe.
  • Interruption. interrupt() happens-before the interrupted thread detects it.
  • Default values. The write of the default value (0, null, false) to every field happens-before the first action in any thread. You never see memory garbage, only stale values.
  • Final fields. Under JLS 17.5, a correctly constructed object's final fields are visible to any thread that obtains the reference, even without synchronization. This one has caveats, and the safe-publication article covers them.
  • java.util.concurrent extras. Submitting a task to an Executor happens-before the task runs. A Future.get() return sees everything the task did. BlockingQueue.put happens-before the matching take. CountDownLatch.countDown() happens-before a return from await(). A ConcurrentHashMap write happens-before a later read that sees it. These are the same rules, implemented by volatile writes and CAS inside the classes.

The step people miss is the same variable, or same monitor. A write to volatile a and a read of volatile b don't synchronize with each other. Two different synchronized blocks on two different objects don't either. The edge only exists when both sides touch the same thing.

Here is a worked chain, because transitivity is where the value is:

static int data;                      // plain
static volatile boolean ready;

// thread A                          // thread B
data = 42;                           while (!ready) Thread.onSpinWait();
ready = true;                        System.out.println(data);

data = 42 hb ready = true (program order). ready = true hb B's read that sees true (volatile rule). That read hb println(data) (program order). Chain the three and B must print 42. data doesn't need to be volatile. If ready were plain, there's no chain and no guarantee; and, as the volatile article showed, on JDK 21 the spinning loop can hang forever.

Now go back to the lab with the vocabulary. In the plain version, A's write of x and B's read of x are conflicting accesses with no edge between them. That's a data race, and the model says nothing about what B reads. With volatile, all volatile accesses are in one total synchronization order that every thread agrees on, consistent with each thread's program order. If A's r1 = y read 0, then that read comes before B's write of y in the total order. B's write comes before B's read of x. So A's write of x is earlier still, and B must read 1. 0, 0 has no consistent order. Forbidden. The JVM pays for that by emitting a store-load fence after the volatile store, as far as I know (I didn't dump the assembly), which drains the store buffer.

And acquire/release, step 3, is weaker on purpose. VarHandle.setRelease/getAcquire give you a happens-before edge between a release and a matching acquire of the same variable. They don't give you the single global order, so store-then-load reordering stays legal, and my numbers show it. AtomicInteger.lazySet and friends are in that family. It's a good optimization when you can prove you don't need sequential consistency. I mostly can't, so I don't.

Where the JLS and the JDK meet

Short history, because older answers on the web are from older models.

Java 1.0-1.4   original JMM (JLS chapter 17); known broken: volatile didn't order surrounding accesses,
               final fields could be seen half-initialized
Java 5         JSR 133 rewrites the model; volatile gets acquire/release-like ordering; final field semantics;
               java.util.concurrent (JSR 166)
Java 9         VarHandle (JEP 193): plain / opaque / acquire-release / volatile access modes on a field
               Thread.onSpinWait (JEP 285)

The model hasn't been rewritten since Java 5. What changed after is the toolbox: VarHandle exposes the weaker modes explicitly, and the java.util.concurrent classes use them internally, which is where Doug Lea's comments in the source are worth reading when you want to know which edge a class really gives you.

I did not walk the AbstractQueuedSynchronizer or ConcurrentHashMap source for this article; the claim above about CAS and volatile being the implementation is from the Javadoc's memory-consistency section and general knowledge. Treat the per-class edges as Javadoc facts, not as something I traced through bytecode.

How people misread it

  • "volatile flushes the cache." Hardware keeps caches coherent. What a volatile write does is forbid certain reorderings and force the compiler to emit the fence that drains the store buffer. The mental picture of flushing lets you believe a visible write also makes x++ atomic. It doesn't.
  • "It works on my machine." x86 is one of the stronger architectures: it only allows store-load reordering. ARM allows many more. Code that survives on your Intel laptop can break on Graviton or Apple Silicon. Also: a debugger or -Xint makes the JIT-induced half of the problem vanish.
  • "Data race and race condition are the same thing." They aren't. A data race is the JMM's term: unordered conflicting accesses. A race condition is a logic bug where the result depends on timing, and you can have it with perfectly synchronized code (two threads doing locked check-then-act). You can also have a data race that's harmless in practice, though the spec won't promise that. Race conditions in Java pulls them apart.
  • "Synchronize only the writes." Reads need the edge too. A synchronized setter and an unsynchronized getter is a data race.
  • "Different locks are fine as long as each is used consistently." Only the same monitor creates the edge. Locking this in one method and a private lock object in another protects nothing between them.
  • "It passed a million iterations." Mine passes a million iterations with zero lost rounds when the harness doesn't open the window. Absence of failure in a test isn't a happens-before argument.

Proving your own code is correct

Don't aim for "I tried it and it worked." Aim for an argument you can write down: for each shared variable, name the write, name the read, and name the edge that connects them. If you can't name the edge, that's the bug, whatever the tests say.

Practical order of preference, each with its cost:

  1. Don't share it. Confine to one thread, or make it immutable. No edge needed. (The thread-confinement and immutability articles cover this: safe publication, immutable objects.)
  2. Use a java.util.concurrent class and rely on its documented edges (queue handoff, Future.get, latches). Cheapest to reason about.
  3. Lock with one monitor consistently. Gives mutual exclusion and visibility. Costs contention.
  4. volatile for a flag or an immutable snapshot reference. See the volatile article.
  5. Acquire/release or opaque via VarHandle only with a jcstress test and a written reason.

Which JDK versions behave how, and how to test

The guarantees above are the Java 5 ones and apply on every release you'll run. What varies between JDK versions is how aggressively the JIT exploits a data race and which machine you're on. I ran JDK 21 only. JDK 25 is an LTS, and Oracle's download page was already advertising a JDK 27 livestream for 15 September 2026 when I searched; I haven't re-run this on either. On any upgrade, retest anything you've been getting away with.

To catch this class of bug:

  • jcstress is the real answer. It runs each outcome many times with many scheduling configurations and gives you a table with ACCEPTABLE, FORBIDDEN, INTERESTING. Write one test per shared-state claim you rely on. Sample SB-style and message-passing tests ship in the project.
  • Vary the platform. Run on ARM at least once in CI if you ship to it.
  • Vary the compiler. -Xint vs default. If behaviour differs, you've got a JIT dependence.
  • Code review rule: every field accessed from two threads gets a comment naming the edge. // guarded by lock, // volatile: published by reloader, // immutable. No comment, no merge.
  • Thread dumps won't show a memory-model bug. A visibility hang looks like a healthy RUNNABLE thread with climbing CPU time. Reading thread dumps covers telling that apart.

If you remember nothing else

  • The JMM is about which executions are legal, not about caches. Don't explain it with "thread-local copies".
  • No happens-before edge between a write and a read of the same variable means a data race, and then anything goes. My lab: 0, 0 in 2 to 17 percent of rounds on x86, and still 2 to 3 percent with the JIT switched off.
  • Edges come from same-monitor lock/unlock, same-variable volatile write/read, start, join, and the documented handoffs in java.util.concurrent. Different lock, different variable, no edge.
  • Volatile is sequentially consistent. Acquire/release isn't, and my numbers show the difference.
  • Your test passing isn't an argument. Name the edge.

Questions people ask about the Java memory model

What is the Java memory model in simple terms? It's the part of the language spec (JLS chapter 17) that defines when one thread is guaranteed to see another thread's writes. The mechanism is happens-before. Without it, reads can return stale or reordered results.

Does happens-before mean "happens earlier in time"? No. It's about visibility and ordering guarantees, not the clock. Two actions can be ordered in time and still have no happens-before edge, and then the reader gets no promise.

Is synchronized or volatile enough to avoid a data race? Both create edges, but only for the same monitor or the same variable. volatile covers single reads and writes; synchronized also gives atomicity of the block. See volatile vs synchronized.

Do final fields need synchronization? Not for the final fields of a correctly constructed object (JLS 17.5), as long as this doesn't escape the constructor. Non-final fields reachable from it still need an edge.

Sources

  • JLS 21, chapter 17: Threads and Locks: §17.4 (memory model, Example 17.4-1), §17.4.3, §17.4.5 (happens-before, data race), §17.5 (final fields)
  • java.util.concurrent package summary, "Memory Consistency Properties", JDK 21 Javadoc
  • JEP 193: Variable Handles, JEP 285: Spin-Wait Hints
  • jcstress (not run here, Maven Central was blocked from my sandbox)
  • Search: "java memory model happens-before explained", observed 2026-10-24 (page one is tutorials; most lean on "thread-local caches")
  • Stack Overflow Hot page and Stack Exchange API: blocked from this run, no thread cited
java-concurrencythread-safetyjava-memory-model

Staff engineer architecting Kafka and Flink platforms for banking, where a dropped event is a reconciliation break, not a retry. Through javahandbook.com, I share production-tested guidance on building reliable event-driven systems.

Comments