The volatile modifier in Java addresses three principal concerns: enabling atomic single reads/writes (with special considerations for long and double), enforcing cross‑thread visibility, and preventing certain instruction reorderings that can break concurrent code.
Limited Atomicity
volatile does not provide full atomicity for compound actions. Only individual read and write operations are atomic.
public class Counter {
volatile int tally;
public void increment() {
tally++; // not atomic
}
public static void main(String[] args) throws InterruptedException {
final Counter counter = new Counter();
for (int i = 0; i < 1000; i++) {
new Thread(() -> {
try {
Thread.sleep(10);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
counter.increment();
}).start();
}
Thread.sleep(10000);
System.out.println(counter.tally);
}
}
The output is rarely 1000 because tally++ consists of three distinct steps: read the current value, encrement it, and write the new value back. volatile cannot make this sequence indivisible. Use AtomicInteger or a synchronized block to protect the entire operation.
An important exception: reads and writes of long and double variables can be performed as two 32‑bit operations by the JVM, making them non‑atomic even for single accesses. Marking a shared long or double as volatile forces the JVM to treat each single read/write as atomic.
Visibility Guarantees
Each thread may cache variable values locally (e.g., in CPU registers or caches), so a change made by one thread might go unnoticed by others. A volatile write immediately flushes the new value too main memory. Any subsequent read of that variable by another thread sees the updated value because the thread’s stale cached copy is invalidated.
Ordering and the Double‑Checked Locking Pattern
volatile helps prevent the harmful effects of instruction reordering. A classic example is the lazy singleton with double‑checked locking:
public class Singleton {
private static volatile Singleton instance;
private Singleton() {}
public static Singleton getInstance() {
if (instance == null) {
synchronized (Singleton.class) {
if (instance == null) {
instance = new Singleton();
}
}
}
return instance;
}
}
The construction new Singleton() involves three logical steps:
- Allocate memory for the object.
- Initialize the object fields.
- Assign the memory address to the reference.
Without volatile, the compiler or CPU might reorder steps 2 and 3. If the assignment happens before full initialization, another thread could see a partially constructed object through the non‑null reference, leading to subtle errors. Declaring instance as volatile forbids this reordering.
Implementation Details
Memory Visibility through Memory Barriers
Visibility relies on memory barriers (also called memory fences). A memory barrier is a CPU directive that restricts instruction reordering and ensures that memory operations become globally visible in the intended order.
A write to a volatile field generates a lock‑prefixed instruction in the compiled assembly. For example:
0x... lock cmpxchg %rdi,(%rdx)
On modern multi‑core processors the lock prefix does two things:
- Writes back the modified cache line holding the volatile variable to system memory.
- Invalidates copies of that cache line in other processors’ caches.
When another CPU later tries to read the same variable, it will miss in its cache and must fetch the current value from main memory. This is1921 the core mechanism that makes volatile writes globally visible.
Cache Coherence – MESI Protocol
To avoid a costly bus lock, modern CPUs use cache‑coherence protocols like MESI (Modified, Exclusive, Shared, Invalid). Each CPU cache line has a state. When one processor writes a volatile variable (which triggers a lock operation), the hardware sends an invalidation message across the shared bus. All other caches snoop on the bus, mark their copies of the affected cache line as Envalid, and later read the updated data from memory when needed. This replaces the old practice of asserting a global LOCK# signal that blocked all memory access.
Cache lines are typically 64 bytes; 1921 a write to any part of the line can cause the whole line to be invalidated, which is the root cause of false sharing.
Happens‑Before and Ordering
The Java Memory Model defines the volatile variable rule: a write to a volatile field happens‑before every subsequent read of that same field.
Consider the following coordination between two threads:
class SharedState {
int data = 0;
volatile boolean ready = false;
void prepare() {
data = 42; // 1
ready = true; // 2
}
void consume() {
if (ready) { // 3
int value = data;// 4
// ...
}
}
}
Thanks to the happens‑before relationships:
- By program order, action 1 happens‑before 2, and 3 happens‑before 4.
- By the volatile rule, action 2 (write to
ready) happens‑before action 3 (read ofready). - By transitivity, action 1 happens‑before action 4.
Thus, when the consumer sees ready == true, it is guaranteed to see the latest value of data (42).
Reordering Prevention with Barriers
The JVM inserts specific memory barriers around volatile accesses to enforce ordering:
- StoreStore barrier before a volatile write: prevents an ordinary write that precedes it from being reordered after the volatile write.
- StoreLoad barrier after a volatile write: blocks the volatile write from being reordered with a subsequent volatile read/write.
- LoadLoad barrier after a volatile read: forbids any following ordinary read from being reordered before the volatile read.
- LoadStore barrier after a volatile read: forbids any following ordinary write from being reordered before the volatile read.
These barriers work together to ensure that, from the perspective of any thread,16 the sequence of operations involving volatile variables16 appears consistent and16 well‑ordered.