Threads and the Java Memory Model

What a thread is to the OS and the JVM, why two threads can disagree about a variable, and happens-before as the only rule that matters.

13 min read🧵 Java Concurrency

Concurrency bugs are the ones that pass every test and fail in production at a rate of once a week. They do this because the mental model most people have — "threads take turns running my code" — is wrong in a specific way: threads can see different values for the same variable at the same time, and the language only promises otherwise under conditions you have to create. Those conditions are the Java Memory Model, and the one rule from it that matters is called happens-before. This lesson is that rule, the hardware and compiler behaviour that makes it necessary, the exact guarantee the JMM gives a program that follows it, and the puzzles that show what "allowed" means.

What a thread is

A Thread is an independent path of execution with its own stack and program counter, sharing the heap with every other thread in the process. A platform thread is a thin wrapper over an OS thread, one to one: creating one is a system call, a megabyte of reserved stack, and a scheduler entry, which is why a few thousand is the practical ceiling and why pools exist. A virtual thread (Java 21) is a Java object the JVM schedules onto a small pool of platform threads; it is cheap because it is not an OS thread. Either way, two threads running the same method are two frames, two sets of locals, and one shared set of objects.

java
Thread t = new Thread(() -> process(queue), "worker-1");
t.start();            // schedules it; returns immediately
t.join();             // waits for it to finish

Everything on the heap is shared. Every field of every object is, potentially, read and written by several threads. The question is what they see.

Why two threads can disagree

java
class Flag {
    boolean stop = false;
    void run() { while (!stop) { work(); } }     // thread A
    void halt() { stop = true; }                  // thread B
}

Thread B sets stop. Thread A may never see it and loop forever. Not "may take a while" — never. Three things conspire:

  1. CPU caches. Each core has its own cache; a write sits in a store buffer and a private cache line until the coherence protocol moves it, and another core can keep reading its own copy.
  2. Compiler reordering. The JIT sees a loop reading a field it never writes and hoists the read out of the loop: if (!stop) while (true) work(); is a legal transformation of the code as written.
  3. CPU reordering. Even without the JIT, processors execute loads and stores out of order. x86 is comparatively strict (a store can be reordered after a later load, nothing else); ARM, which most phones and a growing share of servers run, reorders almost anything.

The JVM specification allows all of this, because forbidding it would make every field access as slow as a synchronised one. Instead it defines exactly when a write is guaranteed visible to a read.

core 0 · thread Acore 1 · thread B while (!stop) work();stop = true; L1 cachestop = false (read hoisted) store buffer → L1 cachestop = true (not yet published) main memory · stop = false
Three copies of one variable, none obliged to agree. Without a happens-before edge, thread A's loop is legally infinite.

Happens-before

The Java Memory Model says: if action A happens-before action B, then B sees the effects of A. If neither happens-before the other, and one is a write, they are in a data race, and B may see A's write, or not, or a stale value from any earlier write.

The relationships that establish happens-before:

A happens-before B when…
A and B are in the same thread and A comes first in program order
A is an unlock of a monitor and B is a subsequent lock of the same monitor
A is a write to a volatile field and B is a subsequent read of it
A is Thread.start() and B is anything in the started thread
A is anything in a thread and B is join() on it returning
A is a write before a task is submitted to an executor and B is the task running
A is a put into a concurrent collection and B is a get that observes it
A is the end of a constructor writing final fields and B is any read of those fields through a safely published reference

And it is transitive: if A hb B and B hb C, then A hb C. That transitivity is what makes the rule practical — one synchronisation point carries everything written before it.

java
volatile boolean stop = false;   // now B's write happens-before A's read that sees it

With volatile, the JIT may not hoist the read, and the CPU must publish the write. The loop terminates. More than that: every write B made before stop = true is also visible to A after it reads true, because of transitivity. A volatile flag publishes the state written before it.

Under the hood: what the model actually promises

The JMM (JLS chapter 17) is defined in terms of allowed executions, not hardware. Its one practical theorem is this: a program with no data races behaves as if its threads' operations were interleaved in some single global order — sequential consistency for data-race-free programs. Write every access to shared mutable state under a happens-before edge, and you may reason about your program as if it ran on one core with a fair coin deciding who goes next. Miss one edge, and none of that reasoning holds for the variables involved.

What the edges cost, on hardware:

  • A volatile read is an ordinary load on x86, with the compiler forbidden to reorder or hoist it. On ARM it is a load-acquire instruction. Nearly free.
  • A volatile write is a store followed by a full fence on x86 (lock addl or mfence, tens of cycles, draining the store buffer), a store-release on ARM. This is the expensive half, and it is why a volatile counter written a million times a second is slow and a volatile flag written once is not.
  • A monitor acquire/release is a compare-and-swap plus the same fences, and blocking if contended.

Two more guarantees that are easy to miss. final fields are the exception to "unsynchronised means unsafe": the JMM guarantees that a thread which sees a reference to an object sees the object's final fields correctly initialised, provided the constructor did not leak this. That is what makes immutable objects publishable through a plain field. And Java 9's VarHandle exposes the ordering modes underneath: getOpaque/setOpaque (no reordering of this variable, no fence), getAcquire/setRelease (one-directional fences, cheaper than volatile), and getVolatile/setVolatile. The concurrent collections are written with these; application code almost never needs them, but reading ConcurrentHashMap's source becomes possible once you know they exist.

Walkthrough: the loop the JIT made infinite

Take the Flag class, no volatile, run with -XX:+PrintCompilation. Thread A's run gets hot within milliseconds (a tight loop is the fastest thing to reach C2). Here is what C2 does with it, in prose:

  1. It sees stop read on every iteration and never written inside the loop.
  2. The field is not volatile, so the JMM allows it to assume no other thread changes it during the loop. This is not a bug; it is the licence the specification grants.
  3. It loads stop once into a register before the loop, and compiles the loop as if (!stop) { for (;;) work(); }.
  4. Thread B's write lands in memory a moment later. Nobody ever reads the field again.

The symptom in production: a "stop" or "reload" flag that works on a developer laptop, where the loop never gets hot enough to reach C2 before the flag flips, and hangs in production, where it does. Adding volatile forbids step 3; the loop reloads the field every iteration and terminates.

Atomicity is a separate question

Visibility is "will you see my write". Atomicity is "will my read-modify-write happen as one step". volatile gives the first and not the second:

java
volatile int count;
count++;                 // read, add, write: three steps. Two threads → lost updates.

Two threads read 5, both write 6. volatile made each read fresh and each write visible; it did nothing to stop the interleaving. For compound operations you need a lock or an atomic (AtomicInteger.incrementAndGet), which the following lessons cover.

Reads and writes of long and double are not even atomic on their own without volatile on 32-bit JVMs — a thread could see the high half of one write and the low half of another. Every other primitive and every reference is written atomically, but not visibly.

Safe publication

An object is safely published when the reference to it is handed to another thread through one of the happens-before edges above. An object that is not safely published can be seen partially constructed:

java
static Config config;                      // plain field
// thread A
config = new Config(load());               // may be seen by B with null fields
// thread B
if (config != null) use(config.timeout);   // could read 0 or null

The write of the reference and the writes of the fields inside the constructor can be reordered. Fix: volatile on the field, or make Config immutable with final fields, or publish through a lock or a concurrent structure. Immutable objects with final fields are always safe to publish, which is the deepest reason to prefer them.

Thread lifecycle and interruption

A thread is NEW, then RUNNABLE after start(), then BLOCKED (waiting for a monitor), WAITING/TIMED_WAITING (sleep, join, wait, park), then TERMINATED. There is no way to stop a thread from outside. Thread.stop is removed. What exists is interruption: t.interrupt() sets a flag, and blocking methods (sleep, wait, join, take) throw InterruptedException when they see it. Code that catches that exception must either exit or restore the flag with Thread.currentThread().interrupt(), or the cancellation is lost.

Try it yourself

Which outcomes are allowed?

java
int x = 0, y = 0;            // plain fields
// thread 1            // thread 2
x = 1;                 y = 1;
int r1 = y;            int r2 = x;

Which values of (r1, r2) may the program observe?

Answer

All four: (1,1), (1,0), (0,1) and (0,0). The last looks impossible — each thread wrote before it read — but there is no happens-before between the threads, so each read may see a stale value, and x86 hardware produces (0,0) routinely by buffering both stores. Make x and y volatile and (0,0) becomes impossible: whichever volatile write comes first in the synchronisation order is visible to the other thread's later read. jcstress has this as its first sample, and it observes (0,0) in a fraction of a second.

Is the map safely published?

java
class Registry {
    private Map<String, Handler> handlers;
    void init() { Map<String, Handler> m = new HashMap<>(); m.put("a", new A()); handlers = m; }
    Handler get(String k) { return handlers.get(k); }
}

init() runs once on the main thread at startup; get() runs on request threads. Is it safe?

Answer

Only if something between init() and the first get() establishes happens-before, and in a Spring application something usually does: the container publishes beans through synchronisation before serving requests, and the thread that starts the request threads has Thread.start semantics. Written as shown with nothing else, it is a data race: a request thread could see handlers as null, or see the map with its internal table not yet visible. Make the field final and assign it in the constructor (then the final guarantee applies), or volatile, or use Map.copyOf into a final field. Relying on the framework's incidental edges is the kind of correctness that survives until someone adds a lazy-init flag.

Restore or exit

java
try { queue.take(); } catch (InterruptedException e) { log.warn("interrupted"); }
while (running) { ... }

What is wrong, and what are the two correct shapes?

Answer

The interrupt was consumed: take() cleared the flag when it threw, the catch logged and continued, and the loop keeps running as if nothing happened. The caller who interrupted (an executor shutting down, a timeout) has lost its only cancellation mechanism. Either exit the loop from the catch (return), or restore the flag (Thread.currentThread().interrupt()) so the next blocking call throws again and the loop's owner can check isInterrupted().

Misconceptions

  • "volatile flushes the cache." It is defined in terms of ordering, not caches; on x86 it costs a fence on the write and nothing on the read. What it forbids is the compiler and CPU reordering that would hide the write.
  • "Threads see writes in the order they were made." Without an edge, a thread may see the second write and not the first, or neither. Program order holds within a thread only.
  • "If it worked in the test, the race is not there." A race is a set of allowed outcomes; the test observed one. jcstress exists because the others need millions of trials to appear.
  • "Adding synchronized to the setter is enough." The getter needs the same lock, or the read is still a race. Every access, reads included.
  • "The JVM is a sequential machine with threads on top." It is a weakly ordered machine whose language promises sequential consistency only to programs without data races.

Going deeper

  • JLS chapter 17.4, the memory model, and Jeremy Manson and Brian Goetz, "JSR-133 FAQ": the readable version.
  • Doug Lea, "The JSR-133 Cookbook for Compiler Writers": which fences each construct needs on which CPU.
  • Aleksey Shipilëv, "Java Memory Model Pragmatics" (talk and transcript) and "Close Encounters of the JMM Kind".
  • jcstress, the JDK's concurrency stress harness: run the samples and watch (0,0) appear.
  • java.lang.invoke.VarHandle Javadoc, the "Memory Ordering" section.
Progress is saved on this device and to your account when signed in.