The volatile Keyword in Java

In Java, volatile is a modifier that changes how a shared variable is read and written across threads: a read of a volatile variable sees the latest volatile write to it, and accesses to volatile variables impose a partial order on other reads and writes around them (JLS §17.4.5, The Java Memory Model). It is not a lock, and it does not make a compound operation like x++ atomic. The classic bugs it fixes — a worker thread that never sees a flag being cleared, or a reader that sees a flag set but not the data that was written just before — are exactly what this post demonstrates, along with the one thing volatile deliberately does not do.

The code

One file, three demos. First, a stop flag: a worker spins on volatile boolean running until the main thread flips it, repeated 200 times. Second, a publish pattern: a producer writes a plain int value, then sets volatile boolean ready; a consumer waits for ready and checks that value is visible — 100,000 rounds. Third, three shared counters (plain, volatile, and AtomicInteger) each incremented by 3 threads × 2,000,000, over three trials.

import java.util.concurrent.atomic.AtomicInteger;

public class VolatileDemo {

    // --- Demo 1: a volatile stop flag -------------------------------
    // A worker thread spins until the main thread clears the flag.
    static class Stopper implements Runnable {
        volatile boolean running = true;
        @Override public void run() {
            while (running) {
                // spin until told to stop
            }
        }
    }

    static void demoStopFlag() throws InterruptedException {
        int rounds = 200, stopped = 0;
        for (int i = 0; i < rounds; i++) {
            Stopper stopper = new Stopper();
            Thread worker = new Thread(stopper);
            worker.start();
            Thread.sleep(2);
            stopper.running = false;      // main thread flips the flag
            worker.join(1000);
            if (!worker.isAlive()) stopped++;
        }
        System.out.println("[stop flag] " + stopped + "/" + rounds
                + " workers exited after the flag was flipped");
    }

    // --- Demo 2: publish data together with a volatile ready flag ----
    // Producer writes a value, then publishes it by setting `ready`.
    // Because `ready` is volatile, a reader that observes ready == true
    // is guaranteed to also see the value write that preceded it.
    static class Result {
        int value = 0;                    // plain field, written first
        volatile boolean ready = false;   // volatile publish flag
    }

    static void demoPublish() throws InterruptedException {
        int rounds = 100_000;
        AtomicInteger seenBad = new AtomicInteger();
        for (int i = 0; i < rounds; i++) {
            Result r = new Result();
            Thread producer = new Thread(() -> {
                r.value = 42;
                r.ready = true;           // volatile write, ordered after value
            });
            Thread consumer = new Thread(() -> {
                while (!r.ready) { }      // volatile read
                if (r.value != 42) seenBad.incrementAndGet();
            });
            producer.start();
            consumer.start();
            producer.join();
            consumer.join();
        }
        System.out.println("[publish] " + rounds + " rounds, "
                + seenBad.get() + " rounds where ready was true but value was not 42");
    }

    // --- Demo 3: volatile does not make compound updates atomic ------
    static int plain = 0;
    static volatile int vol = 0;
    static AtomicInteger atomic = new AtomicInteger();

    static void demoCounter() throws InterruptedException {
        int perThread = 2_000_000, threads = 3;
        int expected = perThread * threads;
        for (int trial = 1; trial <= 3; trial++) {
            plain = 0; vol = 0; atomic.set(0);
            Thread[] ts = new Thread[threads];
            for (int t = 0; t < threads; t++) {
                ts[t] = new Thread(() -> {
                    for (int i = 0; i < perThread; i++) plain++;
                    for (int i = 0; i < perThread; i++) vol++;
                    for (int i = 0; i < perThread; i++) atomic.incrementAndGet();
                });
            }
            for (Thread t : ts) t.start();
            for (Thread t : ts) t.join();
            System.out.printf("[counter] trial %d: expected %d -> plain=%d volatile=%d atomic=%d%n",
                    trial, expected, plain, vol, atomic.get());
        }
    }

    public static void main(String[] args) throws Exception {
        demoStopFlag();
        demoPublish();
        demoCounter();
    }
}

Running it

Compiled and run with OpenJDK 21:

$ javac VolatileDemo.java
$ java VolatileDemo
[stop flag] 200/200 workers exited after the flag was flipped
[publish] 100000 rounds, 0 rounds where ready was true but value was not 42
[counter] trial 1: expected 6000000 -> plain=2065185 volatile=2148238 atomic=6000000
[counter] trial 2: expected 6000000 -> plain=2153516 volatile=2155099 atomic=6000000
[counter] trial 3: expected 6000000 -> plain=6000000 volatile=2546837 atomic=6000000

The stop flag: visibility across threads

200 out of 200 workers exited after the main thread cleared running. That is the basic use of volatile: the write in one thread is made visible to a reader in another, so the spin loop actually observes the change. The flip side is why people add the keyword reflexively — the Java Memory Model imposes no such visibility requirement on a plain field, so a worker spinning on a non-volatile flag is not guaranteed to ever see it change (JLS §17.4.5). On many machines and JVMs you would probably still see it exit, but the guarantee is what you’re buying.

The publish flag: ordering, not just visibility

The subtler effect of volatile is the ordering it imposes. In Result, value is a plain field written immediately before ready. Because ready is volatile, a reader that observes ready == true is guaranteed to also observe value == 42 — the write to value can’t be reordered past the volatile write, nor left invisible to the reader that reads the flag (JLS §17.4.5). In this run, 100,000 rounds produced zero rounds where ready was true but value was still 0. That is the pattern behind lots of real code: a plain data field plus a volatile “I’m done” flag.

The counter: what volatile is not

The third demo is the trap. Three threads each increment a shared counter two million times; the expected total is 6,000,000. The plain counter lost updates on two of the three trials (2,065,185 and 2,153,516), and the volatile counter lost updates on all three (2,148,238, 2,155,099, 2,546,837) — while AtomicInteger landed exactly on 6,000,000 every trial.

Two things stand out. First, volatile did not rescue the increment: ++ is a read, an add, and a write, and volatile guarantees the visibility of individual reads and writes, not that those three steps execute as one unit. Second, note trial 3 — the plain counter happened to hit 6,000,000 exactly. A race that doesn’t show up in one run (or even a few) is still a race; the run-to-run spread in the other two counters is the visible symptom of that.

Takeaway

volatile gives you two things — cross-thread visibility of a variable, and an ordering boundary around its reads and writes — which covers the two demos above: a stop flag that reliably trips, and a publish flag that reliably carries its data with it. It is deliberately not an atomicity mechanism: if the operation is a compound update like x++, reach for AtomicInteger, synchronized, or a lock, because as this run showed, volatile left roughly two-thirds of the increments on the floor while the atomic version kept every one.