Advanced15 min readConcurrency & Thread Safetylive prototype

Memory visibility & the memory model

A lock stops two threads from touching the same data at the same time. But there's a second, completely separate problem: a value one thread writes may never be seen by another — possibly forever. Picture two people, each with a private notebook, sharing one whiteboard: Amy scribbles "done ✓" in her notebook, meaning to copy it to the board later, while Ben keeps checking the board — or worse, his own stale copy — and waits forever. volatile is the rule "always write straight to the whiteboard, always read from the whiteboard."

The idea

What it is

Most people learn locks first and assume that's the whole story of thread safety. It isn't. A lock gives you mutual exclusion — only one thread inside the critical section at a time. But there is a second, independent problem hiding underneath: visibility. A value that thread A writes may not be seen by thread B — not late, not eventually, but possibly never. Your program isn't crashing or corrupting anything; B is simply looking at an old value and will keep looking at it until the heat death of the universe.

Here's the picture to keep. Amy and Ben each have their own notebook, and they share one whiteboard. The whiteboard is main memory (RAM); the notebooks are the private caches that sit inside each CPU core. Amy finishes her job and writes "done ✓" — but she writes it in her own notebook, intending to copy it to the whiteboard whenever she gets around to it. Ben, meanwhile, is watching the whiteboard, which still says not done. Or worse: Ben copied the board into his notebook once and now just re-reads his own copy. Amy is done. Ben will wait forever. Nobody lied, nobody crashed — the news just never travelled.

Two separate things cause this. Hardware: each core has its own cache and a store buffer, so a write can sit in one core's private memory instead of going out to RAM. Software: the compiler and the JIT are allowed to keep a variable in a CPU register and to reorder independent statements, because both are invisible within a single thread. The rules that say when one thread's writes must become visible to another are called the language's memory model — and volatile is the smallest tool that language gives you to force the issue.

The one sentence to remember

Correct multithreaded code needs two guarantees, not one: mutual exclusion (only one thread at a time) and visibility (my write actually reaches your eyes). A lock gives you both. volatile gives you only the second — which is exactly why it fixes a stop-flag and does not fix count++.

You've probably already hit this

The classic symptom is a background thread that ignores its stop flag: you set running = false, the worker keeps going, and it works fine when you add a System.out.println or run it in the debugger — because printing takes a lock, which happens to flush memory. That's not a heisenbug in your logic; it's a missing visibility guarantee.

Mechanics

How it works

Why a write can go missing

Reading and writing main memory is slow — hundreds of times slower than a CPU instruction. So every core keeps a small private cache and a store buffer in front of it. When a thread writes a variable, the value first lands in that core's private storage; hardware will push it out to RAM and invalidate other cores' copies eventually, but your program's rules don't promise when. Meanwhile the other core happily serves reads out of its cache, which still holds the old value.

  • The core's cache — a private copy of the data each core is working on. Two cores can hold two different values for the same variable at the same moment.
  • The store buffer — a write queue in front of the cache, so the core doesn't stall waiting for memory. A write parked here hasn't reached anybody else yet.
  • Registers — the fastest storage of all, and the one that isn't memory at all. If the compiler decides to keep done in a register, no cache protocol on earth can help you: the thread has stopped reading memory.

The infinite-spin bug

This is the canonical example, and it's mostly a compiler bug rather than a hardware one. You write a loop that waits for a flag. The compiler notices that inside the loop body nothing ever assigns to done, so re-reading it every iteration is (from this thread's point of view) pointless work. So it hoists the read out of the loop into a register:

java
// what you wrote                      // what the JIT is allowed to run
boolean done = false;                   boolean r1 = done;      // read ONCE
                                        if (!r1) {
while (!done) {          ───────▶           while (true) { }    // never re-reads
    // spin                             }
}

That transformation is perfectly legal, because it is invisible in a single thread: no single-threaded program could tell the difference. It becomes catastrophic the moment another thread writes the flag. Marking the field volatile removes the compiler's permission: every read must go to the shared location, every write must go to the shared location.

What volatile actually promises

  • A volatile read is guaranteed to see the most recent write — no register copy, no stale cache line. Read the whiteboard, not your notebook.
  • A volatile write is immediately made visible to any thread that subsequently reads that variable. Write on the whiteboard, not in your notebook.
  • No reordering across it — the compiler and CPU may not move ordinary reads/writes across a volatile access in a way another thread could observe. Everything Amy wrote before putting the flag on the board is on the board too.
  • No atomicity. volatile does not make a read-modify-write indivisible. It is a visibility and ordering tool, full stop.
java
class Worker implements Runnable {
    private volatile boolean done = false;   // ← the whole fix

    public void run() {
        while (!done) {        // volatile read: goes to shared memory every time
            doWork();
        }
    }

    public void stop() {
        done = true;           // volatile write: published immediately
    }
}

Reordering: the second surprise

The compiler and the CPU may execute independent statements out of order — again, because it's undetectable from inside one thread. Write data = 42; then ready = true; and another thread may genuinely observe ready == true while data is still 0. It saw the flag before the payload. This is the ⇄ Swap demo in the prototype, and it is the reason "set the data, then set the flag" is not, by itself, safe. Making ready volatile fixes it: everything written before the volatile write is visible to anyone who reads that volatile and sees the new value.

Happens-before, in plain words

Happens-before is the rule the memory model uses to answer "is this write guaranteed visible?". It is not about wall-clock time. It means: if action A happens-before action B, then everything A wrote is guaranteed to be visible to B. If there is no happens-before edge between two actions, the memory model promises you nothing at all — and "nothing" includes never. You create these edges with a small, fixed set of moves:

  • Unlock → lock on the same lock. Everything a thread did before releasing a lock is visible to the next thread that acquires it. This is why consistent locking gives you visibility for free.
  • Volatile write → volatile read of the same variable. Same deal, without the exclusion.
  • Thread.start() — everything the parent did before starting a thread is visible to that new thread.
  • Thread.join() — everything the finished thread did is visible to whoever joins it.
  • Final-field publication — a properly constructed object's final fields are visible to any thread that gets a reference to it, with no extra synchronization.
  • Transitivity — if A happens-before B and B happens-before C, then A happens-before C. Edges chain.

volatile gives visibility, NOT atomicity

volatile int count; count++; is still a broken race. The ++ is three separate operations — read, add, write — and volatile only guarantees that each of those touches real memory. Two threads can both read 7, both compute 8, and both store 8; you lost an increment. For read-modify-write you need an atomic (AtomicInteger, std::atomic<int>, atomic.AddInt64) or a lock. Rule of thumb: volatile is for a value that one thread sets and others read — a flag, a reference, a cached configuration — never for a counter.

Safe publication and the half-built object

The nastiest version of this bug isn't a boolean, it's an object. Thread A does config = new Config(...) and thread B reads config. Without a happens-before edge, B can see a non-null reference to a half-constructed object: the reference write became visible before the writes that filled in the fields. B reads config.timeout and gets 0. Publishing an object safely means handing it over across a happens-before edge — store it in a volatile field or an AtomicReference, store it while holding a lock that readers also hold, initialise it in a static initialiser, or make it immutable with all-final fields.

One line per language

  • Java — the Java Memory Model (rewritten by JSR-133 in Java 5) is the specification that defines happens-before, volatile, final-field safety, and what a data race is; before JSR-133, volatile and double-checked locking were genuinely broken.
  • C++std::atomic<T> with memory orders: the default memory_order_seq_cst is the safe, intuitive one (a single global order everyone agrees on); memory_order_relaxed is faster but gives you visibility with no ordering, and acquire/release is the middle ground. A plain data race in C++ is formally undefined behaviour, not just a wrong answer.
  • Go — the official advice from the Go memory model is don't be clever: "Don't communicate by sharing memory; share memory by communicating." Use channels, sync.Mutex, or sync/atomic — and run your tests with the built-in race detector (go test -race), which catches missing happens-before edges at runtime.
  • Python — CPython's GIL makes many races look impossible, but it does not make compound operations atomic and it is not a portable guarantee. Use threading.Event, Lock, or queue.Queue to publish state between threads.

The practical takeaway

If you already guard a piece of shared state with the same lock on every read and every write, you get visibility for free — you never need to think about caches, registers or reordering for that state. Reach for volatile only in the narrow case where a lock is overkill: a single flag or reference that one thread writes and others just read.

Interactive prototype

See it. Build it. Break it.

A sandboxed, hands-on simulation — no setup, no install. Play with it as you read.

About this simulation

Two CPU cores side by side, each with its own cache cell holding a copy of done, and one Main memory (RAM) bar underneath. Thread B is already spinning — the spins: counter climbs on its own. In 🟡 plain mode, hit ▶ A: done = true: only Core 1's cache turns green while RAM done and Core2 sees stay red — and the spins keep climbing. Wait a moment and B's lane rewrites itself to r1 = done; while (!r1) { } with a 🌀 read hoisted into a register tag: now B can never notice. Switch to ⚡ volatile and write again — a dot travels from Core 1 down to RAM, RAM flips true, Core 2's copy is struck through (invalidated), and B's next read pulls from RAM and exits, lane green. 🔒 lock does the same thing for a different reason. Below, the 🔀 Reordering strip lets you ⇄ Swap data = 42; and ready = true; then ▶ Run A — B flashes red when it sees ready == true but data == 0. In volatile or lock mode the swap button becomes 🔒 Locked.

Hands-on

Try these yourself

Open the prototype above, predict what happens, then verify.

try 01

Watch a write disappear (🟡 plain)

Leave the toggle on 🟡 plain and notice B is already running — the spins: counter climbs on its own while Core2 sees stays false. Now click ▶ A: done = true. Only Core 1's cache cell turns green; the RAM bar and Core 2's cache stay red, and the scoreboard still reads RAM done: false · Core2 sees: false. Press ▶ B: check a few times: B keeps reading its own stale copy. Wait a moment and B's lane rewrites itself to r1 = done; while (!r1) { } with a 🌀 read hoisted into a register tag — the compiler cached the value, so B can never notice. That's the infinite-spin bug.

try 02

Fix it with ⚡ volatile (and see 🔒 lock do the same)

Click ⚡ volatile (this resets the demo) and hit ▶ A: done = true again. A green dot travels from Core 1 down to the Main memory (RAM) bar, RAM flips to true, and Core 2's cached copy is struck through — invalidated. A beat later B's next read pulls from RAM, its lane turns green with done ✓ — loop exited, and the spins counter freezes. Now click 🔒 lock and repeat: the same publication happens, but the explain panel names the reason — unlock → lock on the same lock is a happens-before edge. Consistent locking gives you visibility for free.

try 03

Break the order in the 🔀 Reordering strip

Back on 🟡 plain, look at the bottom strip: A writes ① data = 42; then ② ready = true;. Press ▶ Run A and B ends up seeing data: 42 · ready: true — fine. Now press ⇄ Swap so ready is written first, and run it again: B's panel flashes red at data: 0 · ready: true — it saw the flag before the data. Switch to ⚡ volatile and the button becomes 🔒 Locked: a volatile write forbids reordering across it, so the data always lands first. Hit ↺ Reset any time to replay.

In practice

When to use it — and what trips people up

Reach for volatile when...

  • One thread writes a simple flag, others only read it — a running/shutdown boolean, a "config reloaded" marker. This is volatile's home turf and a lock would be pure overhead.
  • You publish an immutable object by swapping a reference — build it fully, then assign it to a volatile field. Readers either see the old object or the fully-built new one, never a half-built one.
  • A value is written independently of its previous value — that's the dividing line. If the new value doesn't depend on the old one, volatile is enough.
  • Double-checked locking — the classic lazy singleton needs the instance field to be volatile, or a second thread can get a non-null reference to a half-constructed object.

Use a lock or an atomic instead when...

  • The operation is read-modify-write — counters, check-then-act, if (map.get(k) == null) map.put(k, v). Use AtomicInteger/std::atomic/sync/atomic, or a lock.
  • Two or more fields must change togethervolatile protects one variable at a time and can't give you an all-or-nothing update across several.
  • You already hold a lock on that state everywhere — then stop worrying: consistent locking already gives you visibility, and adding volatile on top just adds noise.
  • You're reaching for relaxed memory orders to go faster — measure first. Almost every real bottleneck is elsewhere, and relaxed ordering is one of the easiest ways to write code that is wrong only on some machines.

What it gives you

  • Correct visibility — a volatile read is guaranteed to see the latest write, killing stale-cache and hoisted-register bugs outright.
  • Ordering for free — writes made before a volatile write are visible to anyone who reads that volatile and sees the new value, which is what makes safe publication work.
  • Much cheaper than a lock for the read-mostly flag case: no blocking, no context switches, no deadlock risk, and readers never contend with each other.
  • It's one keyword on one field — the fix is local and reviewable, unlike restructuring code around a new lock.

Common mistakes

  • No atomicity — the single biggest source of false confidence: volatile count++ still loses updates, and many developers assume otherwise.
  • Single-variable scope — it cannot make two related fields change together, so invariants spanning multiple variables still need a lock.
  • Not free — volatile accesses insert memory barriers, so a volatile in a hot inner loop can be noticeably slower than a plain field.
  • Easy to under-apply and hard to test — code missing a happens-before edge usually passes every test on your laptop and fails in production on different hardware.

Reference

Code & further reading

A minimal reference implementation and pointers worth bookmarking.

import java.util.concurrent.atomic.AtomicInteger;

public class Visibility {

    // ❌ WITHOUT volatile: the JIT may hoist the read of 'done' into a
    //    register, so this worker can spin forever after stop() is called.
    static class BrokenWorker extends Thread {
        private boolean done = false;             // plain field
        public void run() {
            while (!done) { /* spin */ }          // may never re-read memory
            System.out.println("broken worker finished");
        }
        void stop_() { done = true; }             // write may stay in this core
    }

    // ✅ WITH volatile: every read goes to shared memory, every write is
    //    published immediately, and nothing is reordered across it.
    static class GoodWorker extends Thread {
        private volatile boolean done = false;    // visibility + ordering
        public void run() {
            while (!done) { /* spin */ }
            System.out.println("good worker finished");
        }
        void stop_() { done = true; }
    }

    // Safe publication: 'data' is written BEFORE the volatile flag, so any
    // thread that sees ready == true is guaranteed to see data == 42.
    static int data = 0;
    static volatile boolean ready = false;

    static void publisher() { data = 42; ready = true; }   // order preserved
    static void consumer()  { if (ready) assert data == 42; }

    // volatile is NOT atomic — count++ is read, add, write (three steps).
    static volatile int badCount = 0;             // still a race!
    static final AtomicInteger goodCount = new AtomicInteger();

    public static void main(String[] args) throws Exception {
        GoodWorker w = new GoodWorker();
        w.start();                                // start() = happens-before edge
        Thread.sleep(50);
        w.stop_();
        w.join();                                 // join() = happens-before edge

        badCount++;                               // lost updates under contention
        goodCount.incrementAndGet();              // atomic read-modify-write

        // A lock gives BOTH exclusion and visibility:
        Object lock = new Object();
        synchronized (lock) { data = 7; }         // unlock publishes to memory
        synchronized (lock) { System.out.println(data); }  // lock sees it
    }
}

References & further reading

7 sources

Knowledge check

Did it land?

Quick questions, answers revealed on submit. Sign in to save your best score.

question 01 / 06

Thread A sets a plain (non-volatile) boolean done = true. Thread B runs while (!done) {}. What can happen?

question 02 / 06

What does volatile guarantee?

question 03 / 06

You have volatile int hits; and several threads run hits++. Is that correct?

question 04 / 06

In plain words, what does 'A happens-before B' mean in a memory model?

question 05 / 06

Thread A runs data = 42; ready = true; with both fields plain. Thread B checks if (ready) use(data);. What can go wrong?

question 06 / 06

Your shared counter is already read and written only while holding the same lock. Should you also mark it volatile?

0/6 answered