Back to Safe Publication & Memory Model
Q3Question 3 of 5

How has the Java Memory Model evolved?

Key Concepts
  • Pre-Java 5 JMM was broken: volatile did not prevent reordering, double-checked locking was unsafe, and final fields had no visibility guarantee.
  • Java 5 (JSR-133) fixed everything: volatile provides full happens-before, final fields freeze at constructor end, and happens-before rules were formalized.
  • Java 5 also introduced java.util.concurrent with Executors, Locks, Atomics, and ConcurrentHashMap.
  • Java 9 added VarHandle with fine-grained memory access modes: plain, opaque, acquire/release, and volatile, replacing sun.misc.Unsafe.
  • Acquire/release semantics on VarHandle are cheaper than full volatile on ARM and POWER architectures.
  • Java 21 introduced virtual threads (lightweight, millions possible) and removed biased locking.
  • Application developers should stick with volatile and synchronized; VarHandle is for framework and library authors.

Definition

The JMM evolved from a broken pre-Java 5 model (unsafe DCL, unreliable volatile) to JSR-133 in Java 5 (full happens-before), Java 9 (VarHandle with fine-grained access modes), and Java 21 (virtual threads, biased locking removed).
  • Pre-Java 5 – the original JMM was underspecified. volatile prevented caching but did not prevent instruction reordering. Double-checked locking was broken because the JVM could publish an object reference before the constructor completed. final fields had no cross-thread visibility guarantee: another thread could see a final field as null or zero.
  • Java 5 (JSR-133) – completely rewrote the memory model. Volatile now provides a full happens-before edge that prevents reordering. Final fields are frozen at constructor completion and guaranteed visible to any thread that sees the object reference. The happens-before framework was formally defined. java.util.concurrent was introduced.
  • Java 9 (VarHandle) – added java.lang.invoke.VarHandle with four memory access modes: plain (no ordering), opaque (no tearing), acquire/release (one-directional barriers), and volatile (full ordering). This replaced sun.misc.Unsafe for low-level atomic operations.
  • Java 21 – virtual threads (lightweight threads managed by the JVM, blocking is cheap), biased locking removed (the single-thread lock optimization was too complex for diminishing returns on modern hardware).

Before Java 5, National Bank of India had no official rules for sharing information between tellers. Each teller's personal notepad might show different balances even when one teller had updated the shared ledger. Java 5 was the regulatory reform: official rules were published that said, "When you stamp the ledger (volatile write), every other teller reading the ledger (volatile read) must see your updates." Java 9 gave expert tellers finer tools: "I only need other tellers in this region to see my update (acquire/release), not everyone in every branch (full volatile)." Java 21 introduced freelance tellers (virtual threads) who cost almost nothing to hire and can wait on hold without blocking a desk.

Understanding the Concepts

How was the pre-Java 5 memory model broken?

The original JMM, defined in the Java Language Specification first edition, was underspecified in critical ways. The volatile keyword prevented the JVM from caching a variable's value (it forced reads from main memory), but it did not prevent instruction reordering around the volatile access. This made double-checked locking unreliable.

JAVA
// Pre-Java 5: BROKEN double-checked locking
public class BrokenSingleton {
    private static volatile BrokenSingleton instance;  // volatile in pre-5

    public static BrokenSingleton getInstance() {
        if (instance == null) {
            synchronized (BrokenSingleton.class) {
                if (instance == null) {
                    instance = new BrokenSingleton();
                    // Pre-Java 5: CPU could reorder to:
                    // 1. Allocate memory
                    // 2. Assign reference to instance (non-null now!)
                    // 3. Run constructor (fields still uninitialized!)
                    // Another thread sees non-null instance but null fields!
                }
            }
        }
        return instance;
    }
}

Final fields also had no visibility guarantee. A final String name = "Rajesh" field could appear as null to another thread that obtained a reference to the object, because the JVM could reorder the publication of the reference ahead of the final field write.

What did Java 5 (JSR-133) fix?

JSR-133 rewrote the memory model completely. The key fixes were:

  • Volatile now provides a full happens-before edge. A volatile write inserts STORE-STORE and STORE-LOAD barriers that prevent any prior writes from being reordered past the volatile write. This made double-checked locking safe with volatile.
  • Final fields are frozen at the end of the constructor. Any thread that sees a properly published reference to the object is guaranteed to see the fully initialized final fields.
  • The happens-before framework was formally defined with all seven rules.
  • java.util.concurrent was added: Executors, ReentrantLock, AtomicInteger, ConcurrentHashMap, CountDownLatch, Semaphore, and more.
JAVA
package com.bank.service;

// Java 5+: SAFE double-checked locking with volatile
public class ConnectionPoolSingleton {
    private static volatile ConnectionPoolSingleton instance;
    private final String poolName;
    private final int maxConnections;

    private ConnectionPoolSingleton() {
        this.poolName = "NBI-HikariPool";
        this.maxConnections = 20;
    }

    public static ConnectionPoolSingleton getInstance() {
        if (instance == null) {
            synchronized (ConnectionPoolSingleton.class) {
                if (instance == null) {
                    instance = new ConnectionPoolSingleton();
                    // Java 5+: volatile write PREVENTS reordering
                    // Constructor completes BEFORE reference is published
                }
            }
        }
        return instance;  // volatile read: guaranteed fully constructed
    }
}

What did Java 9 add with VarHandle?

Java 9 introduced java.lang.invoke.VarHandle to provide fine-grained memory access modes, replacing the internal sun.misc.Unsafe class for low-level operations. VarHandle offers four access modes with increasing ordering strength.

JAVA
package com.bank.concurrency;

import java.lang.invoke.MethodHandles;
import java.lang.invoke.VarHandle;

public class VarHandleDemo {
    private int counter;

    private static final VarHandle COUNTER;
    static {
        try {
            COUNTER = MethodHandles.lookup()
                    .findVarHandle(VarHandleDemo.class, "counter", int.class);
        } catch (ReflectiveOperationException e) {
            throw new ExceptionInInitializerError(e);
        }
    }

    public void demonstrateModes() {
        // Plain: no ordering, no atomicity guarantee for long/double
        COUNTER.set(this, 1);
        int plain = (int) COUNTER.get(this);

        // Opaque: no tearing (atomic for long/double), but no ordering
        COUNTER.setOpaque(this, 2);
        int opaque = (int) COUNTER.getOpaque(this);

        // Acquire/Release: one-directional barrier (cheaper than volatile)
        COUNTER.setRelease(this, 3);    // all prior writes visible to acquire reader
        int acquired = (int) COUNTER.getAcquire(this);  // sees release writes

        // Volatile: full happens-before (strongest, most expensive)
        COUNTER.setVolatile(this, 4);
        int vol = (int) COUNTER.getVolatile(this);

        // CAS with volatile semantics
        boolean swapped = COUNTER.compareAndSet(this, 4, 5);
    }
}

| Access Mode | Atomicity | Ordering | Cost on x86 | Cost on ARM |
|---|---|---|---|---|
| Plain | No (long/double may tear) | None | Free | Free |
| Opaque | Yes | None | Free | Free |
| Acquire/Release | Yes | One-directional | Free | Barrier |
| Volatile | Yes | Full bidirectional | MFENCE | Full barrier |

On x86 (Intel/AMD), acquire/release is free because x86 has a strong memory model. On ARM and POWER (used in some cloud servers), acquire/release saves the cost of a full barrier, which can be significant in tight loops.

What changed in Java 21?

Java 21 introduced virtual threads and removed biased locking.

Virtual threads are lightweight threads managed by the JVM. A virtual thread uses only about 1 KB of stack (compared to 1 MB for a platform thread). When a virtual thread blocks on IO, the JVM unmounts it from its carrier (platform) thread, freeing the carrier for other virtual threads. This means blocking is cheap and you can create millions of virtual threads.

JAVA
package com.bank.service;

import java.util.concurrent.Executors;

public class VirtualThreadDemo {
    public static void main(String[] args) throws Exception {
        try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
            for (int i = 0; i < 100_000; i++) {
                final int accountId = i;
                executor.submit(() -> {
                    // Each virtual thread blocks on DB call
                    // JVM unmounts it from carrier, carrier serves others
                    String balance = queryBalance("ACCT-" + accountId);
                    return balance;
                });
            }
        }  // waits for all tasks to complete
    }

    static String queryBalance(String accountId) {
        try { Thread.sleep(100); } catch (InterruptedException e) { }
        return "50000.00";
    }
}

Biased locking was removed because the optimization (skipping CAS for uncontended single-thread locks) had diminishing returns on modern hardware and added significant complexity to the JVM's lock implementation.

What should a senior engineer know?

Application developers should use volatile and synchronized for almost all concurrency needs. VarHandle is intended for framework and library authors who need to squeeze out every nanosecond in lock-free algorithms. The acquire/release mode is the sweet spot for advanced lock-free code: it provides the ordering guarantees needed by most algorithms at lower cost than volatile on ARM architectures.

What are common interview follow-up questions?

  • Is double-checked locking safe without volatile in Java 5+? No. The JSR-133 fix requires volatile on the instance field. Without it, the old reordering problem remains.
  • Can VarHandle replace AtomicInteger? Technically yes, and in fact AtomicInteger uses VarHandle internally since Java 9. But AtomicInteger is a cleaner API for application code.
  • Do virtual threads replace thread pools? For IO-bound work, yes. For CPU-bound work, you still want a bounded thread pool sized to the number of cores.

Visual Overview

DIAGRAM
  Java Memory Model Evolution
  ═══════════════════════════

  Pre-Java 5 (broken)         Java 5 (JSR-133)          Java 9+
  ───────────────────         ────────────────          ──────
  volatile: no reorder fix    volatile: full HB         VarHandle modes:
  final: no visibility        final: frozen at ctor     plain │ opaque
  DCL: BROKEN                 DCL: SAFE with volatile   acquire│release
  No j.u.c                    j.u.c added               volatile
                                                        │
  Java 21                                               ▼
  ────────                                         Fine-grained
  Virtual threads (1KB)                            memory ordering
  Biased locking removed                           for frameworks

Key Takeaway

The JMM evolved from a broken pre-Java 5 model where volatile did not prevent reordering, double-checked locking was unsafe, and final fields had no visibility guarantee, to the robust JSR-133 specification in Java 5 that introduced full happens-before semantics for volatile, final field freeze at constructor end, and the formal happens-before framework. Java 5 also brought java.util.concurrent with Executors, Locks, Atomics, and ConcurrentHashMap.

Java 9 added VarHandle with four memory access modes (plain, opaque, acquire/release, volatile), giving framework authors fine-grained control over memory ordering. Acquire/release is cheaper than full volatile on ARM and POWER architectures, which matters for lock-free algorithms in high-performance systems. Java 21 introduced virtual threads that make blocking IO cheap (JVM unmounts virtual thread from carrier) and removed biased locking.

At National Bank of India, upgrading from Java 1.4 to Java 5 eliminated a once-per-month NullPointerException caused by broken DCL in the connection pool singleton. The volatile keyword on the instance field was all it took. Java 21 virtual threads later allowed the payment gateway to handle 100,000 concurrent API requests on the same hardware.

Remember one sentence: the JMM went from broken (pre-5) to fixed (JSR-133) to fine-grained (VarHandle) to lightweight (virtual threads), and for application code volatile plus synchronized is still the right choice.

---

Share
Use ← → arrow keys to navigate