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-writes phase1Published = true
  • Thread-2 (Relay): reads phase1Published (volatile), sees counter = 42, computes result = 52, then volatile-writes phase2Published = true
  • Thread-3 (Subscriber): reads phase2Published (volatile), reads result — and sees 52
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.