在某乎上看到一个关于 synchronized 能否防止指令重排序的讨论,发现观点不一,这反映出很多开发者在学习底层技术时存在一个误区。
我的核心观点很明确:synchronized 绝对不能防止其同步块内部代码的指令重排序!
下面我们来具体分析一下。
synchronized 到底保证了什么?
很多人认为 synchronized 能防止重排序,是因为经常看到“synchronized 能保证有序性”这句话。然而,这句话有一个重要的大前提!
我们可以打个比方:synchronized 就像一间带锁的单人洗手间。
从并发视角来看它的有序性保证:线程 A 进去后把门反锁,线程 B 只能在门外排队。A 出来之后,B 才能进去。对于线程 B 来说,线程 A 在洗手间里的所有动作是“一次性”完成的。这正是 synchronized 保证的 线程间执行顺序的有序性 和原子性。
但是,问题出在里面。线程 A 关上门之后,在洗手间里到底是先脱裤子再上厕所,还是先洗脸再刷牙?为了追求极致的执行效率,CPU 和编译器完全可能把线程 A 在洗手间里的这些动作顺序打乱 —— 这就是 指令重排序。
只要线程 A 在里面的一系列“微操”不影响单线程执行下的最终结果,CPU 就觉得没毛病。而 synchronized 这个“门锁”,根本管不住 CPU 在单线程内部进行的这种优化。
双重检查锁DCL为什么要加volatile?
我们来看一个经典的双重检查锁(Double-Checked Locking, DCL)单例的实现,问题就出在这里:
public class Singleton {
// 注意:这里如果不加 volatile,将引发问题
private static Singleton instance;
public static Singleton getInstance() {
if (instance == null) { // <--- 第一重检查(在锁外面)
synchronized (Singleton.class) {
if (instance == null) {
instance = new Singleton(); // <--- 问题的根源
}
}
}
return instance;
}
}
这段代码的逻辑看似完美:外层判空避免了不必要的加锁开销,内层加锁保证了只有一个线程能创建对象。但问题恰恰隐藏在 instance = new Singleton() 这一行。
在 Java 字节码和 CPU 执行的层面,new 操作并非原子操作,它大致分为三个步骤:
- 为对象分配内存空间
- 初始化对象(调用构造函数)
- 将
instance 引用指向分配好的内存地址
正常人的逻辑顺序是 1 -> 2 -> 3。
然而,正如前面所说,synchronized 管不住“洗手间里面”的重排序。CPU 一看,步骤 2(初始化)和步骤 3(设置引用)之间没有数据依赖关系,为了提高效率,它可能会将顺序重排为 1 -> 3 -> 2。
灾难是如何发生的?
假设发生了上述的 1 -> 3 -> 2 重排序,我们来看看并发场景下会发生什么:
- 线程 A 进入
synchronized 块,开始执行 new Singleton()。
- 线程 A 执行了步骤1(分配内存),紧接着执行了步骤3(设置引用)。此时,对象尚未初始化,但
instance 已经不再是 null 了!
- 在这个极其危险的时刻,线程 B 恰好执行到第一重检查
if (instance == null)。关键点在于,这行代码是在 synchronized 块之外的!
- 线程 B 根本无需等待锁,它看到
instance != null,便以为对象已经创建完毕,直接 return instance 拿去使用。
- 结果,线程 B 拿到的是一个尚未执行构造函数的“半成品”对象。一旦访问其成员变量,就可能引发
NullPointerException 或得到错误的初始值,导致系统出现难以排查的诡异问题。
你发现问题所在了吗?
synchronized 确实能把线程 A “锁”在同步块内完成操作,但它防不住线程 B 在同步块外进行“偷看”!因为 DCL 模式设计的初衷,就是让第一重检查绕开锁以提升性能。
volatile 的关键作用
这正是为什么在 DCL 单例中,必须给 instance 变量加上 volatile 关键字:
private static volatile Singleton instance;
volatile 关键字除了保证变量的内存可见性之外,在这个场景下最核心的作用是插入内存屏障,禁止指令重排序。
当变量被 volatile 修饰后,CPU 和编译器在处理与之相关的操作时就会“立正站好”。它会强制要求 new 操作的三个步骤必须按照 1 -> 2 -> 3 的顺序执行,禁止步骤 2 和 3 的重排序。
这样一来,只要任何线程看到 instance != null,那么这个 Singleton 对象就一定是一个完全初始化好的、可用的对象,彻底杜绝了拿到“半成品”的风险。
总结
通过上面的分析,我们可以清晰地认识到:
Synchronized 保证的是多个线程之间访问临界区的有序性(一个接一个执行),它通过互斥锁来实现这一点。但它并不能约束单一线程内部,由编译器或 CPU 为了优化性能而进行的指令重排序。
在诸如 DCL 单例这类对对象初始化完成状态有严格要求的精密并发场景中,必须借助 volatile 的禁止重排序语义来与 synchronized 配合,才能构建出线程安全的代码。理解这些 多线程 与内存模型的底层细节,对于编写正确、高效的高并发程序至关重要。如果你想深入探讨更多类似的问题,欢迎到技术社区进行交流。