Definition
- Visibility – a
volatilewrite flushes the value to main memory and invalidates other CPUs' caches. Avolatileread always fetches from main memory, never from a CPU register or cache line. - Happens-before – a write to a volatile variable happens-before every subsequent read of that variable by any thread. All memory writes performed before the volatile write become visible to the thread that performs the subsequent volatile read.
- No atomicity –
volatile int counter; counter++is still three operations (read, increment, write). Volatile ensures each operation accesses main memory, but two threads can still read the same value between the read and write steps. - Memory barrier – on x86, a volatile write inserts a
StoreLoadbarrier (MFENCE or LOCK prefix). This prevents the CPU from reordering instructions across the barrier.
Without volatile, each teller has a personal notepad with Rajesh's balance. Teller-1 updates the balance on their notepad. Teller-2's notepad still shows the old value because it was never refreshed. With volatile, there is one shared whiteboard (main memory). Every write goes to the whiteboard. Every read comes from the whiteboard. All tellers see the latest value. But if two tellers read the whiteboard simultaneously, both increment, and both write, one increment is lost. The whiteboard ensures visibility, not atomicity.
Understanding the Concepts
What does volatile guarantee?
volatile provides two guarantees: visibility and ordering. It does not provide atomicity.
How does visibility work without volatile?
Without volatile, the JIT compiler may optimize a variable read into a CPU register. The thread reads the register repeatedly and never checks main memory.
package com.bank.core;
public class VisibilityProblem {
private static boolean running = true; // NOT volatile
public static void main(String[] args) throws InterruptedException {
Thread worker = new Thread(() -> {
// JIT may hoist: if (running) while(true) { ... }
while (running) {
// busy work
}
System.out.println("Worker stopped");
});
worker.start();
Thread.sleep(1000);
running = false; // written to main memory, but worker may never see it
System.out.println("Main set running=false");
worker.join(2000);
if (worker.isAlive()) {
System.out.println("Worker is STILL running! Visibility bug.");
}
}
}
How does volatile fix the visibility problem?
package com.bank.core;
public class VisibilityFix {
private static volatile boolean running = true; // volatile!
public static void main(String[] args) throws InterruptedException {
Thread worker = new Thread(() -> {
while (running) { // volatile read: checks main memory each time
// busy work
}
System.out.println("Worker stopped");
});
worker.start();
Thread.sleep(1000);
running = false; // volatile write: flushes to main memory
worker.join();
}
}
Worker stopped
Why does volatile NOT make counter++ atomic?
counter++ is three separate operations: read the value, increment it, and write it back. Volatile ensures each individual read or write accesses main memory, but two threads can still interleave between steps.
package com.bank.core;
public class VolatileCounterRace {
private static volatile int counter = 0;
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
for (int i = 0; i < 100000; i++) counter++; // NOT atomic!
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 100000; i++) counter++;
});
t1.start(); t2.start();
t1.join(); t2.join();
System.out.println("Counter: " + counter);
// Expected: 200000. Actual: less (e.g., 156789)
}
}
Counter: 156789
What is the happens-before relationship?
The happens-before relationship guarantees that all writes before a volatile write are visible to any thread that subsequently reads the volatile variable.
package com.bank.core;
public class HappensBeforeDemo {
private static int data = 0; // non-volatile
private static volatile boolean ready = false; // volatile
public static void main(String[] args) throws InterruptedException {
Thread writer = new Thread(() -> {
data = 42; // write to non-volatile
ready = true; // volatile write: establishes happens-before
});
Thread reader = new Thread(() -> {
while (!ready) { } // volatile read: sees everything before the write
System.out.println("Data: " + data); // guaranteed to see 42
});
writer.start();
reader.start();
writer.join();
reader.join();
}
}
Data: 42
When should you use volatile?
| Use case | Volatile? | Why |
|---|---|---|
| Shutdown flag: volatile boolean running | Yes | Single writer, many readers, no compound op |
| Latest price: volatile BigDecimal price | Yes | One writer publishes, many readers consume |
| Double-checked locking: volatile Singleton | Yes | Prevents seeing partially constructed object |
| Counter: counter++ | No | Compound operation, use AtomicInteger |
| Balance check+debit | No | Check-then-act, use synchronized |
What should a senior engineer know?
- Memory barriers: on x86, a volatile write inserts a StoreLoad barrier. On ARM, both LoadLoad and StoreStore barriers are needed. The JVM handles this automatically.
- volatile vs synchronized: volatile is lighter (no monitor acquisition) but weaker (no atomicity, no mutual exclusion). synchronized provides all three guarantees.
- volatile long/double: on 32-bit JVMs, reading a non-volatile
longordoubleis not atomic (it is two 32-bit reads). Declaring themvolatileguarantees atomic read/write. On 64-bit JVMs, this is already atomic in practice.
Visual Overview
Without volatile:
┌────────────┐ ┌────────────┐
│ Thread-1 │ │ Thread-2 │
│ CPU Cache │ │ CPU Cache │
│ running=true│ │ running=true│ (stale!)
└──────┬─────┘ └──────┬─────┘
│ │
▼ ▼
┌─────────────────────────────────────────┐
│ Main Memory │
│ running = false │
└─────────────────────────────────────────┘
Thread-2 never reads main memory: infinite loop!
With volatile:
┌────────────┐ volatile ┌────────────┐
│ Thread-1 │ write │ Thread-2 │
│ writes false│──────────▶ │ reads false │
└────────────┘ main mem └────────────┘
Every volatile write flushes to main memory.
Every volatile read fetches from main memory.
Key Takeaway
volatile guarantees visibility: every write goes to main memory and every read comes from main memory. Without it, the JIT compiler may cache a variable in a CPU register, and a reading thread never sees updates from another thread. The classic use case is a volatile boolean running shutdown flag.
However, volatile does NOT guarantee atomicity. volatile int counter; counter++ is still a race condition because it is three operations (read, increment, write) and another thread can interleave. For counters, use AtomicInteger. For compound check-then-act operations, use synchronized.
Remember one sentence: volatile ensures every thread sees the latest value (visibility), but it does not make compound operations like counter++ atomic.
---