The Java Memory Model's Hidden Layer

Every Java developer has written code that assumes a variable changed in one thread will be visible in another. It works on x86. Then someone runs the same binary on ARM, or upgrades the JVM, and suddenly threads spin forever waiting for a flag that was set half a second ago.

The Java Memory Model (JLS §17) exists to solve this problem. It defines happens-before relationships that guarantee when a write by one thread becomes visible to another — independent of whether the machine uses x86’s Total Store Order, ARM’s weaker load/store ordering, or whatever cache architecture runs under the JDK.

Without such a relationship, the spec imposes no guarantee at all. The JVM compiler can cache a variable in a register, reorder two writes freely, or leave a store sitting in a CPU’s write buffer indefinitely. On paper this sounds extreme; in practice it shows up as silent data corruption that never reproduces on your laptop but breaks in staging.

This post walks through three implementations of the same pattern — writing a counter and signaling “done” to another thread — to show exactly what the JMM guarantees (and doesn’t) for shared variables, and how adding volatile or synchronized fixes it by inserting memory barriers automatically.

The code

Three scenarios in one program. Each scenario has a writer thread that sets two values and a reader thread that waits for the published flag before reading those values.

Plain variables: no synchronization whatsoever. The JVM is free to cache them in registers, reorder writes A through C independently, or delay stores. On x86 hardware this often “just works” due to cache coherency — but that’s hardware behavior, not a spec guarantee.

Volatile: every write and read goes to main memory. The JMM mandates that all prior volatile writes on the same variable are visible before any subsequent volatile read sees the flag change.

Synchronized: monitor release acts as a store barrier (all writes made while holding the lock become visible when another thread acquires it). Monitor enter acts as a load barrier (reads see everything released by the previous holder). Unlike volatile, this publishes an entire consistent snapshot, not just one variable.

// Scenario 1: plain shared variables — NO visibility guarantee
static int  counter   = 0;
static boolean published = false;

static void plainWriter() {
    counter++;             // Line A (may be reordered with C)
    counter++;             // Line B (may be reordered with A)
    System.out.println("  [plain-writer] set counter = " + counter);
    published = true;      // Line C (may appear before A and B!)
}

static void plainReader() {
    while (!published) { Thread.yield(); }
    System.out.println("  [plain-reader] saw counter = " + counter);
}

// Scenario 2: volatile — guaranteed visibility of this variable
static volatile boolean  publishedVolatile = false;
static volatile int      counterVolatile   = 0;

static void volatileWriter() {
    try { Thread.sleep(50); } catch (InterruptedException ignored) {}
    counterVolatile++;
    counterVolatile++;
    System.out.println("  [volatile-writer] set counter = " + counterVolatile);
    publishedVolatile = true;   // all prior volatile writes now visible
}

static void volatileReader() throws InterruptedException {
    while (!publishedVolatile) { Thread.yield(); }
    System.out.println("  [volatile-reader] saw counter = " + counterVolatile);
}

// Scenario 3: synchronized — happens-before for the entire object graph
static int   counterSync   = 0;
static boolean publishedSync = false;
private static final Object syncLock = new Object();

static void syncWriter() {
    try { Thread.sleep(50); } catch (InterruptedException ignored) {}
    synchronized (syncLock) {
        counterSync++;
        counterSync++;
        System.out.println("  [sync-writer] set counter = " + counterSync);
        publishedSync = true;   // release fence: all prior writes visible
    }
}

static void syncReader() throws InterruptedException {
    while (true) {
        boolean seen;
        synchronized (syncLock) {
            seen = publishedSync;
            if (seen) break;
        }
        Thread.yield();
    }
    int value;
    synchronized (syncLock) {
        value = counterSync;   // acquire fence: sees everything the writer did
    }
    System.out.println("  [sync-reader] saw counter = " + value);
}

Running it

All three scenarios write counter = 2 and set their respective published flags. The reader thread waits until the flag is visible, then prints what it read.

The key point is in the JLS definition: volatile writes establish a happens-before edge from every prior volatile write on the same variable to this read. Synchronized blocks do the same thing but for everything reachable through the lock’s monitor, not just one field.

In the plain scenario, all three operations happen inside the writer thread, so counter is 2 at the point where published becomes true — no reordering can break the internal order because there’s no happens-before link to the reader. The JVM could theoretically cache both variables in registers and the reader would never see the flag at all; on this ARM+HotSpot combination it works by coincidence (hardware cache coherency masks the spec hole). On ARM with a different JIT, or with a longer-running program where register allocation changes, that same code could silently fail.

The volatile scenario demonstrates exactly what happens when the JMM steps in: the volatile keyword forces every write to propagate to main memory and every read to refresh from it. The JVM inserts load-store barriers automatically — you don’t need to know anything about ARM’s LDREX/STREX or x86’s lock prefixes. The happens-before rule for volatile reads/writes guarantees that the reader sees both the updated counter and the flag.

The synchronized scenario shows why locks are more powerful than volatile: monitor release publishes everything written while holding the lock, not just the field being published to. That means you can build complex publish patterns (writer updates a dozen fields inside one synchronized block; reader reads all of them after acquiring the same lock) without worrying about which variables need volatile and which don’t.

Takeaway

The JMM is not an optimization hint — it’s a contract that says “if you use volatile or synchronized, visibility works regardless of hardware.” Without those constructs, every shared variable is a guess about what the compiler and CPU will do. The JVM shields you from x86, ARM, RISC-V, and whatever comes next; your job is to draw the right happens-before boundary.