要真正理解 volatile 的工作原理,我们必须从 Java 内存模型(JMM)说起。JMM 定义了 Java 程序中各种变量(线程共享变量)的访问规则,以及在 JVM 中将变量存储到内存和从内存中读取变量的底层细节。
现代计算机体系结构中,CPU 的运算速度与内存的访问速度之间存在巨大鸿沟。为了解决这个问题,CPU 引入了多级缓存架构:
JMM 对此进行了抽象,将内存分为两大层次:
重要提示:这里的"工作内存"并不等同于 CPU 缓存,它是 JMM 的一个抽象概念,可能包括寄存器、CPU 缓存等实际硬件结构。
当多个线程访问同一个变量时,实际上每个线程操作的都是自己工作内存中的副本。这就引出了并发编程中最基本的问题:可见性问题。如果线程 A 修改了变量值但未及时同步到主内存,线程 B 读取到的就可能是过期的数据。
volatile 关键字在 Java 中提供了两大核心语义保障:
这两大特性使得 volatile 成为 Java 并发编程中的重要工具,特别是在高性能框架如 Netty、Disruptor 中被广泛应用。
volatile 的可见性保证是通过 JMM 的特殊规则实现的。当一个变量被声明为 volatile 时:
写操作:JVM 会向处理器发送一条 Lock 前缀的指令(在 x86 架构下),确保:
读操作:每次使用 volatile 变量前,JVM 都会强制要求线程从主内存重新读取最新值
考虑以下代码示例:
java复制public class VisibilityDemo {
private static boolean flag = true; // 没有 volatile 修饰
public static void main(String[] args) throws InterruptedException {
new Thread(() -> {
System.out.println("线程1启动");
while(flag) {
// 空循环
}
System.out.println("线程1检测到flag变化");
}).start();
Thread.sleep(1000);
flag = false;
System.out.println("主线程修改flag为false");
}
}
在这个例子中,由于缺少 volatile 修饰,子线程可能会陷入无限循环。这是因为:
加上 volatile 修饰后,JVM 会确保:
指令重排序是现代处理器和编译器用来提高性能的重要手段。在单线程环境下,重排序遵循 as-if-serial 语义,即保证程序执行结果不变。但在多线程环境下,重排序可能导致意想不到的结果。
volatile 通过插入内存屏障来禁止特定类型的指令重排序。JMM 将内存屏障分为四种:
| 屏障类型 | 示例指令序列 | 作用说明 |
|---|---|---|
| LoadLoad | Load1; LoadLoad; Load2 | 确保 Load1 的数据装载先于 Load2 及其后所有装载指令 |
| StoreStore | Store1; StoreStore; Store2 | 确保 Store1 的数据对其他处理器可见先于 Store2 及其后所有存储指令 |
| LoadStore | Load1; LoadStore; Store2 | 确保 Load1 的数据装载先于 Store2 及其后所有存储指令 |
| StoreLoad | Store1; StoreLoad; Load2 | 确保 Store1 的数据对其他处理器可见先于 Load2 及其后所有装载指令。全能屏障,开销最大 |
在 volatile 写操作前后,JMM 会分别插入 StoreStore 和 StoreLoad 屏障;在 volatile 读操作前后,会分别插入 LoadLoad 和 LoadStore 屏障。
JMM 通过 Happens-Before 规则来定义操作之间的可见性关系。对于 volatile 变量:
这意味着 volatile 变量的写操作之前的任何操作,对后续读这个 volatile 变量的线程都是可见的。
虽然 volatile 提供了可见性和有序性保证,但它不能保证复合操作的原子性。这是很多开发者容易误解的地方。
i++ 这个看似简单的操作,实际上包含三个步骤:
考虑以下代码:
java复制public class AtomicityDemo {
private static volatile int count = 0;
public static void main(String[] args) throws InterruptedException {
Thread[] threads = new Thread[10];
for (int i = 0; i < 10; i++) {
threads[i] = new Thread(() -> {
for (int j = 0; j < 1000; j++) {
count++;
}
});
threads[i].start();
}
for (Thread t : threads) t.join();
System.out.println("Final count: " + count);
}
}
即使 count 被声明为 volatile,最终结果也很可能小于 10000。这是因为 volatile 不能保证 count++ 这个复合操作的原子性。
对于需要原子性保证的场景,可以考虑:
双重检查锁定(DCL, Double-Checked Locking)是一种常见的单例模式实现方式,它很好地展示了 volatile 的关键作用。
java复制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;
}
}
对象初始化操作 instance = new Singleton() 实际上包含三个步骤:
如果没有 volatile 修饰,JVM 可能会进行指令重排序,将步骤2和步骤3颠倒。这会导致其他线程可能看到一个未完全初始化的对象。
volatile 通过插入内存屏障来禁止这种重排序:
这样就能保证对象完全初始化后才将引用赋值给 instance 变量。
根据 volatile 的特性,它最适合以下场景:
状态标志:简单的布尔状态标志,如控制线程启停
java复制volatile boolean running = true;
public void stop() {
running = false;
}
一次性安全发布:确保对象构造完成后才对外可见
java复制class ResourceHolder {
private volatile Resource resource;
public Resource getResource() {
Resource result = resource;
if (result == null) {
synchronized(this) {
result = resource;
if (result == null) {
resource = result = new Resource();
}
}
}
return result;
}
}
独立观察:定期发布观察结果供程序其他部分使用
java复制class TemperatureMonitor {
private volatile double currentTemperature;
public void monitor() {
while (true) {
currentTemperature = readTemperature();
Thread.sleep(1000);
}
}
public double getTemperature() {
return currentTemperature;
}
}
读多写少:结合 CAS 操作实现高效并发
java复制class Counter {
private volatile int value;
public int increment() {
int oldValue = value;
while (!compareAndSet(oldValue, oldValue + 1)) {
oldValue = value;
}
return oldValue + 1;
}
private boolean compareAndSet(int expected, int newValue) {
// 原子CAS操作
}
}
虽然 volatile 比 synchronized 更轻量级,但仍然有一定的性能开销:
在 x86 架构下,volatile 写操作的开销主要来自:
实际测试表明,在单线程环境下:
因此,应该根据实际需求合理使用 volatile,避免过度使用导致性能下降。
很多开发者误以为 volatile 可以完全替代 synchronized。实际上:
java复制volatile int[] array = new int[10];
这种情况下,volatile 只能保证 array 引用的可见性,不能保证数组元素的可见性。如果需要保证数组元素的可见性,可以考虑:
java复制class Data {
int value;
}
volatile Data data;
volatile 只能保证 data 引用的可见性,不能保证 data.value 的可见性。如果需要保证对象字段的可见性,应该:
| 特性 | volatile | synchronized | Lock | Atomic变量 |
|---|---|---|---|---|
| 可见性保证 | ✅ | ✅ | ✅ | ✅ |
| 有序性保证 | ✅ | ✅ | ✅ | ✅ |
| 原子性保证 | ❌ | ✅ | ✅ | ✅ |
| 线程阻塞 | ❌ | ✅ | ✅ | ❌ |
| 适用场景 | 状态标志 | 复合操作 | 复杂同步 | 简单原子操作 |
在实际开发中,应该根据具体需求选择合适的同步机制:
在 Netty 的事件循环实现中,大量使用了 volatile 来保证状态标志的可见性。例如,在 SingleThreadEventExecutor 类中:
java复制private volatile boolean running;
private volatile int state = ST_NOT_STARTED;
这些 volatile 变量用于控制事件循环的启动、关闭等状态变更,确保一个线程的状态修改对其他线程立即可见。
高性能并发框架 Disruptor 使用 volatile 结合内存屏障来实现无锁并发。例如,在 Sequence 类中:
java复制class Sequence {
private volatile long value;
// 使用 Unsafe 实现高效的 volatile 读写
public long get() {
return value;
}
public void set(long value) {
this.value = value;
}
}
Disruptor 通过精心设计的内存屏障插入,在保证正确性的同时实现了极高的性能。
不同 JVM 实现和硬件架构下,volatile 的具体实现可能有所不同。以 HotSpot JVM 在 x86 架构下的实现为例:
写操作:
读操作:
在 ARM 等弱内存模型架构下,JVM 会插入更多内存屏障指令来保证 volatile 语义。
使用 JVM 的 -XX:+UnlockDiagnosticVMOptions -XX:+PrintAssembly 参数可以查看 volatile 操作生成的汇编指令:
code复制lock addl $0x0,(%rsp) ; *putstatic instance
这些工具可以监控多线程程序中 volatile 变量的状态变化,帮助验证可见性保证。
使用 CountDownLatch 等工具编写多线程测试用例,验证 volatile 的行为是否符合预期:
java复制public class VolatileTest {
volatile int sharedValue;
@Test
public void testVisibility() throws InterruptedException {
final int THREAD_COUNT = 10;
CountDownLatch latch = new CountDownLatch(THREAD_COUNT);
for (int i = 0; i < THREAD_COUNT; i++) {
new Thread(() -> {
sharedValue = ThreadLocalRandom.current().nextInt();
latch.countDown();
}).start();
}
latch.await();
System.out.println("Final value: " + sharedValue);
}
}
A1:
A2:
A3:
A4:
深入理解 volatile 需要掌握 JMM 的 happens-before 关系。除了 volatile 规则外,JMM 还定义了以下 happens-before 规则:
这些规则共同构成了 Java 并发编程的基础保证。