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:

  1. synchronized Block/Method: If thread1 executes synchronized(lock) and then performs a write, and thread2 later executes synchronized(lock) on the same lock object and performs a read, the write from thread1 is guaranteed to be visible to thread2. 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 our VisibilityExample.

    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 synchronized block, the write to ready and data in ThreadA happens-before the exit of its synchronized block. The entry into ThreadB’s synchronized block happens-after ThreadA’s exit. This guarantees ThreadB sees the updated ready and data. The fix is to use synchronized (lock) in both threads around the shared variable access. This ensures that the write to ready and data in ThreadA is flushed to main memory and that ThreadB reads the latest values from main memory.

  2. volatile Keyword: For simple flags or single-variable updates, volatile is more efficient than synchronized. A write to a volatile variable happens-before any subsequent read of that same volatile variable. This ensures visibility. Importantly, volatile also 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 volatile keyword on ready guarantees that when ThreadA sets ready = true, this change is immediately visible to ThreadB. However, volatile only guarantees ordering between the volatile write and subsequent volatile reads. It does not guarantee that the non-volatile write data = 123 in ThreadA will be visible to ThreadB before or after ready becomes true. A compiler could still reorder data = 123 to happen logically after ready = true from ThreadB’s viewpoint, or the write to data might not be flushed to main memory by the time ThreadB reads it. To fix this, both ready and data would need to be volatile, or synchronized would be required around both writes and reads.

  3. final Fields: Writes to final fields must happen-before the constructor of the object finishes. Reads of final fields can happen after the constructor finishes. This is a powerful guarantee for immutable objects.

  4. Thread Start/Join: A thread’s start() method happens-before any action in the newly started thread. A thread’s join() method happens-after the thread it’s joining on terminates.

  5. Thread Interruption: A thread interrupting another thread happens-before the interrupted thread detects the interruption.

  6. Thread Destruction: The completion of a thread happens-before any other thread’s detection of the thread’s termination.

  7. Concurrent Collections and Atomic Classes: Libraries like java.util.concurrent and java.util.concurrent.atomic provide thread-safe data structures and atomic variables. These are built using low-level synchronization primitives that adhere to the JMM rules. For example, AtomicInteger uses 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.

Want structured learning?

Take the full Java course →