The Java Memory Model (JMM) is designed to let you write multithreaded code without worrying about the specific CPU architecture or compiler optimizations, but it does so by defining a set of rules that are often misunderstood, leading to subtle and hard-to-debug concurrency bugs.
Let’s watch this in action. Imagine two threads, ThreadA and ThreadB, and a shared boolean flag ready:
public class VisibilityExample {
private static boolean ready = false;
private static int data = 0;
public static void main(String[] args) throws InterruptedException {
ThreadA threadA = new ThreadA();
ThreadB threadB = new ThreadB();
threadA.start();
threadB.start();
threadA.join();
threadB.join();
System.out.println("Main thread finished.");
}
static class ThreadA extends Thread {
@Override
public void run() {
System.out.println("ThreadA: Setting ready to true.");
ready = true; // Write to 'ready'
data = 123; // Write to 'data'
System.out.println("ThreadA: Finished.");
}
}
static class ThreadB extends Thread {
@Override
public void run() {
System.out.println("ThreadB: Waiting for ready to be true.");
while (!ready) {
// Busy-wait for 'ready' to become true
}
System.out.println("ThreadB: ready is now true. Data = " + data); // Read 'data'
System.out.println("ThreadB: Finished.");
}
}
}
If you run this code, you might see output like this:
ThreadA: Setting ready to true.
ThreadA: Finished.
ThreadB: Waiting for ready to be true.
ThreadB: ready is now true. Data = 0
ThreadB: Finished.
Main thread finished.
Notice ThreadB printed Data = 0. This is unexpected because ThreadA clearly set data = 123 after setting ready = true. Why didn’t ThreadB see the updated data? This is where visibility and ordering come in.
The problem is that threads don’t necessarily see each other’s writes immediately. Processors can reorder instructions for performance, and caches can hold stale data. The JMM defines rules to control this.
Visibility: When ThreadA writes ready = true, this change might not be immediately visible to ThreadB. If ThreadB’s CPU cache has an old copy of ready (still false), it will keep looping. This is because writes to main memory and cache coherence protocols are complex.
Ordering: The JMM also dictates how operations are ordered. Without explicit synchronization, a processor or compiler might reorder ready = true and data = 123. It could execute data = 123 before ready = true from ThreadB’s perspective, even if ThreadA’s source code shows it in the other order. This is instruction reordering.
The solution to both visibility and ordering problems is synchronization. The JMM guarantees that certain operations are "visible" and "ordered" relative to others. The key mechanism for this is the happens-before relationship.
A happens-before relationship establishes a guarantee that the memory effects (writes) of an action are visible to another action. If action A happens-before action B, then any write performed by A will be visible to any read performed by B.
Here are the core happens-before rules:
-
synchronizedBlock/Method: Ifthread1executessynchronized(lock)and then performs a write, andthread2later executessynchronized(lock)on the same lock object and performs a read, the write fromthread1is guaranteed to be visible tothread2. The exit of a synchronized block/method happens-before the entry of any other synchronized block/method on the same monitor. This is how we fix ourVisibilityExample.public class VisibilityFixed { private static boolean ready = false; private static int data = 0; private static final Object lock = new Object(); // Use a lock object public static void main(String[] args) throws InterruptedException { ThreadA threadA = new ThreadA(); ThreadB threadB = new ThreadB(); threadA.start(); threadB.start(); threadA.join(); threadB.join(); System.out.println("Main thread finished."); } static class ThreadA extends Thread { @Override public void run() { synchronized (lock) { // Synchronized entry System.out.println("ThreadA: Setting ready to true."); ready = true; // Write to 'ready' data = 123; // Write to 'data' System.out.println("ThreadA: Finished."); } // Synchronized exit } } static class ThreadB extends Thread { @Override public void run() { synchronized (lock) { // Synchronized entry System.out.println("ThreadB: Waiting for ready to be true."); while (!ready) { // Busy-wait for 'ready' to become true } System.out.println("ThreadB: ready is now true. Data = " + data); // Read 'data' System.out.println("ThreadB: Finished."); } // Synchronized exit } } }With the
synchronizedblock, the write toreadyanddatainThreadAhappens-before the exit of its synchronized block. The entry intoThreadB’s synchronized block happens-afterThreadA’s exit. This guaranteesThreadBsees the updatedreadyanddata. The fix is to usesynchronized (lock)in both threads around the shared variable access. This ensures that the write toreadyanddatainThreadAis flushed to main memory and thatThreadBreads the latest values from main memory. -
volatileKeyword: For simple flags or single-variable updates,volatileis more efficient thansynchronized. A write to avolatilevariable happens-before any subsequent read of that samevolatilevariable. This ensures visibility. Importantly,volatilealso prevents reordering around the volatile access itself.public class VolatileExample { // volatile ensures visibility and prevents reordering around this access private static volatile boolean ready = false; private static int data = 0; // Still not guaranteed to be seen with updated 'ready' public static void main(String[] args) throws InterruptedException { ThreadA threadA = new ThreadA(); ThreadB threadB = new ThreadB(); threadA.start(); threadB.start(); threadA.join(); threadB.join(); System.out.println("Main thread finished."); } static class ThreadA extends Thread { @Override public void run() { System.out.println("ThreadA: Setting ready to true."); ready = true; // Write to 'ready' (volatile) data = 123; // Write to 'data' (not volatile) System.out.println("ThreadA: Finished."); } } static class ThreadB extends Thread { @Override public void run() { System.out.println("ThreadB: Waiting for ready to be true."); while (!ready) { // Busy-wait for 'ready' to become true } // Because 'ready' is volatile, its write is visible. // BUT, the write to 'data' might still be reordered by the compiler // to happen *after* 'ready' is set, from ThreadB's perspective. // Thus, data might still be 0. System.out.println("ThreadB: ready is now true. Data = " + data); // Read 'data' System.out.println("ThreadB: Finished."); } } }The
volatilekeyword onreadyguarantees that whenThreadAsetsready = true, this change is immediately visible toThreadB. However,volatileonly guarantees ordering between the volatile write and subsequent volatile reads. It does not guarantee that the non-volatile writedata = 123inThreadAwill be visible toThreadBbefore or afterreadybecomes true. A compiler could still reorderdata = 123to happen logically afterready = truefromThreadB’s viewpoint, or the write todatamight not be flushed to main memory by the timeThreadBreads it. To fix this, bothreadyanddatawould need to bevolatile, orsynchronizedwould be required around both writes and reads. -
finalFields: Writes tofinalfields must happen-before the constructor of the object finishes. Reads offinalfields can happen after the constructor finishes. This is a powerful guarantee for immutable objects. -
Thread Start/Join: A thread’s
start()method happens-before any action in the newly started thread. A thread’sjoin()method happens-after the thread it’s joining on terminates. -
Thread Interruption: A thread interrupting another thread happens-before the interrupted thread detects the interruption.
-
Thread Destruction: The completion of a thread happens-before any other thread’s detection of the thread’s termination.
-
Concurrent CollectionsandAtomicClasses: Libraries likejava.util.concurrentandjava.util.concurrent.atomicprovide thread-safe data structures and atomic variables. These are built using low-level synchronization primitives that adhere to the JMM rules. For example,AtomicIntegeruses Compare-And-Swap (CAS) operations, which are atomic and provide memory ordering guarantees.
The crucial insight is that the JMM provides a happens-before guarantee, not a strict sequential execution guarantee. volatile is often misunderstood as simply making a variable "thread-safe"; it’s more about guaranteed visibility and ordering for that specific variable’s access. synchronized provides a much stronger guarantee by establishing a lock-based exclusion and ensuring all writes before the synchronized exit are visible to reads after the corresponding synchronized entry.
The next error you’ll likely hit is related to Deadlock when using synchronized incorrectly.