一、指令重排的定义
- 编译器重排:编译器在不改变单线程程序语义的前提下,重新安排语句的执行顺序。
- 处理器重排:CPU 采用了指令级并行技术,将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应的机器指令的执行顺序。
二、指令重排的背景和原因
- 串行执行:厨师可能会先把鱼蒸上(需要 10 分钟),然后在这 10 分钟里什么都不做,一直等鱼蒸好,再去炒青菜(需要 2 分钟)。总耗时 12 分钟。这显然效率很低。
- 重排并行执行:一个更高效的厨师会这样做:他先把鱼放进蒸箱,设置好时间。然后,在鱼蒸着的这 10 分钟里,他转身去炒青菜。这样,当青菜炒好时,鱼也差不多蒸好了。总耗时大约就是 10 分钟。
三、指令重排的类型
四、一个经典的例子:指令重排导致的并发问题
java
运行
public class Singleton {
private static Singleton instance;
private Singleton() {}
public static Singleton getInstance() {
if (instance == null) { // 1. 第一次检查
synchronized (Singleton.class) { // 2. 加锁
if (instance == null) { // 3. 第二次检查
instance = new Singleton(); // 4. 创建实例
}
}
}
return instance;
}
}时间点 | 线程 A | 线程 B |
T1 | 检查 | - |
T2 | 获取锁。 | - |
T3 | 再次检查 | - |
T4 | 执行 1. 分配内存。 2. 将 | - |
T5 | - | 检查 |
T6 | - | 直接返回了这个未被完全初始化的 |
T7 | 线程 A 继续执行对象的初始化工作。 | 线程 B 开始使用一个 “半成品” 对象,可能导致程序崩溃或数据异常。 |
五、如何解决指令重排问题?
- 可见性:一个线程对
volatile变量的修改,会立即被其他线程看到。 - 有序性:
volatile变量的读写操作前后,会形成一个 “内存屏障”(Memory Barrier)。这相当于告诉 CPU:“在执行我后面的指令之前,必须先完成我前面所有volatile变量的读写操作。”
java
运行
public class Singleton {
// 使用 volatile 关键字修饰
private static volatile Singleton instance;
private Singleton() {}
public static Singleton getInstance() {
if (instance == null) {
synchronized (Singleton.class) {
if (instance == null) {
// volatile 会禁止此处的指令重排
instance = new Singleton();
}
}
}
return instance;
}
}总结
- 是什么? 指令重排是 JVM 和 CPU 为优化性能而对指令执行顺序进行的重新排列。
- 为什么? 为了最大化利用 CPU 资源,提升程序运行效率。
- 有何风险? 在单线程中无害,但在多线程环境下,可能会破坏程序的正确性,导致难以预料的 bug。
- 如何应对? 在并发编程中,当多个线程共享状态时,应使用
volatile、synchronized等关键字来显式地保证内存可见性和操作有序性,从而规避指令重排带来的风险。
