Definition
- Pre-Java 5 – the original JMM was underspecified.
volatileprevented caching but did not prevent instruction reordering. Double-checked locking was broken because the JVM could publish an object reference before the constructor completed.finalfields 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.concurrentwas introduced. - Java 9 (VarHandle) – added
java.lang.invoke.VarHandlewith four memory access modes: plain (no ordering), opaque (no tearing), acquire/release (one-directional barriers), and volatile (full ordering). This replacedsun.misc.Unsafefor 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.
// 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.concurrentwas added:Executors,ReentrantLock,AtomicInteger,ConcurrentHashMap,CountDownLatch,Semaphore, and more.
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.
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.
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
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.
---