Visibility: the change one thread never sees
Here is a program that should stop, and often does not.
public class StopFlag {
private static boolean running = true;
public static void main(String[] args) throws InterruptedException {
Thread worker = new Thread(() -> {
long count = 0;
while (running) {
count++;
}
System.out.println("stopped after " + count);
});
worker.start();
Thread.sleep(100);
running = false; // ask it to stop
worker.join(); // wait for it
System.out.println("done");
}
}
The main thread sets running = false. The worker loops on running. It
should stop.
Run it with a JIT-warmed loop and it may run forever. Not for a while — forever. And there is no race condition here in the usual sense: only one thread writes, and it writes once.
Why
running is a field in memory, but a thread does not have to read memory.
The JVM and the CPU are both allowed to keep a copy in a register or a cache
line. The worker's loop reads running millions of times a second and nothing
in the loop suggests it could change, so the JIT compiler is entitled to hoist
the read out of the loop entirely:
// what you wrote
while (running) { count++; }
// what the JIT is allowed to produce
if (running) {
while (true) { count++; }
}
That is a legal transformation. The Java Memory Model makes no promise that one thread's write is ever seen by another unless the two are connected by something that establishes ordering.
This is the part people miss: a data race is not only about two writers. It is about any unsynchronised access where at least one side writes. One writer and one reader is a data race, and the reader is allowed never to notice.
The fix
private static volatile boolean running = true;
One word. volatile says:
- every read comes from memory, not a cached copy,
- every write goes to memory immediately,
- the compiler may not hoist, reorder or eliminate those accesses.
The loop now terminates, reliably, on every JVM.
What volatile does and does not do
Does: guarantee visibility. A write is seen by any subsequent read on another thread.
Does: prevent reordering around it. Everything written before a volatile write is visible to a thread that reads that volatile afterwards — which is sometimes used deliberately to publish a whole object safely.
Does not: make anything atomic.
private volatile int count = 0;
void increment() {
count++; // still broken
}
count++ is still read, add, write. volatile makes each of those three steps
see fresh memory; it does not make the three of them one step. Two threads can
still read the same value and both write the same result.
volatile fixes visibility. It does not fix compound actions. Anything of
the form check-then-act or read-modify-write needs a lock or an atomic.
When volatile is the right tool
It is right for exactly one shape: a flag or a reference written by one thread and read by others, where the new value does not depend on the old one.
private volatile boolean shuttingDown = false;
private volatile Config currentConfig = Config.defaults();
A shutdown flag. A hot-swapped configuration object. A "latest value" cache where a slightly stale read is acceptable. In each, the write does not read the previous value, so there is nothing to make atomic.
The moment the new value depends on the old one — a counter, a list, a
toggle — volatile is not enough.
The wider rule: happens-before
"Visibility" has a precise name in the specification: happens-before. If action A happens-before action B, then everything A wrote is visible to B.
You do not need the formal model, but you should know what establishes it, because it is the complete list of ways one thread can reliably see another's work:
| This | happens-before | that |
|---|---|---|
| releasing a lock | acquiring the same lock | |
writing a volatile |
reading that volatile |
|
thread.start() |
anything in that thread | |
| anything in a thread | another thread's join() returning |
|
writing a final field in a constructor |
any read of it after construction | |
| putting into a concurrent collection | taking it out |
Everything else is a data race.
That last row is why concurrent collections are safe and the fifth is why immutable objects are. A record with final fields is safely publishable to any number of threads with no synchronisation at all — the constructor's writes happen-before every later read. That is the deepest reason the hierarchy in the last lesson put immutability near the top.
Note also Thread.start() and join(): the example at the top of this lesson
would work if the flag were set before start(), or read after join(). The
problem is only the write that happens in between.
Why this is hard to test
The worst property of visibility bugs is that they are not deterministic and they are not random either — they depend on the JIT, the CPU, the number of cores, and how long the loop has been running.
The same code can:
- work perfectly for months in development,
- work in your test suite because the loop runs too briefly for the JIT to optimise it,
- hang in production on a bigger machine under load.
So you cannot rely on testing to find them. You have to reason about them, and the reasoning is the happens-before table above.
The practical habit: when a field is touched by more than one thread, stop and say out loud which row of that table applies. If none does, you have a bug, whether or not you can make it happen.
Check your work
Why the stop flag may never stop: the JIT may hoist the read out of the loop, because nothing in the loop suggests the value can change.
What a data race actually requires: unsynchronised access where at least one side writes. One writer and one reader is enough.
What volatile guarantees: reads and writes go to memory, and are not
reordered or eliminated.
What volatile does not do: make anything atomic. count++ stays broken.
The one shape volatile fits: a flag or reference where the new value does
not depend on the old.
What happens-before means: if A happens-before B, everything A wrote is visible to B.
Why immutable objects need no synchronisation: final fields written in a constructor happen-before every read after construction.
Why start() and join() matter: they establish ordering, so a flag set
before start or read after join is fine.
Why you cannot test for these bugs: behaviour depends on the JIT, the CPU and the load, so passing tests prove nothing.
Practice
- Run the
StopFlagprogram above. If it terminates, raise the sleep and the loop work until it does not. - Add
volatileand confirm it now stops every time. - Remove
volatileand instead make the loop body call asynchronizedmethod. Explain why that also works. - Make
countvolatile and increment it from four threads. Confirm the total is still wrong, and say whyvolatiledid not help. - Set the flag before
worker.start()instead. Confirm it is seen, and name the row of the table that makes it safe. - Write a
Configobject hot-swapped through a volatile reference. Confirm readers see the new one. - Take a record with final fields, publish it to another thread, and explain why no synchronisation is needed.
- Write down, for every shared field in your capstone, which happens-before rule protects it.
- Run the
StopFlagprogram with-Xint(interpreter only) and see whether it behaves differently. Explain. - Find a
booleanflag in any codebase that is read by one thread and written by another. Check whether it is volatile.
Next: atomics, and the collections built for this.
Stuck on this lesson?
Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.
About the internship