What is the happens-before relationship?
A guarantee, not a statement about time: if A happens-before B, everything A wrote is visible to B. Without an edge between two threads there is no guarantee at all, whatever the clock says. The practical payoff is the opposite of what people expect — most fields crossing an existing edge need no volatile.
The Answer
Say this in the room. 45 seconds.
- happens-before is a guarantee about visibility, not a statement about time. "Earlier on the clock" means nothing across threads.
- If A happens-before B, then everything written before A is visible to B — not just the field you were thinking about.
- With no edge between a write in one thread and a read in another, the read is not guaranteed to see the write. Ever. Not "usually sees it late".
- The edges you actually use: program order within a thread, monitor release/acquire, volatile write/read,
Thread.start(),Thread.join(), latches and futures, and anyjava.util.concurrenthandoff. - It is transitive: A → B and B → C gives you A → C. Almost all real reasoning is chaining edges.
- The practical payoff is the reverse of the instinct: if a value crosses an edge, a plain field is fine. Most
volatilein real codebases is unnecessary, and the ones that matter are missing.
Understand It
It is a guarantee, not a clock
"Happens-before" is the worst-named concept in Java. It says nothing about which line executes first in wall-clock time. It is a relation between two actions that, when it holds, obliges the JVM to make the first one's writes visible to the second.
Turn it around, because this is the half that matters: where the relation does not hold, nothing is guaranteed. A thread can write a field and another thread can read that field a full second later and legally see the old value — because without an edge, the compiler may keep the value in a register, the CPU may not flush the store buffer, and neither is required to do anything about it.
This is why "I added a sleep and it worked" is not a fix. Time is not an edge.
The edges
| Edge | Reads as |
|---|---|
| Program order | Within one thread, earlier statements happen-before later ones |
| Monitor | Releasing a lock happens-before any later acquire of the same lock |
| Volatile | A write to a volatile field happens-before every later read of it |
Thread.start() | Everything before start() happens-before everything in the new thread |
Thread.join() | Everything in the thread happens-before join() returning |
final field | A final field set in a constructor is visible, fully built, to any thread that sees the object |
| Latches, futures, queues | countDown() before await() returns; the task before Future.get() returns; put before the matching take |
| Interruption | The interrupt call happens-before the thread detects it |
That is the whole practical list. Everything else is these chained together.
Two edges you already use without knowing
Thread.start() and Thread.join() are edges, which means plain fields cross them safely. No volatile, no lock, nothing:
Config config = new Config();
config.timeoutMillis = 250;
config.endpoint = "https://api.internal/v2";
Thread worker = new Thread(() -> {
System.out.println(" worker sees timeout : " + config.timeoutMillis);
System.out.println(" worker sees endpoint : " + config.endpoint);
config.result = "ok";
});
worker.start();
worker.join();
System.out.println(" main sees result : " + config.result); worker sees timeout : 250
worker sees endpoint : https://api.internal/v2
main sees result : okEvery field there is a plain field. The setup is visible to the worker because it happened before start(). The worker's write is visible to main because it happened before join() returned. Adding volatile to any of them would change nothing except the reader's confidence in whoever wrote it.
Transitivity is where the work happens
The rule that makes this usable: if A happens-before B and B happens-before C, then A happens-before C. So a single edge can carry an unlimited amount of unrelated state across with it.
Here the producer writes two plain fields, then counts down a latch. The consumer awaits the latch, then reads them:
Report report = new Report();
CountDownLatch ready = new CountDownLatch(1);
Thread producer = thread(() -> {
report.rows = List.of("alpha", "beta", "gamma");
report.total = 3;
ready.countDown();
});
Thread consumer = thread(() -> {
ready.await();
System.out.println(" consumer sees rows : " + report.rows);
System.out.println(" consumer sees total : " + report.total);
});
producer.join();
consumer.join(); consumer sees rows : [alpha, beta, gamma]
consumer sees total : 3Chain it: the two writes happen-before countDown() by program order; countDown() happens-before await() returning; await() returning happens-before the reads by program order. So the writes happen-before the reads.
The latch is not protecting rows and total. It does not know they exist. It provides one edge, and transitivity carries everything written before it.
The same is true of every executor handoff
submit and Future.get() are an edge in each direction, which is why a pool task can write plain fields and the submitting thread can read them:
ExecutorService pool = Executors.newFixedThreadPool(2);
Batch batch = new Batch();
Future<Integer> f = pool.submit(() -> {
batch.processed = 500;
return batch.processed;
});
System.out.println(" future returned : " + f.get());
System.out.println(" main sees field : " + batch.processed);
pool.shutdown();
System.out.println(" pool terminated : " + pool.awaitTermination(5, TimeUnit.SECONDS)); future returned : 500
main sees field : 500
pool terminated : trueThis generalises: anything in java.util.concurrent that hands work or data between threads gives you an edge. A BlockingQueue put happens-before the matching take. That is why a producer can build an object, put it on a queue, and the consumer sees it fully built with no synchronization of its own.
Immutability is the edge you get for free
A final field assigned in a constructor is guaranteed visible, fully initialised, to any thread that later sees the reference — with no volatile, no lock, no edge of your own. That is the freeze action at the end of the constructor, and it is the single most useful guarantee in the memory model.
It is also why the fix to almost every publication bug is the same shape: don't mutate shared state, replace it. Build a complete immutable object, publish the reference with one write, and readers get all of it or none of it.
The one condition people miss: the guarantee covers the object as the constructor left it. If the constructor leaks this — registers a listener, starts a thread, passes itself to something — another thread can reach the object before the freeze, and the guarantee is gone.
What this page cannot show you
Every block above proves an edge working. None of them show an edge missing, and that is deliberate: a missing edge produces a bug that depends on the JIT's decisions, the core count, and the CPU's memory model. On x86 it may never appear. On ARM it may appear immediately — which is why a service that ran for years on Intel started failing when it moved to Graviton.
So there is no honest way to print the failure here. Which is the point of the entry: this is the one area of Java where running the code is not how you establish correctness. You establish it by naming the edge. If you cannot name one, there isn't one.
The real tool for the other direction is jcstress, which runs billions of iterations across specially generated interleavings to expose exactly these bugs. It exists because ordinary testing cannot find them.
Interviewer's Next Move
The question after the question. This is what the round is actually testing.
1. "What is happens-before?" A visibility guarantee between two actions: if A happens-before B, everything written before A is visible to B. It says nothing about wall-clock time, and where the relation is absent there is no guarantee at all.
2. "Name the edges you actually use."
Program order within a thread; releasing and acquiring the same monitor; a volatile write and its later reads; Thread.start() and Thread.join(); final fields at the end of a constructor; and every java.util.concurrent handoff — latches, futures, blocking queues.
3. "Why does transitivity matter?" Because one edge carries everything written before it. A latch does not know which fields you wrote; chaining program order into the latch edge and back out is what makes plain fields safe across it.
4. "Do I need volatile on a field written before Thread.start()?"
No. start() is an edge, so everything before it is visible in the new thread. Most volatile in real code is like this — unnecessary where it appears and missing where it matters.
5. "What does final guarantee?"
That a field assigned in the constructor is visible, fully initialised, to any thread that sees the reference, with no synchronization. The exception is a constructor that leaks this, which lets another thread reach the object before the freeze.
6. "I added a sleep and the bug went away." Nothing was fixed. Time is not a happens-before edge. The sleep changed the timing enough to hide it on this machine, and it will return on hardware with a weaker memory model.
7. "How would you prove code is free of a visibility bug?" Not by running it. Name the edge between the write and the read; if you cannot, there isn't one. jcstress exists for the empirical direction precisely because ordinary tests cannot reach these interleavings.
8. "How do you publish a configuration object safely?" Make it immutable with final fields, build the replacement completely, and assign it to a volatile reference in one write. Readers take a single read into a local and use that — two reads can straddle a reload.
Code traps
Trap A — predict before you run:
class Worker implements Runnable {
private boolean done = false;
public void run() {
while (!done) { process(); }
}
public void finish() { done = true; }
}
Answer
finish() may never be observed and the loop may never exit. There is no edge between the writing thread and the reading thread, so the JIT is entitled to hoist the read of done out of the loop entirely — it can prove nothing in that loop changes it.
This is the textbook case where volatile is the correct and complete fix. It is also the exact bug a Thread.sleep inside the loop appears to fix, by making the hoist less likely without making it illegal.
Trap B:
class Session {
private final String user;
Session(String user, Registry registry) {
registry.register(this); // before the field is assigned
this.user = user;
}
}
Answer
The constructor leaks this before the freeze, so another thread reaching the session through the registry can observe user as null — despite it being final. The final-field guarantee covers the object as the constructor finished it, and this object escaped before that.
Register after construction, from a factory method, so the reference only becomes reachable once the object is complete.
Trap C:
private volatile Settings settings;
public String describe() {
return settings.endpoint + " (" + settings.timeoutMillis + "ms)";
}
Answer
Two separate reads of a volatile field. A reload between them returns one version's endpoint with another version's timeout — a mixed configuration, even though the field is volatile and even though Settings is immutable.
volatile makes each read see a valid object; it does not make two reads see the same object. Read once into a local and use that. This is why the local variable in a double-checked-locking implementation is not a micro-optimisation.
Common wrong answers
| Said in interviews | Reality |
|---|---|
| "It means A runs before B in time." | It is a visibility guarantee. Clock order across threads implies nothing. |
| "Without it the read gets a stale value briefly." | Without it there is no guarantee at all — the read may never see the write. |
| "You need volatile on anything shared." | Not if it crosses an existing edge. start(), join(), latches and futures all carry plain fields. |
| "A sleep gives other threads time to see it." | Time is not an edge. It hides the bug on this machine. |
| "final only stops reassignment." | It also gives a publication guarantee — unless the constructor leaks this. |
| "I load-tested it, so it's correct." | Visibility bugs depend on JIT, cores and CPU memory model. A green run is not an argument. |
Check Yourself
Q1. A field is written by main before thread.start() and read inside the thread. Does it need volatile?
Answer
No. Thread.start() is a happens-before edge, so everything written before it is visible in the new thread. Adding volatile changes nothing except suggesting to the next reader that the author was unsure.
Q2. A producer writes five plain fields, then calls latch.countDown(). A consumer returns from await() and reads all five. Are they visible, and why?
Answer
Yes, by transitivity. The writes happen-before the countDown by program order, the countDown happens-before await returning, and that happens-before the reads by program order. One edge carries all five fields, none of which the latch knows about.
Q3. Settings is immutable and its reference is volatile. A method reads settings.endpoint and settings.timeoutMillis on separate lines. What can go wrong?
Answer
Two reads of the reference can straddle a reload, returning fields from two different Settings objects. Immutability and volatile both hold; neither makes two reads atomic. Read the reference once into a local and use that.
Practice
| Tier | Exercise | Time |
|---|---|---|
| Warm-up | Name the edge | 10 min |
| Challenge | Delete every unnecessary volatile | 25 min |
| Production | The config that was read half-updated | 45 min |
| Interview | Full round replay | 10 min |
What changed, and when
Version boundaries are where follow-ups live. Know the change and the behaviour it replaced.
- Java 5 (1.5)
JSR-133 replaced the memory model with one that actually holds: volatile gained ordering as well as visibility, and a final field set in a constructor became guaranteed visible, fully built, to any thread that sees the object.
Before Java 5 (1.5): The old model made safe publication impossible to express. Double-checked locking was broken however it was written, and a thread could observe a final field before its constructor had assigned it.
Practice ladder
Reading this page is not knowing it. Four tiers, ending in a real incident.
- Warm-up10 min
Name the edge
One concept, guided. Near-impossible to fail.
- Challenge25 min
Delete every unnecessary volatile
Edge cases. You have to reason, and two valid fixes differ.
- Production incident45 min
The config that was read half-updated
A real incident: symptom first, cause hidden, tradeoff at the end.
- Interview replay10 min
Full round replay — the memory model
Timed verbal replay with pass/fail criteria per follow-up.
Where this question goes next
- double checked locking — not written yet
- How does ConcurrentHashMap achieve thread safety?
- atomic vs lock — not written yet
- executor service — not written yet
Questions that lead here
How does ConcurrentHashMap achieve thread safety?
Since Java 8 there are no segments. It locks one bin at a time — a CAS to install the first node in an empty bin, and synchronized on the bin's head node otherwise — while reads never lock at all. The catch is that per-key atomicity does not make your sequence of calls atomic.
Asked constantlyintermediate2–12 yrs12 min readConcurrencyWhat does volatile guarantee, and what does it not?
volatile guarantees visibility and ordering: a write is seen by any later read, and operations are not reordered across it. It never guarantees atomicity, so volatile count++ still loses updates — it is three operations, and volatile makes each of them visible without making the trio indivisible.
Asked constantlyintermediate1–12 yrs12 min readConcurrency
Every runnable example above was compiled and executed against openjdk 21.0.11 on this build, and its output diffed against what this page claims. Last updated 2026-08-28.