volatile 是 Java 并发编程中一个非常重要但相对底层的关键字,它主要用于修饰被多个线程共享的变量,要理解 volatile,我们首先需要了解 Java 内存模型以及它试图解决的问题。
为什么需要 volatile?—— JMM 与可见性问题
1 Java 内存模型
为了提高性能,现代计算机体系结构在硬件层面做了很多优化,其中一个重要的优化是 高速缓存,CPU 的运算速度远快于主内存的读写速度,因此每个 CPU 核心都有自己的高速缓存(Cache),线程在运行时,会把主内存中的数据拷贝一份到自己的工作内存(即高速缓存或寄存器)中。
这就带来了问题:
-
可见性:当某个线程修改了一个共享变量的值,这个新值并不会立刻同步到主内存中,其他线程在使用这个变量时,可能直接从自己的工作内存中读取,而不是从主内存读取,这就导致了它们读取到的可能是一个过期的、不一致的值。
-
指令重排:为了优化性能,编译器和处理器可能会对输入的代码进行乱序执行优化,即改变代码的执行顺序,在单线程环境下,这不会影响最终结果,但在多线程环境下,这种重排可能会导致意想不到的后果。
2 volatile 的核心作用:保证可见性
volatile 关键字就是用来解决 可见性 问题的。
当一个变量被声明为 volatile 后,它具有以下两个特性:
-
保证可见性:当一个线程修改了一个
volatile变量,新值会立刻同步到主内存中,当其他线程需要读取这个volatile变量时,会直接从主内存中读取,而不是从各自的工作内存中读取,这就保证了线程间变量的可见性。 -
禁止指令重排(部分有序性):
volatile关键字会插入一个“内存屏障”(Memory Barrier),内存屏障可以禁止其前后的指令进行重排序优化,从而保证了程序的执行顺序。
volatile 的两个核心语义详解
1 保证可见性
工作原理:
volatile 的可见性是通过内存屏障来实现的,当一个写操作发生在一个 volatile 变量上时,JVM 会在写操作后插入一个 StoreStore 和 StoreLoad 屏障,当一个读操作发生在一个 volatile 变量上时,JVM 会在读操作前插入一个 LoadLoad 和 LoadStore 屏障。
这些屏障确保了:
- 写操作后的任何普通写操作都不能重排到
volatile写操作之前。 - 读操作前的任何普通读操作都不能重排到
volatile读操作之后。 - 保证了
volatile变量的写对后续的任何读(无论是否volatile)都是可见的。
示例:
class Worker implements Runnable {
// 使用 volatile 修饰,保证 flag 的可见性
private volatile boolean flag = true;
public void stop() {
this.flag = false;
}
@Override
public void run() {
System.out.println("Worker thread started.");
while (flag) {
// do something...
// 如果没有 volatile,线程可能一直从自己的工作内存中读取 flag=true,
// 导致即使 main 线程调用了 stop(),也无法及时退出循环。
}
System.out.println("Worker thread stopped.");
}
}
public class VolatileDemo {
public static void main(String[] args) throws InterruptedException {
Worker worker = new Worker();
new Thread(worker).start();
Thread.sleep(1000); // 主线程休眠1秒
System.out.println("Main thread is about to stop the worker...");
worker.stop(); // 修改 flag
System.out.println("Main thread has stopped the worker.");
}
}
在这个例子中,如果没有 volatile,Worker 线程可能永远无法感知到 flag 的变化,导致程序无法正常退出,加上 volatile 后,main 线程对 flag 的修改会立刻对 Worker 线程可见。
2 禁止指令重排
volatile 关键字可以保证变量的读写操作不会被重排序,这在双重检查锁定实现的单例模式中至关重要。
经典应用:双重检查锁定单例模式
public class Singleton {
// volatile 是必须的!
private static volatile Singleton instance;
private Singleton() {
// private constructor
}
public static Singleton getInstance() {
if (instance == null) { // 第一次检查
synchronized (Singleton.class) {
if (instance == null) { // 第二次检查
instance = new Singleton();
}
}
}
return instance;
}
}
为什么需要 volatile?
问题出在 instance = new Singleton(); 这一行代码,它并非一个原子操作,大致可以分解为三个步骤:
- 分配内存:
memory = allocate()。 - 初始化对象:
ctorInstance(memory)(填充对象的各个字段)。 - 建立引用:
instance = memory(将instance引用指向分配的内存地址)。
在没有 volatile 的情况下,由于指令重排,执行顺序可能会变成 1 -> 3 -> 2。
这会导致什么问题?
- 假设线程 A 进入
synchronized代码块,执行了1和3,但还没执行2。instance已经不为null了。 - 就在此时,线程 B 调用
getInstance(),它执行第一次检查if (instance == null),由于instance已经不为null,条件判断为false,线程 B 直接返回instance。 - 线程 B 拿到的
instance是一个 未初始化完成的对象!如果此时线程 B 试图访问instance的任何字段,都可能会导致NullPointerException或其他不可预知的错误。
volatile 如何解决这个问题?
volatile 关键字会插入内存屏障,确保 instance = new Singleton(); 的三个步骤不会被重排序,必须按照 1 -> 2 -> 3 的顺序完成,这样,在线程 A 完成整个操作之前,线程 B 看到的 instance 始终是 null,从而保证了线程安全。
volatile 的局限性:不保证原子性
volatile 只能保证可见性和禁止指令重排,但它 不能保证复合操作的原子性。
原子性:一个或多个操作要么全部执行且执行的过程不会被任何因素打断,要么就都不执行。
示例:
class Counter {
// volatile 保证了 count 的可见性,但不能保证 count++ 的原子性
private volatile int count = 0;
public void increment() {
count++; // 这不是原子操作!
}
public int getCount() {
return count;
}
}
count++ 实际上包含三个步骤:
- 读取
count的值。 - 将值加 1。
- 将新值写回
count。
即使 count 是 volatile,也只能保证第 3 步写回主内存对其他线程是可见的,但在多线程环境下,仍然会发生以下情况:
- 线程 A 读取
count(值为 10)。 - 线程 B 读取
count(值也为 10)。 - 线程 A 执行加 1,准备写回 11。
- 线程 B 也执行加 1,准备写回 11。
- 线程 A 写回 11。
- 线程 B 写回 11。
count 的结果是 11,而不是预期的 12,这就是典型的 “丢失更新” 问题。
如何保证原子性?
对于需要原子性的操作,应该使用 synchronized 关键字或者 java.util.concurrent.atomic 包下的原子类(如 AtomicInteger)。
// 使用 AtomicInteger 保证原子性
class AtomicCounter {
private final AtomicInteger count = new AtomicInteger(0);
public void increment() {
count.incrementAndGet(); // 底层使用 CAS 机制,是原子操作
}
}
volatile vs. synchronized
| 特性 | volatile |
synchronized |
|---|---|---|
| 作用范围 | 只能修饰变量 | 可以修饰代码块、方法 |
| 原子性 | 不保证 | 保证 (被 synchronized 修饰的代码块是原子的) |
| 可见性 | 保证 (对单个变量的读写) | 保证 (对一个线程解锁 happens-before 于另一个线程加锁) |
| 有序性 | 禁止指令重排 | 禁止指令重排 (一个线程解锁 happens-before 于另一个线程加锁) |
| 锁机制 | 不涉及锁,轻量级 | 涉及锁,重量级 |
| 性能 | 较高 | 较低(锁的获取和释放有开销) |
使用场景总结:
-
使用
volatile:- 写入变量不依赖于当前值。
- 该变量没有包含在具有其他变量的不变式中。
- 当一个线程需要读取另一个线程写入的值,且不需要基于旧值计算新值时,
volatile是一个很好的选择,状态标志(isRunning)、一次性发布(Event Bus)等。
-
使用
synchronized或原子类:- 当你需要保证一个或多个操作的原子性时(如
i++)。 - 当你需要保护一个代码块,确保同一时间只有一个线程执行它。
- 当你需要保证一个或多个操作的原子性时(如
volatile 是 Java 并发工具箱中一个轻量级的同步工具。
-
核心功能:
- 保证可见性:确保一个线程的修改对其他线程立即可见。
- 禁止指令重排:保证程序的执行顺序,防止因重排序导致的逻辑错误。
-
核心限制:
- 不保证原子性:不能用于替代
synchronized来处理复合操作。
- 不保证原子性:不能用于替代
-
适用场景:
- 状态标志(如
boolean isRunning)。 - 一次性安全发布(
Event Bus的发布者)。 double-checked locking单例模式中的实例引用。
- 状态标志(如
-
不适用场景:
- 依赖旧值计算新值的场景(如
count++),应使用synchronized或AtomicInteger。
- 依赖旧值计算新值的场景(如
理解 volatile 的原理和适用范围,对于编写正确且高效的并发程序至关重要,它是在 synchronized 之外,解决并发问题的一个重要工具。
