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.
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);
}
}a = 10, b = 2The 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.
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)
}
}result = 42Neither 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.
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");
}
}worker saw running = false
main: worker finishedThe 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.
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();
}
}payload = 42The 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.
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));
}
}count <= 200000: trueThe 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.
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());
}
}count = 200000Each 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.
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();
}
}version 2, orders limit 250The 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 tojoin()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.
volatilegives visibility and ordering, not atomicity of compound actions such ascount++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
volatilefor 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()andjoin()create happens-before edges, so results read afterjoin()are visible withoutvolatile.- Publishing an immutable object through a volatile reference gives readers a consistent snapshot without locks.
java.util.concurrentclasses document their own happens-before guarantees in their Javadoc.
