How it works
The explanation, with examples you can run.
Why visibility isn't automatic
public class StopFlag { private static volatile boolean running = true; // try removing volatile public static void main(String[] args) throws InterruptedException { Thread worker = new Thread(() -> { long spins = 0; while (running) spins++; System.out.println("worker saw the change"); }); worker.start(); Thread.sleep(100); running = false; // volatile write: visible to the worker's next read worker.join(); }}With volatile this stops at once. Without it, on HotSpot 21 it typically runs forever: once the loop is compiled, the JIT sees no reason to read running again and hoists the read out of the loop. That transformation is legal, because nothing told it another thread was involved. Other JVMs or CPUs may fail differently, or not at all, which is what makes these bugs so hard to find in testing.
Happens-before
The JLS defines a partial order. The edges you use every day:
- Program order: within one thread, each action happens-before the next one.
- Monitors: unlocking a monitor happens-before every later lock of the same monitor.
- volatile: a write to a volatile field happens-before every later read of it.
- Threads:
t.start()happens-before anythingtdoes; everythingtdoes happens-before another thread returns fromt.join(). - Transitivity: if A happens-before B and B happens-before C, then A happens-before C.
Transitivity is what makes it useful. Fill in an object with ordinary writes, then publish it through a volatile field or under a lock, and a thread that reads that field sees the whole object. The classes in java.util.concurrent document their own edges: putting an item into a BlockingQueue happens-before taking it out, and completing a Future happens-before get() returns.
A data race is two accesses to the same variable from different threads, at least one a write, with no happens-before order between them. A program with races gets very weak guarantees; for example, a plain long or double write may even be split into two 32-bit halves.
volatile, synchronized, atomics
- volatile gives visibility and ordering for one field. It doesn't make compound actions atomic:
count++is a read, an add and a write. - synchronized gives mutual exclusion and visibility. Monitors are reentrant, so a thread can lock what it already holds.
- Atomics (
AtomicInteger,AtomicReference,LongAdder) give lock-free atomic updates with volatile-like visibility:
private final AtomicLong hits = new AtomicLong();void record() { hits.incrementAndGet(); } // atomic and visible private final Object lock = new Object();private final Map<String, Integer> totals = new HashMap<>();void add(String key, int n) { synchronized (lock) { totals.merge(key, n, Integer::sum); } // several steps, one at a time}final fields
Fields marked final get a special guarantee: once the constructor finishes, any thread that obtains a reference to the object sees their values set by the constructor, even if the reference was shared through a race. Immutable objects built on final fields (records, for example) are safe to share. The guarantee only holds if the constructor doesn't leak this before it finishes.
Check yourself
3 questions. Get them all right to finish this topic.
0 of 3 right so far. Get all three to finish this topic.