Java Memory Model and happens-before

Java · Expert · 18 min

Concurrent code that "works on my machine" can fail on another CPU or JIT; the memory model tells you what is actually guaranteed.

Compilers, the JIT and CPUs all reorder and cache memory operations for speed. Within one thread you can't tell; across threads you can. The Java Memory Model (JLS chapter 17) defines which writes another thread is guaranteed to see.

The central idea is happens-before. If action A happens-before action B, then B sees A's effects (and everything before A). Key rules:

  • Program order: each action in a thread happens-before later actions in that thread.
  • Monitor lock: unlocking a monitor happens-before every later lock of the same monitor.
  • volatile: a write to a volatile field happens-before every subsequent read of that field.
  • Thread.start() happens-before the started thread's actions; a thread's actions happen-before another thread returns from join() on it.
  • Transitivity ties them together. java.util.concurrent classes document their own edges (e.g. putting into a BlockingQueue happens-before taking it out).

Two conflicting accesses (at least one a write) not ordered by happens-before form a data race, and the reading thread may see stale values indefinitely. A classic failure: a boolean running flag polled in a loop without volatile — the JIT may hoist the read out of the loop and spin forever.

volatile gives visibility and ordering but not atomicity: volatile int x; x++ is still racy. Use atomics for that.

final fields have a special guarantee: if the object reference doesn't escape during construction, every thread sees the final fields' constructed values without synchronization. That is what makes immutable objects safe to share.

Double-checked locking for lazy singletons works only if the field is volatile; the simpler alternative is the holder-class idiom, which relies on the class-initialization guarantee.

Example

public class Visibility {
    // Without volatile, the worker may never observe running = false.
    private static volatile boolean running = true;

    // Lazy, thread-safe singleton via class initialization (no locks needed)
    static final class Config {
        private Config() { System.out.println("loading config"); }
        private static final class Holder { static final Config INSTANCE = new Config(); }
        static Config get() { return Holder.INSTANCE; }
    }

    public static void main(String[] args) throws InterruptedException {
        Thread worker = Thread.ofPlatform().start(() -> {
            long spins = 0;
            while (running) spins++;           // volatile read each iteration
            System.out.println("stopped after " + (spins > 0 ? "some" : "no") + " spins");
        });
        Thread.sleep(100);
        running = false;                       // volatile write: happens-before the next read
        worker.join();                         // worker's actions happen-before join returns

        System.out.println(Config.get() == Config.get()); // loading config, then true
    }
}

Remember

  • Without a happens-before edge, another thread may see stale or reordered values
  • Edges come from locks, volatile, thread start/join and java.util.concurrent classes
  • volatile gives visibility, not atomicity; final fields make safe immutable objects

Watch out for

  • Polling a non-volatile flag from another thread, which can loop forever after JIT optimisation.
  • Double-checked locking without volatile on the instance field.

Check yourself

  1. Thread A writes `data = 42` and then writes volatile `ready = true`. Thread B reads `ready == true`, then reads `data`. What does B see?
  2. Which is still a data race?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: docs.oracle.com/javase/specs/jls/se21/html/jls-17.html#jls-17.4

Builds on

Unlocks

Nothing builds on this yet.