Core Java

Java’s Memory Model Is Not What You Think: The Gap Between the JMM Spec and the JIT’s Actual Guarantees

The Java Memory Model is formally specified. The JIT implements a superset of that spec in ways the spec permits but most developers never encounter — until something breaks in production at 3 a.m.

Most Java developers have a working model of the Java Memory Model that goes roughly like this: volatile ensures visibility, synchronized ensures atomicity and visibility, and the JVM takes care of the rest. That model is not wrong exactly — but it is incomplete in ways that produce real, hard-to-reproduce bugs in multithreaded code.

The JMM, formally specified in Chapter 17 of the Java Language Specification and substantially revised by JSR-133 in Java 5, defines a set of permitted behaviors — what the runtime is allowed to do. The JIT compiler then implements some subset of those permissions, and on any given platform, on any given JVM version, under any given load, it may choose different optimizations than you expect. The gap between “what the spec allows” and “what developers assume” is exactly where subtle concurrency bugs live.

This article is not a beginner’s introduction to threads. It assumes you understand happens-before as a concept, have used synchronized and volatile, and want to understand what is actually happening at the spec and hardware level — and specifically, where the gaps are.

1. The model you think you know

At its core, the JMM defines a contract: if your program is correctly synchronized — meaning all shared mutable state is protected by synchronization constructs — then executions will appear sequentially consistent, as though all threads took turns executing in some total order. This is the guarantee the spec actually makes.

The mechanism is happens-before: a partial ordering relation over memory actions. If action A happens-before action B, then the effects of A are guaranteed to be visible to B. The rules that establish happens-before are specific and enumerable:

RuleWhat establishes happens-before
Program OrderWithin a single thread, each action happens-before the next in program order
Monitor LockAn unlock on a monitor happens-before every subsequent lock on that same monitor
Volatile WriteA write to a volatile field happens-before every subsequent read of that same field
Thread StartA call to Thread.start() happens-before any action in the started thread
Thread JoinAll actions in a thread happen-before any thread returns from Thread.join() on it
TransitivityIf A happens-before B, and B happens-before C, then A happens-before C
Final FieldsAll writes to final fields in a constructor happen-before any subsequent read of the object (with conditions — see below)

That last row is the one most developers have never fully internalized. And the volatile row hides a subtlety that has broken production systems. Let us take them in turn.

2. When volatile is not enough

volatile does two things in the post-JSR-133 Java world: it guarantees visibility (a volatile write is immediately visible to all subsequent volatile reads of that variable), and it prevents certain reorderings (no load or store can be reordered across a volatile access). What it does not do is provide compound atomicity. That distinction is the first place things go wrong.

2.1 The classic trap: the invisible flag

Here is a case most developers have seen but not always understood:

Broken — JIT may hoist the read out of the loop
class Worker implements Runnable {
    private boolean running = true; // NOT volatile

    public void run() {
        while (running) {
            // do work
        }
    }

    public void stop() { running = false; }
}

The JIT sees that running is never written inside the loop body. Because there is no synchronization establishing a happens-before between the write in stop() and the read in run(), the JIT is legally permitted to hoist the read out of the loop — transforming it into something semantically equivalent to if (running) while (true) {}. On a modern server JVM under load, this optimization regularly fires. The thread never stops. Adding volatile fixes it.

2.2 Where volatile actually falls short: compound actions

The deeper problem appears when developers assume that because a variable is volatile, all operations on it are safe across threads. That is not true. volatile guarantees that each individual read and write is atomic and visible. It does not make a check-then-act sequence atomic.

Still broken despite volatile
private volatile int counter = 0;

// In Thread A and Thread B simultaneously:
counter++; // Read, increment, write — NOT atomic
           // The read and write are each individually atomic,
           // but another thread can interleave between them.

For compound actions, you need either synchronized, or the classes in java.util.concurrent.atomic which use hardware-level compare-and-swap operations.

2.3 The double-checked locking lesson

The most famous example of volatile being misunderstood is double-checked locking (DCL). Before Java 5, it was broken in ways the JIT made invisible in testing. The pattern looks like this:

Broken pre-Java-5, requires volatile in Java 5+
class Singleton {
    private static Singleton instance; // Missing: volatile

    public static Singleton getInstance() {
        if (instance == null) {               // Check 1 — no lock
            synchronized (Singleton.class) {
                if (instance == null) {         // Check 2 — inside lock
                    instance = new Singleton(); // Object creation
                }
            }
        }
        return instance;
    }
}

The problem is that instance = new Singleton() is not a single atomic operation. It expands to roughly three steps: allocate memory, initialize the object, assign the reference. The JIT and the CPU are free to reorder the second and third steps. The Symantec JIT famously did exactly this, writing the reference before the constructor finished — so Thread B could see a non-null but incompletely constructed object.

The fix and why it works

Declaring instance as volatile fixes DCL in Java 5 and later. A volatile write creates a memory barrier that prevents the initialization steps from being reordered after the assignment. Thread B’s volatile read of instance then guarantees it sees all the writes that happened before the volatile write — including the constructor’s initialization. The happens-before chain is: constructor writes → volatile write → volatile read → consumer reads.

However, the correct advice today, as Bill Pugh’s work established, is to use the Initialization-on-Demand Holder idiom instead:

Preferred: Initialization-on-Demand Holder — no locks, no volatile
class Singleton {
    private static class Holder {
        static final Singleton INSTANCE = new Singleton();
    }

    public static Singleton getInstance() {
        return Holder.INSTANCE;
    }
}

The class loader guarantees that Holder is initialized exactly once, with full memory visibility, before any thread can access it. No locks. No volatile. No DCL. The JVM’s own class initialization machinery provides the synchronization — and it is guaranteed by the spec to be correct.

3. The final field publication guarantee that surprises most engineers

This is the part of the JMM that even experienced developers get wrong — usually in the direction of being more cautious than necessary. The spec provides a strong guarantee here that most people do not use as confidently as they could.

The rule, from JSR-133: if an object is properly constructed — meaning no reference to it escapes the constructor before the constructor completes — then all threads which subsequently obtain a reference to that object will see the correct values of its final fields, without any additional synchronization.

This is called initialization safety, and it is a stronger guarantee than most developers realize. It means that a correctly built immutable object can be shared across threads via any mechanism — even a data race on the reference itself — and the final fields will always be seen in their initialized state.

Safe: final fields are always visible after construction completes
class ImmutablePoint {
    public final int x, y;

    public ImmutablePoint(int x, int y) {
        this.x = x;
        this.y = y;
        // JVM inserts a StoreStore barrier here before returning.
        // All writes to x and y are flushed before the reference
        // becomes visible to any other thread.
    }
}

// Even this unsafe-looking publication is safe for final fields:
static ImmutablePoint sharedPoint; // Not volatile, not synchronized

// Thread A:
sharedPoint = new ImmutablePoint(3, 7);

// Thread B (even if it races with Thread A):
ImmutablePoint p = sharedPoint;
if (p != null) {
    // p.x and p.y are GUARANTEED to be 3 and 7
    // Thread B may see p as null (the reference race),
    // but if it's non-null, the finals are correct.
    System.out.println(p.x + ", " + p.y);
}

The JVM enforces this by inserting a StoreStore memory barrier just before the constructor returns. All writes made during construction — including writes to objects reachable through the final fields — are flushed before the object reference can be published.

What “reachable through final fields” means

The guarantee extends transitively: if a final field holds a reference to a HashMap, and you put entries in that map during the constructor, those entries are also guaranteed to be visible to any thread that reads the object. This is why immutable classes with final fields pointing to mutable collections can be safely read (but not written) across threads without synchronization.

3.1 The trap: escaping this from the constructor

The guarantee evaporates the moment this escapes before the constructor completes. This is a subtler trap than it first appears.

Dangerous: this escapes from constructor — JMM guarantee is void
class EventListener {
    private final String name;

    public EventListener(String name, EventBus bus) {
        this.name = name;
        bus.register(this); // 'this' escapes here!
                             // Another thread on the bus can access
                             // 'name' before the constructor finishes.
                             // The final field guarantee does not apply.
    }
}

The this reference escaping via bus.register(this) is the most common variant. Anonymous inner classes starting a thread that references the outer this, or adding this to a collection during construction, are other forms of the same mistake. In all these cases, another thread may see the final fields in their default (zero/null) state, even though initialization happens “before” the escape in source code order.

Under the old JMM (pre-Java 5), it was worse

Before JSR-133, the JMM permitted a thread to observe a final field having more than one value over its lifetime — seeing its default value first, then its initialized value. The fix introduced by JSR-133 was specifically to guarantee that a properly constructed object’s final fields are frozen at the end of construction. This is one of the most important changes Java 5 ever made to concurrent programming.

4. Instruction reordering and CPU cache coherency: what the abstraction hides

The JMM is deliberately a hardware-agnostic abstraction. It defines what is permitted without specifying what mechanism the JVM uses to enforce it. In practice, the mechanism is memory barriers (also called memory fences) — CPU instructions that constrain how the processor reorders loads and stores with respect to caches and other cores.

The JMM spec and the underlying hardware operate at different levels of abstraction, and the gap between them is where many mental models break down.

Memory access latency: why the cache hierarchy makes reordering so profitable

Approximate figures for modern x86 server hardware — the gap is what drives JIT optimization

The reason reordering is so profitable is shown above: L1 cache is roughly 100× faster than main memory. From the CPU’s perspective, reordering memory accesses so that cache-warm operations cluster together is an enormous win. The JMM permits this reordering in the absence of synchronization — and the JIT exploits it aggressively.

4.1 The four types of barrier

When the JIT needs to enforce a JMM guarantee, it emits one of four types of memory barrier. Understanding these removes the mystery from what volatile and synchronized actually do at the hardware level:

Barrier typePreventsInserted by JIT when
LoadLoadLoad from being reordered before a preceding loadAfter a volatile read
StoreStoreStore from being reordered before a preceding storeBefore a volatile write; before a constructor’s final field freeze
LoadStoreStore from being reordered before a preceding loadAfter a volatile read
StoreLoadLoad from being reordered before a preceding storeAfter a volatile write — the most expensive barrier; effectively a full fence

On x86 and x86-64, the architecture’s memory model is relatively strong — loads are not reordered with other loads, and stores are not reordered with other stores. The only reordering x86 allows that the JMM does not is store-load reordering. This means that on x86, the JIT only needs to emit an actual machine-level barrier for volatile writes (specifically, a MFENCE or equivalent on write). Volatile reads on x86 are essentially free in hardware terms.

This is a significant reason why concurrency bugs that would be caught on ARM or SPARC (which have weaker memory models) are invisible on x86. The JIT produces different code on different architectures, and both are spec-compliant — but the x86 version happens to be stricter than required.

4.2 The CPU cache coherency layer the JMM abstracts over

Even with barriers in place, the JMM deliberately abstracts over cache coherency protocols. Modern multi-core CPUs use protocols like MESI (Modified-Exclusive-Shared-Invalid) to maintain consistency between L1 caches on different cores. From a Java programmer’s perspective, the important thing is that the JMM does not expose this protocol — it only specifies the outcomes.

The compiler, runtime, and hardware are supposed to conspire to create the illusion of as-if-serial semantics for single-threaded programs. Reorderings can come into play only in incorrectly synchronized multithreaded programs.— JSR-133 FAQ, Bill Pugh and Jeremy Manson

The word “conspire” is doing a lot of work in that sentence. The JIT, the CPU, and the memory subsystem each make local decisions that together produce globally correct behavior for well-synchronized programs. For incorrectly synchronized programs, all bets are off — and “incorrectly synchronized” is a broader category than most developers assume. It includes any shared mutable state that lacks a happens-before chain, even if it looks safe on paper.

5. What breaks when you assume the abstraction is perfect

Let us look at a few concrete failure patterns that real production systems have encountered — patterns where the JMM’s abstraction is technically sound, but developers’ assumptions about it were not.

5.1 Pattern 1: Benign data races that aren’t

A common belief: “this shared variable is only written during initialization and then only read; it’s effectively immutable, so I don’t need synchronization.” This is the “benign data race” assumption, and it fails in two ways.

First, a non-volatile, non-final field has no initialization safety guarantee. A reading thread might see the field’s default value (zero or null) even if the writing thread has already assigned it. Second, long and double fields are not guaranteed to be read or written atomically by default on 32-bit JVMs — they can be read as two separate 32-bit operations, producing a “word tearing” value that combines bits from two different writes.

Not safe: the "benign" race on a non-volatile, non-final field
class Config {
    private long timeout; // Set once during construction, then read-only
                          // but NOT final — no initialization safety.
                          // On a 32-bit JVM: word tearing is possible.

    public Config(long timeout) {
        this.timeout = timeout;
    }

    public long getTimeout() {
        return timeout; // Another thread might see 0 or a mangled value.
    }
}

// Fix: declare timeout as final, or publish Config safely.

5.2 Pattern 2: The synchronized-but-broken visibility mistake

A less obvious trap: synchronizing writes but not reads, or synchronizing on different objects.

Broken: write is synchronized, read is not — no happens-before
class SharedState {
    private int value;

    public synchronized void setValue(int v) {
        value = v; // Locked on 'this'
    }

    public int getValue() {
        return value; // NOT synchronized — no lock acquisition here.
                      // The unlock in setValue() happens-before a
                      // subsequent LOCK acquisition on the same monitor,
                      // not a bare read. This reader may see stale values.
    }
}

The JMM rule is precise: an unlock happens-before every subsequent lock on that monitor, not before every subsequent access to the same data. If the reading thread does not acquire the same lock, the happens-before chain is broken, and the read is a data race even though the write was synchronized.

5.3 Pattern 3: Volatile piggybacking — intentional and accidental

Because a volatile write happens-before all subsequent volatile reads of that variable, you can use a single volatile field to create a happens-before edge that covers other, non-volatile writes:

Volatile piggybacking: deliberate, but fragile
class DataBatch {
    private int[] data;     // Not volatile
    private volatile boolean ready = false; // The synchronization sentinel

    void publish(int[] payload) {
        data = payload;  // Write happens BEFORE volatile write
        ready = true;   // Volatile write; also happens-before any
                         // subsequent read of 'ready'.
    }

    int[] consume() {
        if (ready) {     // Volatile read; because of transitivity,
            return data; // the write to 'data' is visible here too.
        }
        return null;
    }
}

This works, and is used intentionally in performance-sensitive code to avoid making all fields volatile. However, it is fragile: the ordering of the writes in publish() is load-bearing. If the compiler reorders data = payload to after ready = true, the happens-before chain breaks. In practice, the JMM prevents this specific reordering (a normal store cannot be moved after a volatile store), but the code’s correctness depends on a guarantee that is not visible in the source and is easy to accidentally break during refactoring.

6. The JMM reordering permission matrix

The most useful practical reference for understanding what is and is not allowed comes from Doug Lea’s JSR-133 Cookbook for Compiler Writers. The key insight is that the JMM’s constraints form a matrix: for any pair of sequential memory operations, there is a rule about whether reordering is permitted. Here are the most important cells:

Relative cost of synchronization mechanisms on modern x86-64

Approximate throughput relative to unsynchronized access; benchmarks vary significantly by workload and JVM version

The chart above illustrates a practical implication of the barrier costs: on x86-64, volatile reads are near-zero cost compared to plain reads (the architecture’s strong memory model does the heavy lifting), while volatile writes and synchronized blocks carry measurable overhead from the full fence and cache-line invalidation. The Initialization-on-Demand Holder idiom and final fields surface as the best-case options for read-heavy patterns because they incur barrier costs only at construction time.

7. What we have learned

The Java Memory Model is a formal specification of permitted behaviors, not a specification of what the JIT will do on any particular platform. The gap between “what the spec allows” and “what developers assume” is exactly where subtle, hard-to-reproduce concurrency bugs live — especially because the x86 JVM tends to be stricter than the spec requires, hiding bugs that would surface immediately on ARM.

Three principles consolidate the key lessons. First, volatile is about visibility and ordering, not atomicity — compound operations on volatile fields are still data races. Second, final fields provide a genuine initialization safety guarantee that is stronger than most developers use: a properly constructed immutable object can be shared without additional synchronization, because the JVM inserts a StoreStore barrier at constructor return. Third, synchronization is only effective when both sides of the access — the write and the read — participate in the same happens-before chain on the same monitor.

The practical hierarchy for shared mutable state, from most to least correct: use immutable objects with final fields where possible; use the Initialization-on-Demand Holder for lazy singletons; use java.util.concurrent.atomic classes for single-variable compound operations; use volatile for single-variable visibility without compound actions; use synchronized blocks when you need compound atomicity and visibility together. Skipping levels in this hierarchy is where the spec allows behavior that production systems cannot afford.

The hardest bugs in Java concurrency are not the ones where you forgot to synchronize. They are the ones where you synchronized — just not in the way that actually establishes a happens-before relationship between the write and the read. The JMM’s formal rules are the only reliable guide.

Eleftheria Drosopoulou

Eleftheria is an Experienced Business Analyst with a robust background in the computer software industry. Proficient in Computer Software Training, Digital Marketing, HTML Scripting, and Microsoft Office, they bring a wealth of technical skills to the table. Additionally, she has a love for writing articles on various tech subjects, showcasing a talent for translating complex concepts into accessible content.
Subscribe
Notify of
guest

This site uses Akismet to reduce spam. Learn how your comment data is processed.

0 Comments
Oldest
Newest Most Voted
Back to top button