Transitive Happens-Before Chains in Java
Part 1 of 4 in Mastering Modern Java Jvm
The problem
Two threads can share data safely if one thread writes a volatile variable and another thread reads it — the Java Memory Model guarantees that all writes before the volatile write are visible to the reader. That’s the basic synchronizes-with rule.
But what happens when a third thread needs to see the result of work that originated in a different pair of threads? If Thread 1 publishes data via a volatile flag to Thread 2, and Thread 2 processes it and publishes its own result via another volatile flag to Thread 3 — does Thread 3 see Thread 1’s writes too?
The answer is yes, but only because the Java Memory Model defines happens-before as a transitive relation. That’s what this post demonstrates.
The three-thread chain
Here’s a self-contained example with exactly three threads:
- Thread-1 (Publisher): writes
sharedCounter = 42, then volatile-writesphase1Published = true - Thread-2 (Relay): reads
phase1Published(volatile), seescounter = 42, computesresult = 52, then volatile-writesphase2Published = true - Thread-3 (Subscriber): reads
phase2Published(volatile), readsresult— and sees52
import java.util.concurrent.CountDownLatch;
class ProgramOrderTransitiveHappensBefore {
static class ThreePhaseChain {
// Phase 1: between Thread-1 and Thread-2
int sharedCounter = 0;
volatile boolean phase1Published = false;
// Phase 2: between Thread-2 and Thread-3
volatile boolean phase2Published = false;
int computedResult = 0;
void thread1Work() {
sharedCounter = 42; // [A]
phase1Published = true; // [B] volatile write
System.out.println("[T1] Wrote counter=42, set phase1Published=true");
}
void thread2Work(CountDownLatch ready) throws InterruptedException {
ready.await(); // wait for all threads to start together
while (!phase1Published) {} // [C] volatile read
int temp = sharedCounter; // [D] sees 42!
System.out.println("[T2] Read counter=" + temp);
computedResult = temp + 10; // [E]
phase2Published = true; // [F] volatile write
System.out.println("[T2] Set result=" + computedResult);
}
String thread3Work(CountDownLatch ready) throws InterruptedException {
ready.await();
while (!phase2Published) {} // [G] volatile read
System.out.println("[T3] Read result=" + computedResult);
return String.format("result=%d", computedResult);
}
}
public static void main(String[] args) throws InterruptedException {
ThreePhaseChain chain = new ThreePhaseChain();
CountDownLatch ready = new CountDownLatch(1);
Thread t1 = new Thread(chain::thread1Work, "T1-Publisher");
Thread t2 = new Thread(() -> {
try { chain.thread2Work(ready); } catch (InterruptedException e) {}
}, "T2-Relay");
Thread t3 = new Thread(() -> {
try { System.out.println(chain.thread3Work(ready)); } catch (InterruptedException e) {}
}, "T3-Subscriber");
t1.start(); t2.start(); t3.start();
ready.countDown(); // release all at once
t1.join(); t2.join(); t3.join();
}
}
Three rules combine here, and understanding their interaction is the whole point:
Rule 1 — Program Order. Within each thread, actions happen-before later actions in that same thread. So in T1, [A] HB [B]. In T2, [C] HB [D] HB [E] HB [F]. In T3, [G] is the last action before reading result.
Rule 2 — Synchronizes-With. A volatile write synchronizes-with a subsequent volatile read of the same variable. So [B] (T1’s volatile write to phase1Published) synchronizes-with [C] (T2’s subsequent volatile read). And [F] (T2’s volatile write) synchronizes-with [G] (T3’s subsequent volatile read).
Rule 3 — Transitivity. If A HB B and B HB C, then A HB C. This is the rule that makes the whole chain work.
Running it
The output shows result=52 every time, which tells us that T3’s read of computedResult sees the value written by T2 — and T2 saw sharedCounter=42 from T1. No thread pair that isn’t directly linked by a volatile publication needs to worry: the HB chain carries everything.
Here is exactly how each action depends on the previous:
T1: [A] sharedCounter = 42 (program-order)
[B] phase1Published = true (volatile write -- synchronizes-with [C])
T2: [C] read phase1Published (sees true; HB from [B] covers all of T1's writes before [B])
[D] temp = sharedCounter (=42) (program-order after [C]; sees 42 via transitivity)
[E] computedResult = 52 (program-order after [D])
[F] phase2Published = true (volatile write -- synchronizes-with [G])
T3: [G] read phase2Published (sees true; HB from [F] covers all of T2's writes before [F], which transitively covers T1's)
reads computedResult = 52 (all visible via the chain)
The crucial insight is what transitivity does for us. Without it, we’d only get visibility between directly linked thread pairs — T1→T2 and T2→T3 — but information from T1 would stop at T2. Transitivity means the HB relation is a reachability property: if there’s any chain of program-order edges and synchronizes-with edges from A to B, then everything A wrote is visible at B.
This isn’t a theoretical curiosity. Any multi-stage pipeline that uses volatile flags as publication points — producer→processor→consumer patterns, layered configuration reloads, or even ExecutorService’s internal task submission chain — relies on this transitivity working correctly. The compiler and the JVM are allowed to reorder, cache, and optimize aggressively within each thread’s HB scope, but they must never break a chain edge.
Takeaway
A volatile write-then-read creates one edge in a happens-before graph; program order fills the internal edges within each thread; and transitivity makes the whole graph reachable — so Thread 1’s writes are guaranteed visible to Thread 3 even though no single volatile variable links them directly.