In our previous discussion on Java's memory model, we explored the three fundamental characteristics of threads and the happens-before principle. This article focuses on the volatile keyword, examining its underlying mechanisms and practical applications. By the end, you'll have a deeper understanding of how volatile works and its appropriate use cases.
The volatile keyword in Java provides a lightweight synchronization mechanism. While often misunderstood and less commonly used than synchronized blocks, volatile can offer performance benefits in specific scenarios where heavy-weight locking would be excessive.
Before diving in, consider these questions:
- How exactly does volatile ensure synchronization of shared variables?
- Why is the i++ operation not atomic from the JVM's perspective?
Volatile Read/Write Synchronization Principles
When a variable is declared as volatile, it exhibits three key properties:
- Visibility: Ensures the variable is visible to all threads.
- Atomicity: Provides atomicity only for individual read/write operations on the volatile variable (not compound operations).
- Ordering: Prevents instruction reordering optimizations for variables marked as volatile.
Happens-Before and Visibility Guarantees
Volatile variable write-read operations enable communication between threads. The happens-before relationship in Java's memory model provides the visibility guarantees that answer our first question about how volatile ensures synchronization.
Let's review the relevant happens-before principles:
- Program Order Rule: If action A appears before action B in the program, then A happens-before B in the same thread.
- Volatile Variable Rule: A write to a volatile variable happens-before every subsequent read of the same volatile variable.
- Transitivity Rule: If A happens-before B and B happens-before C, then A happens-before C.
Consider the following example:
public class VolatileExample {
private int a = 0;
private volatile int b = 0;
public void write() {
a = 1; // Operation 1
b = 2; // Operation 2
}
public void read() {
int i = b; // Operation 3
int j = a; // Operation 4
}
}
Imagine Thread A calls write() followed by Thread B calling read(). The happens-before relationships are:
- Based on program order: 1 happens-before 2 and 3 happens-before 4.
- Based on volatile rule: 2 happens-before 3.
- By transitivity: 1 happens-before 4 and 2 happens-before 4.
In this scenario, Thread B will see the values written by Thread A for both variables (even though 'a' is not volatile).
Conversely, if Thread B reads before Thread A writes, no happens-before relationships exist, and Thread B may not see the updated values.
This leads to the formal definition of volatile variables:
- When writing to a volatile variable, the JVM flushes the shared variable from the thread's local memory to main memory.
- When reading from a volatile variable, the JVM invalidates the thread's local memory, forcing the thread to read from main memory.
Why Non-Volatile Variables Become Visible
You might wonder why variable 'a' (non-volatile) becomes visible in our example. This occurs due to the happens-before relationships established by the volatile variable 'b' and the program order rules.
Volatile's Reordering Restrictions
As mentioned, volatile variables prevent instruction reordering. This occurs at both compiler and processor levels to ensure memory semantics.
Compiler Reordering Rules
The JVM enforces specific rules for volatile variable reordering:
| First Operation | Second Operation |
|---|---|
| Normal Read/Write | Normal Read/Write |
| Normal Read/Write | - |
| Volatile Read | NO |
| Volatile Write | - |
These rules can be summarized as:
- When the second operation is a volatile write, no reordering can occur before it.
- When the first operation is a volatile read, no reordering can occur after it.
- When a volatile write is followed by a volatile read, no reordering is allowed.
Processor Reordering and Memory Bariers
To enforce these rules, the JVM inserts memory barriers into the instruction sequence:
- StoreStore barrier before each volatile write.
- StoreLoad barrier after each volatile write.
- LoadLoad barrier before each volatile read.
- LoadStore barrier after each volatile read.
These barriers ensure correct ordering across all processor platforms and programs.
The Purpose of Reordering Restrictions
These restrictions ensure the happens-before relationships are maintained. In our example, the volatile guarantees preserve the order of operations 1, 2, 3, and 4.
Practical Applications of Volatile
The Non-Atomic Nature of i++
As noted, volatile only guarantees atomicity for individual read/write operations. Compound operations like i++ are not atomic. Consider this example:
public class CounterExample {
private volatile int count;
public void increment() {
count++; // Not atomic!
}
public void setValue(int value) {
this.count = value; // Atomic
}
}
The increment() operation is actually implemented as multiple steps:
public void increment() {
int temp = getCount();
temp = temp + 1;
setCount(temp);
}
This multi-step process explains why i++ is not thread-safe even with volatile.
Guidelines for Using Volatile
For volatile variables to provide thread safety, two conditions must be met:
- The write operation must not depend on the variable's current value.
- The variable must not participate in invariants with other variables.
In other words, values written to volatile variables should be independent of program state.
Practical Example: Thread Cancellation
One common use case for volatile is thread cancellation:
public class StoppableTask implements Runnable {
private volatile boolean running = true;
public void stop() {
this.running = false;
}
public void run() {
while (running) {
// Task logic
}
}
}
Using volatile here ensures immediate visibility of the state change across all threads.
By understanding the principles and proper usage of volatile, you can write more efficient and readable concurrent code. When used correctly within its constraints, volatile can sometimes replace synchronized blocks for simpler synchronization needs.