Java path · Many things at once · Topic 10 of 12

Threads and the Java Memory Model

Not started

When several threads share the heap, the question is not only who runs when, but what each thread can see. The Java Memory Model, chapter 17 of the JLS, answers it. It lets compilers and CPUs reorder and cache memory operations freely, and in return gives you a precise rule, happens-before, that says when a write in one thread must be visible to a read in another. If your program has no data races under that rule, it behaves as if everything ran in some simple interleaved order.

Why it matters: Without a happens-before edge, a write in one thread may never be seen by another. The bug won't show in tests and then hangs in production.

  1. 1No guarantee by default

    Compilers and CPUs reorder and cache. Without synchronisation, another thread may see a write late, out of order, or never.

  2. 2Happens-before is the contract

    If one action happens-before another, its effects are visible to it. Unlocking, volatile writes, Thread.start and join create those edges.

  3. 3volatile is visibility, not atomicity

    A volatile field is always read fresh and orders the writes around it, but count++ on it can still lose updates.

  4. 4synchronized does both

    A monitor gives mutual exclusion and makes everything written before the unlock visible to the next thread that locks it.

1

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 anything t does; everything t does happens-before another thread returns from t.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.

2

Check yourself

3 questions. Get them all right to finish this topic.

  1. 1.Without volatile on running, what may happen when main sets running = false?
    static boolean running = true;// worker thread:while (running) { spins++; }// main thread, later:running = false;
  2. 2.Two threads each run count++ 1,000 times on a volatile int count. What can the final value be?
  3. 3.Which of these does not create a happens-before edge between two threads?

0 of 3 right so far. Get all three to finish this topic.