Guide contents

Lesson 32 of 71

Happens-Before and volatile

This lesson defines the happens-before relation, the rule set the Java Memory Model (JMM) uses to decide which writes a read is allowed to see. After it, a reader can prove that a concurrent program has no data race, choose between volatile and a lock, and explain why volatile does not make count++ safe. The previous lesson, Visibility, Atomicity, and Reordering, showed what goes wrong without these rules.

Idea Short meaning
Program order The order of actions inside one thread, as written in the source
Synchronization order One total order over all synchronization actions (lock, unlock, volatile read and write, start, join)
Happens-before A partial order: if A happens-before B, B sees the effects of A
volatile A field whose reads and writes are synchronization actions
Data-race-free (DRF) Every pair of conflicting accesses is ordered by happens-before
Sequential consistency for DRF A DRF program behaves as if all actions ran in one interleaved order

The rules

Two accesses conflict when they touch the same variable and at least one is a write. A data race is a pair of conflicting accesses not ordered by happens-before. The JMM (JLS chapter 17.4.5) lists the edges that create happens-before:

Rule Edge
Program order Each action in a thread happens-before every later action in the same thread
Monitor lock An unlock of monitor m happens-before every later lock of m
Volatile A write to volatile field v happens-before every later read of v
Thread start t.start() happens-before every action in thread t
Thread join Every action in t happens-before another thread returns from t.join()
Transitivity If A hb B and B hb C, then A hb C

"Later" means later in the synchronization order. The synchronization order is total: every thread agrees on one order of all locks, unlocks, and volatile accesses. Other related edges exist (Thread.interrupt before the interrupted thread detects it, default field values before the first action of any thread, end of a constructor before the finalizer starts), and the java.util.concurrent classes document their own edges, such as "actions before putting an element into a BlockingQueue happen-before actions after taking it".

Happens-before is not about time. An action that runs earlier on the wall clock does not happen-before a later action in another thread unless a chain of the edges above connects them.

Example 1: program order in one thread

A single thread always sees its own writes in source order, even though the compiler and CPU may reorder them internally.

Java
public class ProgramOrder {
    public static void main(String[] args) {
        int a = 1;
        int b = a + 1;
        a = 10;
        System.out.println("a = " + a + ", b = " + b);
    }
}
Output
a = 10, b = 2

The JIT may reorder or remove these assignments, but only in ways the thread itself cannot observe. This is the as-if-serial guarantee. Reordering becomes visible only to another thread that reads the same variables without a happens-before edge.

Example 2: start and join edges

Writes made before start() are visible to the new thread. Writes made by the thread are visible after join() returns.

Java
public class StartJoin {
    static int input;
    static int result;

    public static void main(String[] args) throws InterruptedException {
        input = 21;                        // before start
        Thread worker = new Thread(() -> {
            result = input * 2;            // sees input = 21 (start rule)
        });
        worker.start();
        worker.join();
        System.out.println("result = " + result); // sees 42 (join rule)
    }
}
Output
result = 42

Neither field is volatile and no lock is used. The program is still data-race-free, because the start edge orders input = 21 before the read in the worker, and the join edge orders the write to result before the read in main. This is why the examples in this course print results only after join().

Example 3: a volatile stop flag

The previous lesson showed a loop that never ends because the JIT hoisted a plain field read out of the loop. Declaring the field volatile makes each read a synchronization action, so the loop must re-read it.

Java
public class StopFlag {
    private static volatile boolean running = true;

    public static void main(String[] args) throws InterruptedException {
        Thread worker = new Thread(() -> {
            long spins = 0;
            while (running) {
                spins++;
            }
            System.out.println("worker saw running = false");
        });
        worker.start();
        Thread.sleep(100);
        running = false;                   // volatile write
        worker.join();
        System.out.println("main: worker finished");
    }
}
Output
worker saw running = false
main: worker finished

The volatile write by main happens-before every later volatile read in the worker, so the worker observes false in finite time. The number of spins is not printed because it changes between runs.

Example 4: piggybacking plain data on a volatile write

A volatile write publishes every write that came before it in program order. The reader must read the same volatile field first.

Java
public class Piggyback {
    private static int payload;                 // plain field
    private static volatile boolean ready;      // the guard

    public static void main(String[] args) throws InterruptedException {
        Thread reader = new Thread(() -> {
            while (!ready) {
                Thread.onSpinWait();
            }
            System.out.println("payload = " + payload);
        });
        reader.start();

        payload = 42;   // (1) plain write
        ready = true;   // (2) volatile write
        reader.join();
    }
}
Output
payload = 42

The chain is: (1) hb (2) by program order, (2) hb the reader's read of ready == true by the volatile rule, and that read hb the read of payload by program order. Transitivity gives (1) hb the read of payload, so 0 cannot be printed. Reversing (1) and (2) breaks the chain, and the reader could print 0 or 42. Thread.onSpinWait() is a hint to the CPU that the thread is in a spin loop; it has no memory effect.

Example 5: volatile does not make count++ atomic

count++ is three actions: read, add, write. volatile makes each read and write visible, but two threads can still read the same value and both write back the same result.

Java
public class VolatileCounter {
    private static volatile int count;

    public static void main(String[] args) throws InterruptedException {
        Runnable task = () -> {
            for (int i = 0; i < 100_000; i++) {
                count++;            // read, add, write: not atomic
            }
        };
        Thread t1 = new Thread(task);
        Thread t2 = new Thread(task);
        t1.start(); t2.start();
        t1.join();  t2.join();
        System.out.println("count <= 200000: " + (count <= 200_000));
    }
}
Output
count <= 200000: true

The program prints a check instead of the count because the count has no fixed value. Any value from 2 up to 200,000 is allowed, and on a multi-core machine the result is often below 200,000 and different on each run. Lost updates come from the gap between the read and the write, not from visibility. The program is data-race-free (all accesses are volatile), so it is sequentially consistent, and it is still wrong: freedom from data races does not imply atomicity of compound actions.

Example 6: the monitor rule fixes the counter

A synchronized block makes read-add-write one indivisible step and gives the unlock-to-lock happens-before edge.

Java
public class SyncCounter {
    private int count;

    synchronized void increment() { count++; }
    synchronized int get() { return count; }

    public static void main(String[] args) throws InterruptedException {
        SyncCounter counter = new SyncCounter();
        Thread[] threads = new Thread[4];
        for (int t = 0; t < threads.length; t++) {
            threads[t] = new Thread(() -> {
                for (int i = 0; i < 50_000; i++) counter.increment();
            });
            threads[t].start();
        }
        for (Thread t : threads) t.join();
        System.out.println("count = " + counter.get());
    }
}
Output
count = 200000

Each unlock happens-before the next lock of the same monitor, so each increment sees the value left by the previous one. The field does not need volatile: every access is inside the lock. Lesson Atomic Classes and CAS shows a lock-free version with AtomicInteger.

Need Tool
One writer, many readers of a single value volatile
Read-modify-write on one variable lock or atomic class
Invariant across several variables lock

Example 7: production-style configuration reload

A common pattern publishes an immutable snapshot through one volatile reference. Readers never lock; the reloader replaces the whole object.

Java
import java.util.Map;

public class ConfigReload {
    record Config(int version, Map<String, Integer> limits) {
        Config {
            limits = Map.copyOf(limits);
        }
    }

    static final class ConfigHolder {
        private volatile Config current;

        ConfigHolder(Config initial) { current = initial; }

        Config get() { return current; }               // volatile read

        void reload(Map<String, Integer> limits) {
            Config next = new Config(current.version() + 1, limits);
            current = next;                             // volatile write
        }
    }

    public static void main(String[] args) throws InterruptedException {
        ConfigHolder holder = new ConfigHolder(new Config(1, Map.of("orders", 100)));

        Thread reloader = new Thread(() -> holder.reload(Map.of("orders", 250)));
        reloader.start();
        reloader.join();

        Thread reader = new Thread(() -> {
            Config c = holder.get();
            System.out.println("version " + c.version() + ", orders limit " + c.limits().get("orders"));
        });
        reader.start();
        reader.join();
    }
}
Output
version 2, orders limit 250

The reader takes one reference and reads all fields through it, so it never sees half of version 1 and half of version 2. Transitivity carries the edge across three threads: the reloader's volatile write hb its end, the join hb main starting the reader, and start hb the reader's read. reload itself is a read-modify-write of current; with two concurrent reloaders one update could be lost, so a real system either has one reloader thread or uses AtomicReference.updateAndGet from Atomic Classes and CAS.

Sequential consistency and causality

Sequential consistency means the result equals some single interleaving of all threads' actions, with each thread's actions in program order. The JMM gives it only to data-race-free programs: if every execution of a correctly synchronized program is free of data races, all its executions appear sequentially consistent. This is the "SC for DRF" guarantee, and it is the reason to reason about happens-before rather than about caches and CPUs.

Racy programs get weaker guarantees. The JMM still forbids out-of-thin-air values: a read cannot return a value that no write produced, built from a circular chain of speculative reads and writes. The formal causality rules that express this are complex and known to have flaws; for practical work the rule is to remove every data race.

Interview points

  • Happens-before is a partial order of visibility, not of time; two actions in different threads are unordered unless a chain of edges connects them.
  • The edges to know: program order, unlock to later lock of the same monitor, volatile write to later volatile read, start() to the thread's actions, the thread's actions to join() return, and transitivity.
  • A volatile write publishes all earlier plain writes of the same thread to a thread that reads that volatile and sees the written value.
  • volatile gives visibility and ordering, not atomicity of compound actions such as count++ or check-then-act.
  • A program without data races is sequentially consistent; a program with races may show reorderings, but never out-of-thin-air values.
  • Use volatile for a single published value with one writer, a lock or atomic class for read-modify-write, and a lock for multi-variable invariants.

Summary

  • Conflicting accesses touch the same variable with at least one write; a data race is a conflicting pair not ordered by happens-before.
  • The synchronization order is a total order over locks, unlocks, volatile accesses, start, and join.
  • start() and join() create happens-before edges, so results read after join() are visible without volatile.
  • Publishing an immutable object through a volatile reference gives readers a consistent snapshot without locks.
  • java.util.concurrent classes document their own happens-before guarantees in their Javadoc.