“What does volatile do in Java?” and “Why might this loop never stop?” are standard concurrency interview questions. The interviewer usually shows a boolean running flag set by one thread and read in a loop by another, and asks why the loop can spin forever even after the flag is set. The deeper question is whether you can explain the Java Memory Model’s happens-before rule, which is the only reliable way to reason about what one thread sees of another’s writes.
Visibility bugs are dangerous because they are rare, depend on hardware and JIT behaviour, and vanish when you add a print statement to debug them.
Before you start
You should know how to create threads, what synchronized does at a basic level, and that modern CPUs have per-core caches. You do not need to know CPU memory-barrier instructions; the Java Memory Model abstracts them. Knowing that the JIT compiler can reorder and hoist code is enough.
The short answer
Without synchronization, the Java Memory Model does not guarantee that a write by one thread ever becomes visible to another, nor that writes are seen in program order. A happens-before relationship provides that guarantee: if action A happens-before action B, B sees A’s effects and everything before A. Declaring a field volatile creates such an edge between a write and every later read of that field, and also forbids reordering around it. volatile does not make compound operations such as count++ atomic; use locks or atomics for those.
How it works
Consider the classic stop-flag bug:
public class Worker implements Runnable {
private boolean running = true; // not volatile
public void run() {
while (running) {
doWork();
}
}
public void stop() { running = false; }
}The JIT compiler is allowed to treat running as a value that this thread never changes. If doWork() is inlined and does not synchronize, it can hoist the read out of the loop, effectively compiling if (running) while (true) doWork();. The other thread’s write may sit in a store buffer, or be visible in memory yet never re-read. Either way, the loop can run forever.
JLS chapter 17 defines happens-before edges, including:
- Program order: each action in a thread happens-before later actions in that same thread.
- Monitor lock: unlocking a monitor happens-before every later lock of the same monitor.
- Volatile: a write to a volatile field happens-before every subsequent read of that field.
- Thread start and join:
thread.start()happens-before the started thread’s actions; a thread’s actions happen-before another thread returns fromjoin()on it. - Transitivity: if A happens-before B and B happens-before C, A happens-before C.
The java.util.concurrent classes document additional edges: placing an object in a BlockingQueue or ConcurrentHashMap happens-before its retrieval, and submitting a task to an executor happens-before the task runs.
Transitivity is what makes “safe publication” work. Writes made before a volatile write are visible to any thread that reads the volatile and sees the new value:
class Holder {
private Config config; // plain field
private volatile boolean ready;
void publish() {
config = loadConfig(); // 1: plain write
ready = true; // 2: volatile write
}
Config read() {
if (ready) { // 3: volatile read sees true
return config; // 4: guaranteed to see the write from step 1
}
return null;
}
}Step-by-step walkthrough
Step 1: Find the shared mutable state
List every field that one thread writes and another reads. Fields that are written once before threads start, or confined to a single thread, need nothing further.
Step 2: Pick the edge that fits
A single flag or reference that is written and read independently: make it volatile.
private volatile boolean running = true;A counter or check-then-act: use AtomicLong, LongAdder or a lock.
private final AtomicLong processed = new AtomicLong();
processed.incrementAndGet();Several fields that must change together: guard them all with one lock.
Step 3: Publish objects safely
An object reference handed to another thread through a plain field can be seen before the constructor’s writes are visible, so the other thread may observe default values. Safe ways to publish: store it in a volatile or final field, a properly locked field, a concurrent collection, or hand it to an executor. Objects whose fields are all final get an extra guarantee: any thread that sees the reference sees the final fields as initialised, provided this did not escape during construction.
Step 4: Get double-checked locking right, or avoid it
class Lazy {
private volatile ExpensiveService instance; // volatile is essential
ExpensiveService get() {
ExpensiveService local = instance;
if (local == null) {
synchronized (this) {
local = instance;
if (local == null) {
instance = local = new ExpensiveService();
}
}
}
return local;
}
}Without volatile, a thread can see a non-null reference to an object whose fields are not yet visible. A simpler alternative is the holder-class idiom: a private static class Holder { static final ExpensiveService INSTANCE = new ExpensiveService(); }, which relies on class initialization being thread-safe.
Worked scenario
A log-shipping service has a background thread that batches lines and flushes them. During shutdown, the main thread calls shipper.stop(), which sets a plain boolean stopped = true, and then waits for the background thread with join(). In production, about one deployment in twenty hangs until Kubernetes kills the pod. Locally, it never reproduces, and adding a debug System.out.println inside the loop makes the problem disappear, because println synchronizes internally and incidentally forces the flag to be re-read.
The background loop:
while (!stopped) {
String line = buffer.poll(); // ConcurrentLinkedQueue: lock-free, no blocking
if (line != null) batch.add(line);
if (batch.size() >= 500) flush();
}
flush();The fix is one keyword, plus a design improvement so the thread also wakes promptly:
private volatile boolean stopped;
// loop body uses a blocking queue with a timeout so it does not spin
String line = buffer.poll(100, TimeUnit.MILLISECONDS); // LinkedBlockingQueueNow the write in stop() happens-before the next read of stopped in the loop, and the timed poll keeps CPU usage low. An alternative is using thread interruption as the stop signal, since interrupting a thread happens-before the interrupted thread detects the interrupt.
Common mistake
- “volatile makes operations atomic.”
volatile int count; count++still loses updates. - “The CPU cache coherence protocol makes visibility automatic.” The JIT can hoist reads and reorder writes regardless of hardware; only the memory model’s rules count.
- “It works on my machine.” x86 has a relatively strong memory model; ARM servers such as AWS Graviton reorder more, so bugs appear after migration.
- Using volatile for multi-field invariants. Each field is visible individually, but readers can see a mix of old and new values.
- Relying on println or sleep to fix a race. They change timing or incidentally synchronize; they do not create a documented edge for your field.
Verify the behavior
This program may hang without volatile on a server JIT, though it is not guaranteed to on every run, which is exactly the problem with visibility bugs:
public class StopFlag {
static boolean running = true; // add volatile to fix
public static void main(String[] args) throws InterruptedException {
Thread worker = new Thread(() -> {
long spins = 0;
while (running) { spins++; }
System.out.println("stopped after " + spins);
});
worker.start();
Thread.sleep(1000); // let the loop get JIT-compiled
running = false;
worker.join(3000);
System.out.println(worker.isAlive() ? "still running: visibility bug" : "worker exited");
System.exit(0);
}
}Run with java StopFlag.java. On HotSpot with the C2 compiler, the non-volatile version commonly prints “still running”. For systematic testing, the OpenJDK jcstress project runs millions of interleavings and reports which outcomes occur.
Follow-up questions
What is the difference between volatile and synchronized? volatile provides visibility and ordering for single reads and writes of one variable without blocking. synchronized adds mutual exclusion for a whole block and visibility for everything inside it.
What does final guarantee across threads? If an object is constructed without leaking this, any thread that obtains a reference sees its final fields fully initialised, even through a data race.
Are long and double writes atomic? Not necessarily for non-volatile fields; the JLS allows a 64-bit write to be split into two 32-bit halves. Volatile long and double reads and writes are always atomic. Modern 64-bit JVMs do write them atomically in practice.
What are VarHandles? Java 9’s API for fine-grained memory ordering on fields and array elements, offering plain, opaque, acquire/release and volatile modes. Most application code never needs them.
Interview exercise
A cache holds private Map<String, Price> prices = new HashMap<>();. A refresher thread builds a new HashMap, fills it, and assigns it to prices. Request threads only call prices.get(sku). What can go wrong, and what is the minimal fix?
Answer and reasoning
Two things can go wrong. Request threads may keep reading the old map indefinitely, because nothing forces them to re-read the field. Worse, a request thread might see the new map reference before the writes that filled it are visible, observing an incompletely populated or internally inconsistent HashMap.
The minimal fix is private volatile Map<String, Price> prices; and building the new map fully before the assignment. The volatile write happens-before every later volatile read, and by transitivity all the puts made before the assignment are visible to readers that see the new reference. Because the map is never modified after publication, a plain HashMap is safe to read concurrently; wrapping it with Map.copyOf makes that immutability explicit. This copy-on-refresh pattern avoids locks on the read path entirely.
Continue learning
Practise more with the Java interview questions and the Java MCQs. Related notes: synchronized blocks and shared invariants, immutable objects and concurrent collections. The authoritative rules are in Java Language Specification chapter 17: Threads and Locks.