volatile 是 Java 并发编程中一个非常重要但相对底层的概念,它是一个轻量级的同步机制,可以保证变量的可见性和禁止指令重排序,但它不保证原子性。
volatile 的核心作用
volatile 主要用于解决多线程环境下,共享变量的可见性和有序性问题。
保证可见性
这是 volatile 最核心、最重要的作用。
什么是可见性? 在多线程程序中,每个线程都有自己的工作内存(也称为高速缓存或本地缓存),而主存中存放着所有线程共享的变量,当线程需要操作一个共享变量时,它会先把该变量从主存复制一份到自己的工作内存中进行操作,操作完成后再写回主存。
可见性问题: 当一个线程修改了共享变量,但没有立即写回主存,其他线程就无法感知到这个变化,它们仍然使用自己工作内存中的旧值,这就导致了可见性问题。
volatile 如何保证可见性?
当一个变量被 volatile 修饰后,它具有了以下两个特性:
- 写操作:当线程对
volatile变量进行写操作时,JMM(Java 内存模型)会强制将该线程工作内存中的值立即刷新到主存。 - 读操作:当线程对
volatile变量进行读操作时,JMM 会强制让该线程工作内存中的值失效,并从主存中重新读取最新的值。
通过这种方式,volatile 就像是在线程间建立了一个直接的、实时的通信通道,确保任何一个线程对该变量的修改,都能被其他线程立即看到。
volatile 的第二个作用:禁止指令重排序
什么是指令重排序? 为了提高性能,编译器和处理器可能会对输入的代码进行乱序优化,即改变代码中语句的执行顺序,这种重排序分为两种:
- 编译器优化的重排序:在不改变单线程程序执行结果的前提下,编译器可以调整语句的执行顺序。
- 处理器运行时重排序:为了充分利用 CPU 的内部资源,处理器可能会对指令进行乱序执行。
指令重排序带来的问题: 在单线程中,重排序不会影响最终结果,但在多线程环境下,一个线程的重排序可能会破坏另一个线程的逻辑,导致程序出错。
volatile 如何禁止重排序?
volatile 关键字会插入一个“内存屏障”(Memory Barrier),内存屏障可以禁止其前后的指令进行重排序优化,从而保证了程序的执行顺序。
最经典的例子就是双重检查锁定实现的单例模式:
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();
}
}
}
return instance;
}
}
为什么需要 volatile?
instance = new Singleton(); 这行代码并非一个原子操作,大致可以分解为三步:
- 分配对象的内存空间。
- 初始化对象(
Singleton的构造函数)。 - 将
instance引用指向分配好的内存地址。
在单线程中,顺序是 1 -> 2 -> 3,但在多线程环境下,由于指令重排序,顺序可能会变成 1 -> 3 -> 2。
假设线程 A 执行到 1 -> 3 步骤,instance 已经不为 null 了,但对象还没有初始化,这时,线程 B 调用 getInstance(),在第一次检查时发现 instance != null,于是直接返回 instance,但线程 B 得到的是一个未初始化完成的对象,使用它时就会出错。
volatile 的内存屏障可以禁止这种重排序,确保 1 -> 2 -> 3 的顺序执行,从而避免了这个问题。
volatile 的局限性:不保证原子性
这是 volatile 最容易被误解的地方。volatile 只能保证可见性和有序性,不能保证复合操作的原子性。
什么是原子性? 一个或多个操作,要么全部执行且执行的过程不会被任何因素打断,要么就都不执行。
例子:count++ 操作
count++ 看起来是一个操作,但它实际上包含三个步骤:
- 读取
count的值。 - 将值加 1。
- 将新值写回
count。
这三个步骤在多线程环境下不是原子的,如果多个线程同时执行 count++,可能会导致最终结果小于预期值。
public class VolatileNotAtomic {
// volatile 只能保证每次读取到的是最新值,但不能保证 ++ 操作的原子性
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();
System.out.println("Final count: " + count); // 结果几乎总是小于 2000
}
}
如何保证原子性?
对于需要原子性的操作,应该使用 synchronized 关键字或者 java.util.concurrent.atomic 包下的原子类(如 AtomicInteger)。
volatile vs. synchronized
| 特性 | volatile |
synchronized |
|---|---|---|
| 作用范围 | 作用于变量 | 作用于代码块或方法 |
| 内存语义 | 保证可见性、有序性 | 保证原子性、可见性、有序性 |
| 锁机制 | 不使用锁,属于轻量级同步 | 使用锁,属于重量级同步 |
| 性能 | 性能较高,不会引起线程上下文切换和调度 | 性能较低,会引起线程阻塞和上下文切换 |
| 原子性 | 不保证 | 保证 |
| 适用场景 | 一个线程写,多个线程读;状态标志位;双重检查锁定 | 多个线程写同一个共享变量;复杂的临界区代码 |
volatile是轻量级的,适用于一个线程写,多个线程读的场景,或者作为简单的状态标志。synchronized是重量级的,适用于多个线程写同一个共享变量的场景,确保代码块的原子性。
适用场景
volatile 非常适合以下场景:
-
状态标志位:作为线程中断或退出的标志。
private static volatile boolean isRunning = true; public void stop() { isRunning = false; } public void run() { while (isRunning) { // do something } }线程 A 在循环中读取
isRunning,线程 B 调用stop()方法将其设为false。volatile确保线程 A 能立即看到isRunning的变化,从而安全退出循环。 -
独立观察的结果:一个线程的结果被另一个线程使用,且这两个结果之间没有依赖关系。
public class BackgroundTask { private volatile boolean isReady; private volatile int result; public void doWork() { // ... complex calculation ... result = 42; isReady = true; } public void showResult() { if (isReady) { System.out.println("Result is: " + result); } } } -
“发布”对象:当将一个对象引用发布到另一个线程时,如果该对象的状态不需要后续同步,可以使用
volatile。
volatile 是 Java 并发工具箱中的一个利器,它通过保证可见性和禁止指令重排序,解决了多线程编程中的两个核心问题,但它不保证原子性,因此不能完全替代 synchronized,理解 volatile 的特性和局限,是编写高效、健壮的并发程序的基础。
