RizTech Academy logo
RizTech Academy
ConcurrencyLesson 4 of 830 min

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

  1. Run the StopFlag program above. If it terminates, raise the sleep and the loop work until it does not.
  2. Add volatile and confirm it now stops every time.
  3. Remove volatile and instead make the loop body call a synchronized method. Explain why that also works.
  4. Make count volatile and increment it from four threads. Confirm the total is still wrong, and say why volatile did not help.
  5. Set the flag before worker.start() instead. Confirm it is seen, and name the row of the table that makes it safe.
  6. Write a Config object hot-swapped through a volatile reference. Confirm readers see the new one.
  7. Take a record with final fields, publish it to another thread, and explain why no synchronisation is needed.
  8. Write down, for every shared field in your capstone, which happens-before rule protects it.
  9. Run the StopFlag program with -Xint (interpreter only) and see whether it behaves differently. Explain.
  10. Find a boolean flag 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