Java中的volatile关键字
在 Java 中,volatile 是一个轻量级的同步机制,主要用于处理多线程并发中的可见性和有序性问题,但它不保证原子性。
要彻底理解 volatile,我们需要从它解决的三个核心并发特性入手:可见性、有序性和原子性。
一、 volatile 的三大特性
1. 保证“可见性”(Visibility)
什么是可见性?
在 Java 内存模型(JMM)中,所有变量都存储在“主内存”中,每个线程都有自己的“工作内存”(本地缓存)。线程读写变量时,会先从主内存拷贝到工作内存,修改后再写回主内存。这就导致一个线程修改了变量,另一个线程可能还在使用自己工作内存中的旧值。
volatile 的作用:
当一个变量被声明为 volatile 时,它会保证:
- 写操作:一旦某个线程修改了该变量的值,新值会立即被强制刷新到主内存中。
- 读操作:任何线程读取该变量时,都会强制使自己工作内存中的缓存失效,直接去主内存中读取最新值。
代码示例(可见性问题):
public class VolatileDemo {
// 如果不加 volatile,主线程对 flag 的修改,子线程可能永远看不到,导致死循环
private static volatile boolean flag = false;
public static void main(String[] args) throws InterruptedException {
new Thread(() -> {
System.out.println("子线程启动,等待 flag 变为 true...");
while (!flag) {
// 如果没有 volatile,这里会陷入死循环
}
System.out.println("子线程检测到 flag 变为 true,结束!");
}).start();
Thread.sleep(1000); // 主线程睡1秒
flag = true; // 主线程修改 flag
System.out.println("主线程已将 flag 改为 true");
}
}
2. 禁止“指令重排序”保证“有序性”(Ordering)
什么是指令重排序?
编译器和 CPU 为了提高执行效率,在不改变单线程执行结果的前提下,可能会打乱代码的执行顺序。但在多线程环境下,这会导致严重的问题。
volatile 的作用:volatile 底层通过插入内存屏障(Memory Barrier)来禁止特定类型的指令重排序。
- 写
volatile变量时:在写指令前插入 StoreStore 屏障,写指令后插入 StoreLoad 屏障。 - 读
volatile变量时:在读指令后插入 LoadLoad 和 LoadStore 屏障。
简单来说,它确保了在volatile变量写操作之前的代码,绝对不会被重排序到写操作之后。
3. 不保证“原子性”(Atomicity)
什么是原子性?
一个操作不能被中断,要么全部执行完毕,要么完全不执行。
volatile 的局限性:volatile 无法保证复合操作的原子性。例如 i++。i++ 实际上包含了三个步骤:
- 读取
i的值到寄存器。 - 对
i加 1。 - 将新值写回内存。
如果两个线程同时执行 i++,即使 i 是 volatile 的,它们也可能同时读到相同的旧值,各自加 1 后写回,导致结果少加。
代码示例(非原子性):
public class VolatileAtomicityDemo {
private static volatile int count = 0;
public static void main(String[] args) throws InterruptedException {
Runnable task = () -> {
for (int i = 0; i < 1000; i++) {
count++; // 非原子操作
}
};
Thread t1 = new Thread(task);
Thread t2 = new Thread(task);
t1.start();
t2.start();
t1.join();
t2.join();
// 预期结果是 2000,但实际结果通常小于 2000
System.out.println("最终 count 值: " + count);
}
}
解决非原子性: 使用 synchronized、Lock,或者使用 java.util.concurrent.atomic 包下的原子类(如 AtomicInteger)。
二、 volatile 的底层原理
volatile 的底层实现依赖于硬件级别的支持(如 x86 架构下的 lock 前缀指令)。
当对 volatile 变量进行写操作时,CPU 会执行带有 lock 前缀的指令,这会引发两件事:
- 将当前处理器缓存行的数据写回系统内存。
- 这个写回内存的操作会使得在其他 CPU 里缓存了该内存地址的数据无效(依靠缓存一致性协议,如 MESI 协议)。
三、 volatile 的经典应用场景
1. 状态标志位(Status Flag)
用于作为一个布尔状态标志,用于指示发生了一个重要的一次性事件,例如完成初始化或请求停机。
volatile boolean shutdownRequested = false;
public void shutdown() {
shutdownRequested = true;
}
public void doWork() {
while (!shutdownRequested) {
// 执行业务逻辑
}
}
2. 双重检查锁定(Double-Checked Locking,DCL)实现单例模式
这是 volatile 禁止指令重排序最经典的例子。
public class Singleton {
// 必须加 volatile
private static volatile Singleton instance;
private Singleton() {}
public static Singleton getInstance() {
if (instance == null) { // 第一次检查
synchronized (Singleton.class) {
if (instance == null) { // 第二次检查
// instance = new Singleton() 实际上由三步组成:
// 1. 分配内存空间
// 2. 初始化对象
// 3. 将 instance 引用指向分配的内存地址
// 如果没有 volatile,可能会发生指令重排序变成 1 -> 3 -> 2
// 此时另一个线程执行到第一次检查,发现 instance != null,直接返回,
// 但此时对象还没初始化完成,导致使用时报错!
instance = new Singleton();
}
}
}
return instance;
}
}
四、 volatile 与 synchronized 的对比
| 特性 | volatile |
synchronized |
|---|---|---|
| 性质 | 轻量级,修饰变量 | 重量级,修饰方法或代码块 |
| 是否阻塞 | 不会引起线程阻塞 | 可能会引起线程阻塞和上下文切换 |
| 可见性 | 保证 | 保证 |
| 有序性 | 保证(禁止指令重排序) | 保证(通过单线程执行保证相对有序) |
| 原子性 | 不保证 | 保证 |
五、 总结
- 用一句话概括:
volatile告诉编译器和 CPU:“这个变量是多线程共享的,不要把它缓存到寄存器,每次都必须去主内存读写,并且不要打乱关于它的指令顺序。” - 适用前提: 对变量的写操作不依赖于当前值(例如
i++就不行,但flag = true可以),或者能确保只有单一线程修改变量的值。