Course contents

volatile

What volatile really guarantees (visibility, ordering, untorn long/double), why count++ on it still loses updates, the JIT hoisting behind the classic hanging stop flag, and when a volatile snapshot beats a lock.

FreeMust know for interviewsLesson21 min readCoreReviewed Oct 2026 · Java 21
On this page
  1. Picture it first: the sticky note and the whiteboard
  2. Under the hood: a program that never ends
  3. What volatile actually guarantees
  4. What volatile does NOT do: count++
  5. When volatile is exactly the right tool
  6. The approach ladder: one config, 50 readers
  7. Real world
  8. Advanced: fences, CPUs and access modes
  9. How to say it in the interview

📣 What does volatile do in Java?

volatile is a field modifier that tells the JVM "other threads read and write this, so never keep a private copy of it and never move other memory work across it". A thread that reads a volatile field always sees the latest write to it, and everything the writer did before that write comes along too. It does not make count++ atomic. Think of it as a loudspeaker for one variable, not a lock.

For this lesson, we'll start with a program that hangs forever because one keyword is missing, find out who really causes the hang (spoiler: it's the JIT compiler, not a "stale CPU cache"), and then pin down exactly what volatile promises and what it doesn't. Interviewers love this topic because nearly everyone has heard "volatile means read from main memory", and almost nobody can explain why volatile int count; count++ still loses updates. That gap is where interviews are won.

Below the line (out of scope)

The 30-second answer

volatile gives a field two guarantees. Visibility: a read always sees the most recent write in the single global order of all volatile accesses, so the JIT can't cache the value in a register or hoist the read out of a loop. Ordering: a volatile write happens-before every later read that sees it, so all plain writes made before the volatile write are visible after the read. It also makes long and double reads and writes atomic. It does not make compound actions like count++ or check-then-act atomic; use AtomicInteger or a lock for those. Classic uses: a stop flag, a status field with one writer, and publishing an immutable snapshot. On x86, volatile reads cost about the same as plain reads; volatile writes pay for a StoreLoad fence.

What you’ll learn

  • Reproduce a stop flag that hangs forever and prove it's the JIT hoisting the read, not stale CPU caches
  • State the three guarantees of volatile: visibility, happens-before ordering and **atomic long/double access**
  • Explain why volatile int count; count++ still loses updates, and fix it with AtomicInteger or LongAdder
  • Use volatile correctly for stop flags, single-writer status fields and publishing an immutable snapshot
  • Pick between a plain field, synchronized and a volatile reference to an immutable object for read-mostly config
  • Describe the StoreLoad fence a volatile write costs, and how x86 (lock addl) and ARM (ldar/stlr) implement it
  • Compare VarHandle access modes: plain, opaque, acquire/release and volatile

Interview radar

Asked very often: What does volatile do? Is volatile int count; count++ thread-safe? Core follow-up: volatile vs synchronized: what does each guarantee? Why does double-checked locking need volatile? Production angle: A background thread never stops after you flip a boolean flag, but only in production. Why, and what's the one-word fix?

01Picture it first: the sticky note and the whiteboard

Imagine an office with a big whiteboard that says running = true. A worker copies that onto a sticky note on his monitor, because glancing at the note is faster than turning around. Later, the manager walks up and changes the whiteboard to running = false. The worker keeps glancing at his sticky note. He never stops.

volatile is the office rule "no sticky notes for this one". Every time you check, you look at the whiteboard.

Fig. 1

The sticky note and the whiteboard

The sticky note and the whiteboardA mental model. The main thread writes running = false on the shared whiteboard. The worker copied running = true onto a sticky note once and keeps glancing at the note, so it never sees the change. With volatile, the worker has to look up at the whiteboard on every single check.running = falseshared whiteboard (main memory)main thread1. writes falseworker threadrunning = truesticky note2. copied once3. keeps reading the notenever looks upWith volatile- no sticky notes allowed- every read looks at the boardMental model only. The real cause is the JIT keeping the value in a register.
The sticky-note mental model. The worker copied the flag once and never looks up again. Keep the caveat in mind: it's a model, not a hardware diagram.

Here's the thing, though. This is a mental model, not how the silicon works. Modern CPU caches are coherent: hardware protocols (MESI and friends) make sure two cores don't keep disagreeing about the same cache line for long. The "sticky note" in real life is usually a CPU register, filled by the JIT compiler, plus a few hardware tricks like store buffers. We'll catch the JIT doing it in a minute. The actual contract you program against is the Java Memory Model (JLS chapter 17.4), which talks about happens-before, not caches.

02Under the hood: a program that never ends

Try it yourself first

Before reading on, predict the output. A worker spins on while (flag) spins++;. After 100 ms, main sets flag = false. Does the worker stop? Now make the flag volatile. Does anything change?

This is the program I use to show it live. Notice the watchdog: the workers are daemon threads and main only waits 2 seconds, so a stuck worker can't hang your terminal.

public class StopFlag {
    static boolean plainRunning = true;             // no volatile
    static volatile boolean volatileRunning = true; // volatile

    public static void main(String[] args) throws InterruptedException {
        Thread plain = new Thread(() -> {
            long spins = 0;
            while (plainRunning) { spins++; }       // tight loop, no method calls
            System.out.println("plain worker saw the flag after " + spins + " spins");
        });
        plain.setDaemon(true);                      // so a stuck worker can't keep the JVM alive
        plain.start();
        Thread.sleep(100);
        plainRunning = false;
        System.out.println("main: set plainRunning = false");
        plain.join(2_000);                          // watchdog: wait at most 2 s
        System.out.println(plain.isAlive() ? "plain worker: STILL RUNNING after 2 s" : "plain worker: stopped");

        Thread vol = new Thread(() -> {
            long spins = 0;
            while (volatileRunning) { spins++; }
            System.out.println("volatile worker saw the flag after " + spins + " spins");
        });
        vol.setDaemon(true);
        vol.start();
        Thread.sleep(100);
        volatileRunning = false;
        System.out.println("main: set volatileRunning = false");
        vol.join(2_000);
        System.out.println(vol.isAlive() ? "volatile worker: STILL RUNNING after 2 s" : "volatile worker: stopped");
    }
}

On my Apple M4 Pro with JDK 26, java StopFlag.java printed this in three runs out of three (the spin count changes every run):

main: set plainRunning = false
plain worker: STILL RUNNING after 2 s
main: set volatileRunning = false
volatile worker saw the flag after 368451599 spins
volatile worker: stopped

The plain worker never saw false. Not after 100 ms, not after 2 seconds, not ever. Same code, one keyword different.

Who's really holding the sticky note?

Now the interesting part. I ran the exact same file three more ways:

java -Xint StopFlag.java                    -> plain worker saw the flag after 27447891 spins
java -XX:TieredStopAtLevel=1 StopFlag.java  -> plain worker saw the flag after 391571875 spins
JDK 21, four runs                           -> hung, stopped, hung, stopped

With -Xint (interpreter only) the plain worker stops. With TieredStopAtLevel=1 (only the quick C1 compiler) it stops too. Same CPU, same caches. The only thing that changed is which compiler ran the loop, so the caches can't be the culprit. It's the optimising C2 compiler.

Here's what C2 is allowed to do. Inside that loop, nothing writes plainRunning, and the field isn't volatile, so as far as the JMM is concerned no other thread needs to be considered. C2 reads the field once, keeps it in a register and turns your loop into "if it was true, loop forever". This is called hoisting a read out of a loop, and it's a perfectly legal optimisation.

Fig. 2

What the JIT is allowed to do to a plain flag

What the JIT is allowed to do to a plain flagLeft: the loop you wrote, while running, spins plus plus. Middle: what the C2 compiler may legally run for a plain field: read running once, then loop forever. Right: with volatile, the read must stay inside the loop, so a change by another thread is seen within one iteration.while (running) spins++;what you wroteif (running) while (true) spins++;what C2 may run (plain field)while (read running) spins++;volatile: read every timeJITPlain field- one read, hoisted out of the loop- legal: no other thread "exists"- loop never endsVolatile field- read cannot be hoisted- sees the write next iteration- loop endsWhy it hides in tests- interpreter and C1 re-read- only C2 hoists it- println() in the loop can hide the bug
The JIT hoists the read. For a plain field, C2 may read the flag once and spin forever. A volatile read has to stay inside the loop.

Notice three things:

  1. The bug depends on timing. On JDK 21 it hung only half the time, because it depends on whether C2 compiled the loop before or after the flag flipped. "It works on my machine" is the most dangerous output a concurrency bug can give you.
  2. A println or Thread.sleep inside the loop often "fixes" it by accident. The call makes it hard for C2 to prove the field didn't change, so it re-reads. That's luck, not a guarantee. Remove the logging in a refactor and the hang comes back.
  3. volatile is the guarantee. A volatile read must happen on every iteration, and the JMM promises it will eventually see the write.

03What volatile actually guarantees

There are exactly three promises. Learn them as a list; interviewers check each one.

Guarantee Plain words Formal source
Visibility A read sees the latest write to that variable. No register caching, no hoisting. JLS 17.4.4, 17.4.5: all volatile accesses form one total order (synchronization order)
Ordering (happens-before) Everything a thread wrote before a volatile write is visible to a thread that later reads that write. JLS 17.4.5: "a write to a volatile field happens-before every subsequent read of that field"
Atomic 64-bit access A volatile long or volatile double is never seen half-written. JLS 17.7

The second row is the one people forget, and it's the most useful. Picture a courier who seals a box and then rings the doorbell. If you hear the bell, you know the box is on the doorstep. The bell is the volatile write; the box is all the plain data written before it.

Fig. 3

A volatile write publishes everything written before it

A volatile write publishes everything written before itWriter thread: step 1 writes data = 42, a plain field. Step 2 writes the volatile flag ready = true. Reader thread: step 3 reads ready and sees true. Step 4 reads data and is guaranteed to see 42. The volatile write and the volatile read that sees it form a happens-before edge that carries the earlier plain write across.writer threadreader threaddata = 42;plain writeready = true;volatile write (release)if (ready)volatile read (acquire)use(data);plain read: sees 421234program orderprogram orderhappens-beforeRule of thumb: everything above the volatile write is visible below the volatile read.Only if the reader actually saw ready == true.
Happens-before through a volatile. Follow badges 1 to 4: the plain write of 42 rides along with the volatile write of ready, as long as the reader actually saw ready == true.

In code, that's the whole trick behind safe hand-offs:

int data;                 // plain field
volatile boolean ready;   // the doorbell

// writer thread
data = 42;
ready = true;             // volatile write: publishes data too

// reader thread
if (ready) {              // volatile read that sees true
    System.out.println(data);  // guaranteed to print 42, never 0
}

Without volatile on ready, the reader could see ready == true and still print 0, because the compiler or CPU may reorder the two writes. We dig into that reordering in Visibility and reordering and the rule itself in Happens-before.

About that third row: the JLS lets a JVM split a plain 64-bit long or double write into two 32-bit halves, so another thread could see the top half of one value and the bottom half of another. That's called tearing. Every mainstream 64-bit JVM today writes them in one go, so you'll struggle to reproduce it, but the spec only guarantees it with volatile. References and all other primitives never tear.

04What volatile does NOT do: count++

This is the bit that trips people up. count++ looks like one operation, but it's three: read the value, add one, write it back. volatile makes each read and each write visible. It does nothing to stop another thread sneaking in between them.

Try it yourself first

Four threads each run count++ a million times on a volatile int. What's the final value? Guess a number before you scroll.

import java.util.concurrent.atomic.AtomicInteger;

public class VolatileCounter {
    static volatile int volatileCount = 0;
    static final AtomicInteger atomicCount = new AtomicInteger();

    public static void main(String[] args) throws InterruptedException {
        int threads = 4, perThread = 1_000_000;
        Thread[] ts = new Thread[threads];
        for (int i = 0; i < threads; i++) {
            ts[i] = new Thread(() -> {
                for (int j = 0; j < perThread; j++) {
                    volatileCount++;                 // read, add 1, write: three steps
                    atomicCount.incrementAndGet();   // one atomic step
                }
            });
            ts[i].start();
        }
        for (Thread t : ts) t.join();
        int expected = threads * perThread;
        System.out.printf("expected      %,d%n", expected);
        System.out.printf("volatile int  %,d  (lost %,d)%n", volatileCount, expected - volatileCount);
        System.out.printf("AtomicInteger %,d%n", atomicCount.get());
    }
}

Three runs, three different wrong answers for the volatile counter:

expected      4,000,000
volatile int  2,154,771  (lost 1,845,229)
AtomicInteger 4,000,000

volatile int  2,172,599  (lost 1,827,401)
volatile int  2,079,389  (lost 1,920,611)

Almost half the increments vanished, on a field marked volatile. Here's how one of them disappears.

Fig. 4

count++ on a volatile field still loses updates

count++ on a volatile field still loses updatesTwo threads increment a volatile counter that holds 7. Thread A reads 7. Thread B reads 7. Thread A writes 8. Thread B writes 8. Both writes were visible immediately, yet one increment is gone, because read, add and write are three separate steps.Thread AThread Bcountvolatile intread 7read 7write 8write 81234two increments, one +1volatile made every read and write visible. It did not make read-add-write one step.
A lost update on a volatile field. Both threads read 7, both write 8. Every access was perfectly visible; the problem is the gap between read and write.

The fix is an atomic read-modify-write: AtomicInteger.incrementAndGet(), LongAdder for very hot counters, or a lock. We build those up in Atomic classes. The same trap catches check-then-act code like if (instance == null) instance = new X(); on a volatile field: two threads can both pass the check.

Rule of thumb: volatile is safe when each write doesn't depend on the current value, or when only one thread ever writes.

05When volatile is exactly the right tool

Here are the four patterns I'd reach for, and the reason each one is safe.

  1. Stop and status flags. volatile boolean running, volatile State state. One write, many reads, and the new value doesn't depend on the old one. (For stopping threads that block in sleep or I/O, pair it with interrupts.)
  2. One writer, many readers. A single thread updates volatile long lastHeartbeatMillis; monitoring threads read it. With one writer, there's no read-modify-write race.
  3. Publishing an immutable snapshot. Build a new immutable object, then assign it to a volatile reference in one write. Readers get either the whole old object or the whole new one. More on this in the ladder below.
  4. The volatile in double-checked locking. The lazy-singleton idiom only works if the instance field is volatile, because otherwise another thread can see the reference before the constructor's writes. That story gets its own lesson: Safe publication and double-checked locking.

06The approach ladder: one config, 50 readers

Here's a problem you'll hit in real services. One background thread reloads a config every 30 seconds (say maxConnections and timeoutMs, which must stay consistent). 50 request threads read it on every request. Let's solve this the way you'd grow into it in an interview.

Fig. 5

One writer, 50 readers: three ways to share a config

One writer, 50 readers: three ways to share a configThree columns. Naive: a plain field, so readers may never see the update and can see half of it. Solid: synchronized getter and setter, correct but every read takes the lock. Staff: one volatile reference to an immutable Config object; the writer builds a new object and swaps the reference, readers do one cheap read and always get a whole snapshot.NAIVEplain field- reads may be hoisted- may never see update- can mix old + new- cost: zero, bug: realSOLIDsynchronized get/set- correct, easy to review- every read takes lock- 50 readers queue up- fine at low trafficSTAFFvolatile ref to immutable- writer: copy, then swap- reader: one read- always a whole snapshot- needs a truly immutable obj1 writer50 request threadsnew Config(...)
Three ways to share a config. Same problem, three answers. The staff answer swaps a whole immutable object in one volatile write.
Naive approach: Plain fields

static int maxConns; static int timeoutMs; updated by the reload thread. It's what you write when you forget other threads exist, and it "works" in every unit test.

Challenges: readers may never see a reload (the JIT can hoist the read, as StopFlag showed), and even with visibility a reader can see the new maxConns with the old timeoutMs. Marking both fields volatile fixes the first problem but not the second: in my test, 50 readers saw a mixed config 16,725 to 23,891 times in 0.5 seconds.

Solid approach: synchronized getter and setter

Put both fields behind one lock: synchronized Config get() and synchronized void reload(...). The lock gives visibility, ordering and a consistent pair, and any reviewer can verify it.

Challenges: every one of the 50 readers takes the same monitor on every request. Uncontended locks are cheap, but under load readers queue behind each other and behind the writer, for data that changes twice a minute. See synchronized and monitors for what that costs.

Staff approach: volatile reference to an immutable snapshot

Make Config an immutable record and keep one volatile Config current. The reload thread builds a fresh object and assigns it (copy-on-write); readers do one volatile read and use that object for the whole request. In my test: 0 mixed reads.

When not to use it: when several threads update it based on the current value (two concurrent reloads that each "add one rule" would lose one), use AtomicReference.updateAndGet or a lock. And the object must be truly immutable: a volatile reference to a mutable HashMap publishes the map once, not the later puts.

Here's the staff version as a runnable test, racing it against two separate volatile fields:

import java.util.concurrent.atomic.LongAdder;

public class ConfigSnapshot {
    // Two settings that must always match: timeoutMs == maxConns * 10.
    record Config(int maxConns, int timeoutMs) { }

    static volatile int maxConns = 10, timeoutMs = 100;       // two separate volatile fields
    static volatile Config config = new Config(10, 100);      // one volatile reference to an immutable object

    public static void main(String[] args) throws InterruptedException {
        System.out.println("two volatile fields : " + run(false) + " mixed reads");
        System.out.println("volatile snapshot   : " + run(true) + " mixed reads");
    }

    static long run(boolean snapshot) throws InterruptedException {
        LongAdder mixed = new LongAdder();
        Thread[] readers = new Thread[50];
        long end = System.nanoTime() + 500_000_000L;           // 0.5 s per run
        for (int i = 0; i < readers.length; i++) {
            readers[i] = new Thread(() -> {
                while (System.nanoTime() < end) {
                    int m, t;
                    if (snapshot) { Config c = config; m = c.maxConns(); t = c.timeoutMs(); }
                    else { m = maxConns; t = timeoutMs; }
                    if (t != m * 10) mixed.increment();
                }
            });
            readers[i].start();
        }
        boolean big = false;
        while (System.nanoTime() < end) {                      // one writer flips between two configs
            big = !big;
            int m = big ? 20 : 10;
            if (snapshot) config = new Config(m, m * 10);
            else { maxConns = m; timeoutMs = m * 10; }
        }
        for (Thread r : readers) r.join();
        return mixed.sum();
    }
}
two volatile fields : 23891 mixed reads
volatile snapshot   : 0 mixed reads

two volatile fields : 16725 mixed reads
volatile snapshot   : 0 mixed reads

Notice the reader copies config into a local c once. If it read config.maxConns() and then config.timeoutMs() as two separate volatile reads, it could straddle a swap and mix configs all over again. One read, one snapshot.

My default: start with SOLID if you're unsure, and move to the volatile snapshot when the data is read far more often than it's written. It's the same idea CopyOnWriteArrayList is built on.

07Real world

In the real world

The JDK itself is the best catalogue of correct volatile use. CopyOnWriteArrayList keeps its contents in private transient volatile Object[] array: every write copies the array and swaps the reference, and iterators read one snapshot, which is our staff answer in library form. ConcurrentHashMap declares volatile V val and volatile Node<K,V> next in its nodes so get() can read without locking. FutureTask tracks its lifecycle in private volatile int state, a status field changed with CAS. And the most famous volatile story is a bug: the "Double-Checked Locking is Broken" declaration (around 2000) by Bill Pugh and others showed the lazy singleton idiom was unsafe, and only the Java 5 memory model (JSR-133) made volatile strong enough to fix it.

Two bug shapes I see in code review:

  • The stop flag that works in tests. A boolean shutdown without volatile, in a loop that happens to log. Someone removes the log line for performance, C2 hoists the read, and the service hangs on shutdown. Fix: volatile, or AtomicBoolean, or interrupts.
  • The "volatile counter". volatile long requestCount; requestCount++ in a metrics class, written by every request thread. The dashboard quietly under-reports. Fix: LongAdder.

08Advanced: fences, CPUs and access modes

Where candidates slip

Many candidates say "volatile means the value is read from main memory and never cached". It's a decent first sentence, but it's wrong as hardware: caches are coherent, and a volatile read on x86 is an ordinary load that usually hits L1. What volatile actually changes is what the compiler may do (no caching in registers, no hoisting, no reordering across it) and which fences the JVM emits. The other big slip is "volatile makes it thread-safe". It makes single reads and writes safe. Compound actions still need atomics or locks.

How HotSpot implements it: barriers

The JSR-133 Cookbook (Doug Lea) describes four barrier types: LoadLoad, LoadStore, StoreStore and StoreLoad. Conceptually, the JVM puts barriers around volatile accesses like this:

StoreStore   <- earlier writes can't sink below the volatile write
volatile write
StoreLoad    <- the volatile write can't be overtaken by a later volatile read

volatile read
LoadLoad + LoadStore  <- later reads and writes can't float above it

The StoreLoad barrier is the expensive one, because it forces the CPU to drain its store buffer (a small queue where a core parks writes before they reach the cache, so it can keep running). That buffer is also the one place where even x86 reorders: a later load can complete before an earlier store becomes visible.

x86 vs ARM

x86 (TSO) ARMv8 / AArch64 (weaker)
Hardware reorders Only store followed by load Almost anything, unless told otherwise
Volatile read Plain mov; LoadLoad/LoadStore are free ldar (load-acquire)
Volatile write mov then lock addl $0,(%rsp) as the StoreLoad fence stlr (store-release), plus ordering against later ldar
Where bugs show up Mostly compiler reordering and hoisting Compiler and real hardware reordering

That last row matters in 2026. Code that "worked for years" on x86 servers can break on ARM (Graviton, Apple Silicon), because x86 was quietly hiding missing volatiles. HotSpot uses lock addl on the stack rather than mfence on x86 because it's usually cheaper; Aleksey Shipilev's write-up "On The Fence With Dependencies" digs into the trade-off.

Performance angle

The LMAX Disruptor paper measured incrementing a 64-bit counter 500 million times on a 2.4 GHz Westmere: 300 ms single-threaded, 4,700 ms with a volatile write each time, 5,700 ms with CAS, 10,000 ms with an uncontended lock and 224,000 ms with two threads fighting over a lock. So a volatile write cost roughly 9 ns versus 0.6 ns, about 16x a plain write, mostly the StoreLoad fence. Volatile reads on x86 cost about the same as plain reads; the real price is the optimisations they block. Write to volatiles rarely; read them freely.

VarHandle access modes (Java 9+)

Since Java 9, VarHandle lets you pick the strength per access instead of per field. Doug Lea's "Using JDK 9 Memory Order Modes" describes four modes, weakest to strongest:

Mode VarHandle methods What you get Typical use
Plain get / set Nothing across threads; same as a normal field Single-threaded or already-locked data
Opaque getOpaque / setOpaque Each access really happens and per-variable coherence; no ordering with other variables Progress flags, counters read by monitors
Acquire/Release getAcquire / setRelease Release publishes earlier writes to an acquire that sees it (causality) Hand-offs, one producer one consumer
Volatile getVolatile / setVolatile Acquire/Release plus one total order over all volatile accesses Same as a volatile field
import java.lang.invoke.MethodHandles;
import java.lang.invoke.VarHandle;

class Mailbox {
    private int data;
    private boolean ready;
    private static final VarHandle READY;
    static {
        try { READY = MethodHandles.lookup().findVarHandle(Mailbox.class, "ready", boolean.class); }
        catch (ReflectiveOperationException e) { throw new ExceptionInInitializerError(e); }
    }
    void put(int d) { data = d; READY.setRelease(this, true); }           // release: publishes data
    Integer take()  { return (boolean) READY.getAcquire(this) ? data : null; } // acquire: sees data
}

Release/acquire skips the StoreLoad fence, so on ARM setRelease is a cheaper store than a full volatile write. The catch: you lose the single total order, which breaks algorithms like Dekker-style "I set my flag, then check yours". Default to plain volatile; reach for weaker modes only with a benchmark and a jcstress test.

volatile vs synchronized vs atomics

volatile synchronized AtomicInteger etc.
Visibility Yes Yes (on unlock/lock of the same monitor) Yes
Ordering / happens-before Yes Yes Yes
Atomic x++, check-then-act No Yes Yes (CAS)
Multi-field consistency Only via one reference to an immutable object Yes Only via AtomicReference
Blocking Never Yes Never (spins on CAS failure)
Cost Read ~free, write = one fence Lock + possible park One CAS per update

09How to say it in the interview

When I ask about volatile, I'm listening for three things. Do you name visibility and ordering (not "reads from main memory")? Do you say, without being prompted, that count++ isn't atomic on a volatile? And can you give a use case where it's genuinely the right tool, ideally the immutable snapshot?

In the interview

"volatile gives a field two guarantees. Visibility: every read sees the latest write, so the JIT can't cache it in a register or hoist it out of a loop, which is why a non-volatile stop flag can spin forever. And ordering: a volatile write happens-before any read that sees it, so everything the writer did before the write is visible after the read. That's what makes it work for publishing data and for double-checked locking. It also makes long and double access atomic. What it doesn't do is make compound actions atomic: count++ on a volatile still loses updates, so for that I'd use AtomicInteger or LongAdder. I use it for flags, single-writer status fields, and swapping an immutable config object. Under the hood it's fences: on x86 reads are basically free and writes pay a StoreLoad fence."

Here's how this might look if you sketch it on the whiteboard:

On the whiteboard

volatile = visibility + ordering, not atomicity

  • Read sees latest write; no register caching / loop hoisting (JIT, not caches)
  • Volatile write → happens-before → read that sees it (publishes earlier writes)
  • long/double: no tearing (JLS 17.7)
  • count++ / check-then-act: still racy → AtomicInteger, LongAdder, lock
  • Good for: stop flag, 1 writer N readers, volatile ref → immutable snapshot, DCL
  • Cost: x86 read ≈ plain, write = StoreLoad fence (lock addl); ARM ldar/stlr
  • VarHandle modes: plain < opaque < acquire/release < volatile
Practise this topicJava Memory Model: interview questions & scenarios
Further reading (13)