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.
On this page
- Picture it first: the sticky note and the whiteboard
- Under the hood: a program that never ends
- What volatile actually guarantees
- What volatile does NOT do: count++
- When volatile is exactly the right tool
- The approach ladder: one config, 50 readers
- Real world
- Advanced: fences, CPUs and access modes
- 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)
- Why threads can't see each other's writes at all: Visibility and reordering
- The formal rule behind every guarantee here: Happens-before
- The famous lazy singleton bug and its fix: Safe publication and double-checked locking
- When you need a real atomic
count++: Atomic classes
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 **atomiclong/doubleaccess** - Explain why
volatile int count; count++still loses updates, and fix it withAtomicIntegerorLongAdder - Use
volatilecorrectly for stop flags, single-writer status fields and publishing an immutable snapshot - Pick between a plain field,
synchronizedand 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.
The sticky note and the whiteboard
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.
What the JIT is allowed to do to a plain flag
Notice three things:
- 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.
- A
printlnorThread.sleepinside 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. volatileis 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.
A volatile write publishes everything written before it
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.
count++ on a volatile field still loses updates
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.
- 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 insleepor I/O, pair it with interrupts.) - 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. - Publishing an immutable snapshot. Build a new immutable object, then assign it to a
volatilereference in one write. Readers get either the whole old object or the whole new one. More on this in the ladder below. - The
volatilein double-checked locking. The lazy-singleton idiom only works if the instance field isvolatile, 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.
One writer, 50 readers: three ways to share a config
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
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 shutdownwithoutvolatile, 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, orAtomicBoolean, 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
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.
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); ARMldar/stlr - VarHandle modes: plain < opaque < acquire/release < volatile
Further reading (13)
- Java Language Specification (Java SE 21) Chapter 17: Threads and Locks: §17.4.4 synchronization order, §17.4.5 a volatile write happens-before every subsequent read, §17.7 non-atomic treatment of long and double
- JSR 133 (Java Memory Model) FAQ, Jeremy Manson and Brian Goetz: what volatile does under the new memory model, and why Java 5 made it strong enough for double-checked locking
- The JSR-133 Cookbook for Compiler Writers, Doug Lea: LoadLoad, LoadStore, StoreStore and StoreLoad barriers around volatile accesses, and per-CPU instructions
- Using JDK 9 Memory Order Modes, Doug Lea: plain, opaque, release/acquire and volatile modes and the properties each adds
- VarHandle (Java SE 21 API): getOpaque/setOpaque, getAcquire/setRelease, getVolatile/setVolatile access modes
- Aleksey Shipilev: Java Memory Model Pragmatics: why the JMM is specified with happens-before rather than caches, and what the JIT may do to plain accesses
- Aleksey Shipilev: Close Encounters of The Java Memory Model Kind: common volatile myths, hoisting, word tearing and what jcstress actually observes
- Aleksey Shipilev: On The Fence With Dependencies: HotSpot's lock addl StoreLoad barrier for volatile stores on x86, and its cost
- LMAX Disruptor technical paper: Table 1: 500M increments in 300 ms single-threaded vs 4,700 ms with a volatile write, 5,700 ms CAS, 10,000 ms lock
- The "Double-Checked Locking is Broken" Declaration: why lazy initialization needed volatile and the Java 5 memory model
- Arm Developer: Load-Acquire and Store-Release instructions: LDAR and STLR semantics used for volatile accesses on AArch64
- OpenJDK jdk21u source: CopyOnWriteArrayList, ConcurrentHashMap and FutureTask: volatile Object[] array, volatile node val/next and volatile int state as real-world volatile uses
- Java Concurrency in Practice (Goetz et al., 2006): §3.1.4 volatile variables and when to use them; §3.4 immutable objects with a volatile reference