这是一个非常重要且基础的并发编程概念,我会从它的作用、原理、使用场景、与 synchronized 的区别以及局限性等多个方面进行阐述。

volatile 是什么?
volatile 是 Java 语言提供的一种轻量级的同步机制,它是一个修饰符,用来修饰被多个线程共享的变量。
当一个变量被声明为 volatile 后,它将具备两种特性:
- 保证可见性
- 禁止指令重排序
volatile 的核心作用
1 保证可见性
这是 volatile 最核心、最重要的作用。
问题背景:CPU 缓存与主内存

在多核 CPU 的计算机中,每个 CPU 核心都有自己的高速缓存,而主内存是所有 CPU 共享的,为了提高性能,CPU 在操作一个变量时,会先把该变量从主内存加载到自己的高速缓存(CPU Cache / 工作内存)中,然后进行操作,操作完成后,再在某个时间点写回主内存。
这就带来了一个问题:线程间的可见性问题。
假设一个线程修改了一个共享变量的值,但这个修改只保存在它自己的 CPU 缓存中,没有立即写回主内存,其他线程就无法看到这个最新的值,它们读取的仍然是自己缓存中过时的值。
volatile 如何解决可见性问题?

当一个变量被 volatile 修饰后,它就相当于一个“信号灯”,JVM 会确保:
- 写操作:当一个线程修改一个
volatile变量时,JMM(Java 内存模型)会强制将该线程工作内存中的值立即刷新到主内存。 - 读操作:当一个线程读取一个
volatile变量时,JMM 会强制让该线程的工作内存失效,然后必须从主内存中重新读取最新的值。
通过这种方式,volatile 就像在各个线程的缓存和主内存之间建立了一条直接的通信通道,确保了任何一个线程对这个 volatile 变量的修改,对其他线程都是立即可见的。
示例代码:
public class VolatileVisibilityExample {
// private boolean flag = false; // 没有 volatile,线程 B 可能永远看不到变化
private volatile boolean flag = false;
public static void main(String[] args) {
VolatileVisibilityExample example = new VolatileVisibilityExample();
// 线程 A:负责修改 flag
new Thread(() -> {
System.out.println("Thread A is running, and will set flag to true...");
try {
Thread.sleep(1000); // 模拟耗时操作
} catch (InterruptedException e) {
e.printStackTrace();
}
example.flag = true;
System.out.println("Thread A set flag to true.");
}).start();
// 线程 B:负责读取 flag
new Thread(() -> {
System.out.println("Thread B is running, waiting for flag to become true...");
// 使用 while 循环不断检查 flag
// 如果没有 volatile,这里的 flag 可能一直读取的是线程 B 工作内存中的 false
while (!example.flag) {
// do nothing, just wait
}
System.out.println("Thread B sees flag is true, now exiting.");
}).start();
}
}
在这个例子中,如果没有 volatile,线程 B 的 while 循环可能会因为读取到自己工作内存中过期的 false 值而无限循环下去,加上 volatile 后,线程 A 修改 flag 为 true 后,会立即写回主内存,线程 B 在下一次读取时,必须从主内存读取,从而能看到 true 值,并退出循环。
2 禁止指令重排序
问题背景:指令重排序
为了优化性能,编译器和处理器可能会对输入的代码进行乱序执行优化,即“指令重排序”,重排序分为两种:
- 编译器优化重排序:在不改变单线程程序语义的前提下,编译器可以调整语句的执行顺序。
- 处理器运行时重排序:为了最大化 CPU 流水线的利用率,处理器可以乱序执行指令。
在多线程环境下,这种重排序可能会导致意想不到的结果。
volatile 如何禁止指令重排序?
当一个变量被 volatile 修饰后,JMM 会插入一个“内存屏障”(Memory Barrier),内存屏障可以禁止其前后的指令进行重排序优化,从而保证了程序的执行顺序。
经典应用场景:双重检查锁定实现的单例模式
这是 volatile 最经典的应用,用来解决 DCL 中的指令重排序问题。
public class Singleton {
// volatile 关键字防止指令重排序
private static volatile Singleton instance = null;
private Singleton() {
// 私有构造函数
}
public static Singleton getInstance() {
if (instance == null) { // 第一次检查(无锁,快速)
synchronized (Singleton.class) {
if (instance == null) { // 第二次检查(有锁,确保唯一)
instance = new Singleton();
}
}
}
return instance;
}
}
为什么需要 volatile?
关键在于 instance = new Singleton(); 这一行,它并非一个原子操作,大致可以分解为三步:
- 分配对象的内存空间。
- 初始化对象。
- 将
instance引用指向分配的内存地址。
如果没有 volatile,由于指令重排序,步骤 2 和 3 的顺序可能会被颠倒(即先执行步骤 3,再执行步骤 2)。
假设线程 A 正在创建单例,它执行了步骤 3(instance 指向了内存地址),但还没有执行步骤 2(对象还未初始化),线程 B 调用 getInstance() 方法,在第一次检查时发现 instance 不再为 null,于是直接返回 instance,但线程 B 拿到的其实是一个未初始化完成的对象,如果此时访问其内部属性,就会抛出 NullPointerException 或其他错误。
加上 volatile 后,内存屏障确保了 instance = new Singleton(); 的三个步骤不会被重排序,必须按 1->2->3 的顺序完成,从而保证了线程 B 拿到的是一个完全初始化好的对象。
volatile 的局限性
volatile 虽然强大,但它并不能解决所有并发问题。它不保证原子性。
什么是原子性?
一个或多个操作,要么全部执行且执行的过程不会被任何因素打断,要么就都不执行。
示例:volatile 不保证原子性
public class VolatileAtomicityExample {
// volatile 保证了 i 的可见性,但不保证 i++ 的原子性
private volatile int i = 0;
public void increase() {
i++; // i++ 不是原子操作
}
public static void main(String[] args) throws InterruptedException {
VolatileAtomicityExample example = new VolatileAtomicityExample();
Thread[] threads = new Thread[10];
for (int j = 0; j < 10; j++) {
threads[j] = new Thread(() -> {
for (int k = 0; k < 1000; k++) {
example.increase();
}
});
threads[j].start();
}
// 等待所有线程执行完毕
for (Thread t : threads) {
t.join();
}
// 预期结果是 10000,但实际结果通常小于 10000
System.out.println("Final value of i: " + example.i);
}
}
为什么 i++ 不是原子操作?
i++ 实际上包含三个步骤:
- 读取:从主内存读取
i的值到工作内存。 - 修改:将
i的值加 1。 - 写入:将
i的新值写回主内存。
虽然 volatile 保证了第 3 步的写入对其他线程立即可见,但它没有将这三个步骤打包成一个不可分割的整体,在多线程环境下,可能会发生以下情况:
- 线程 A 读取
i=0。 - 线程 B 也读取
i=0。 - 线程 A 将
i加 1,得到 1,并写回主内存。 - 线程 B 也将
i加 1,得到 1,并写回主内存。
i 的值是 1,而不是预期的 2,一次修改“丢失”了。
如何解决原子性问题?
如果需要保证复合操作的原子性,应该使用 synchronized 关键字或者 java.util.concurrent.atomic 包下的原子类(如 AtomicInteger)。
volatile vs. synchronized
| 特性 | volatile |
synchronized |
|---|---|---|
| 本质 | 轻量级的同步机制,修饰变量 | 重量级的同步机制,修饰代码块/方法 |
| 原子性 | 不保证 | 保证(被synchronized保护的代码块是原子的) |
| 可见性 | 保证(读写都会作用于主内存) | 保证(线程解锁前必须把变量刷新回主内存,加锁时清空工作内存) |
| 有序性 | 禁止指令重排序 | 禁止指令重排序(happens-before原则) |
| 锁 | 不涉及锁,不会引起线程阻塞 | 涉及锁,可能会引起线程阻塞和上下文切换,性能开销大 |
| 使用场景 | 适用于一个线程写,多个线程读的场景(如状态标志位) | 适用于多个线程写,或者读-写复合操作的场景 |
| 性能 | 性能较高,不会阻塞线程 | 性能较低,涉及用户态到内核态的切换 |
总结与最佳实践
volatile 是 Java 并发编程中一个不可或缺的工具,它通过保证可见性和禁止指令重排序,解决了多线程编程中的两个核心问题。
- 保证可见性:确保一个线程的修改对其他线程立即可见。
- 禁止指令重排序:防止编译器和处理器为了优化而打乱代码执行顺序,导致并发逻辑错误。
但它最大的局限性是不保证原子性。
最佳实践
在以下场景中,volatile 是一个非常理想的选择:
-
状态标志位:这是最经典、最推荐的使用场景,一个线程通过修改
volatile布尔变量来通知其他线程终止任务或改变状态。private volatile boolean shutdownRequested; public void shutdown() { this.shutdownRequested = true; } public void doWork() { while (!shutdownRequested) { // do work } } -
双重检查锁定(DCL):在实现懒加载的单例模式时,使用
volatile来防止指令重排序导致的错误。 -
一次性安全发布:当某个引用的值在构造后就不需要再被修改,并且需要被安全地发布给其他线程时,可以将该引用声明为
volatile。
何时选择 synchronized 或 java.util.concurrent.atomic?
- 当你需要保证复合操作的原子性时(如
i++,count++),请使用synchronized或AtomicInteger等原子类。 - 当你需要保护一段代码块,确保同一时间只有一个线程能执行它时,请使用
synchronized。
一句话总结:
volatile用于“状态”的可见性,synchronized用于“操作”的原子性。 理解并正确使用它们,是编写高质量 Java 并发程序的关键一步。
