指令重排序带来的多线程问题与volatile解决方案
指令重排序是编译器、JVM 和 CPU 优化性能的常见手段。
为了提高执行效率,编译器、JVM、CPU可能会在不影响单线程语义的前提下,改变代码执行的顺序。
但它可能会导致并发程序出现不可预测或错误的行为。
举个例子:
int a = 1; // A
int b = 2; // B
c = a + b; // C
你以为执行顺序是A → B,但编译器可能会调换它们的顺序,或者 CPU 同时执行它们。不管是并行执行还是真的改变顺序,都是指令重排序。不过 C总得最后执行。因为 C有对 A B 的依赖关系。
系统的多级优化可能会带来三种重排序:
1、编译器优化重排序
Java 编译器会在生成字节码或机器码时重排指令,只要不影响单线程的执行结果,它就认为是合法的。
就比如上面的例子里,如果 A 和 B 无依赖关系,编译器可能会调换顺序。
2、CPU 执行时的重排序(乱序执行)
现代 CPU(如 Intel、ARM)都支持乱序执行,即同时执行多条不依赖的指令,然后乱序提交,这会导致执行顺序与代码顺序不一致。
3、内存系统的可见性乱序(多线程)
由于缓存、写缓冲区、总线延迟等原因,内存写入的可见性顺序可能被打乱。也就是说,线程 A 写入的值,线程 B 可能“晚一点”才能看到。
不过这只做了解,重要的是:
单线程下,重排序是优化,非常的OK;
但是多线程下,还重排,就可能出问题。
多线程
多线程的情况下,假设x 和flag 都是全局变量,初始值是0 和false 。
// 线程1
x = 1; // 语句A
flag = true; // 语句B// 线程2
if (flag) { // 语句CSystem.out.println(x); // 语句D
}
原计划:原本线程1的计划是当 x=1 之后,再把 flag 设为 true,flag 类似于释放锁,然后线程2才能拿到flag 锁,并打印x,所以两个线程原计划是线程2打印的是1。
重排序:但是因为JMM的指令重排序,语句 B 可能先于语句 A 执行,语句 C 察觉到语句 B 的执行之后,就会打印 x,此时语句 A 还没有执行,所以 x 还是旧的值,就出现了乱套。
volatile禁止指令重排序
如果用 volatile 修饰了 flag,就可以解决这个问题。
volatile boolean flag = false;
// 线程1
x = 1; // 普通写
flag = true; // volatile 写,确保 x=1 对线程2可见// 线程2
if (flag) { // volatile 读,确保看到线程1的所有写操作System.out.println(x); // 保证输出 1
}
给 flag 修饰 volatile 之后,对于他的写操作会建立 happens-before 关系,确保多线程的正确性。具体来说就是,当 flag 被声明为 volatile 时,会插入内存屏障,包括写屏障和读屏障:
写屏障(StoreStore + StoreLoad):
在 flag=true 之前的所有普通写(如 x=1)必须先刷回主内存。
禁止 x=1 和 flag=true 的重排序。
读屏障(LoadLoad + LoadStore):
在 if (flag) 时,会强制从主内存重新加载 flag 和 之前的所有变量(包括 x)。
这就保障了flag=true 之前x=1 必须已经执行完毕,并且不能只存在本地内存,要刷回主内存。
而且由于写屏障,并没有被volatile修饰的x=1 也会直接刷回主内存,也保证其他线程可见性。
总结就是:
volatile的作用:
①可以防止变量写的指令重排序
写入 volatile 变量时,JVM 会在底层插入 StoreStore 屏障,禁止该 volatile 写与它前面的普通写重排序。
读 volatile 变量时,会插入 LoadLoad + LoadStore 屏障,禁止后续读写操作提前执行。
②可以保证可见性
当一个线程修改了 volatile 变量的值:必须立即刷新到主内存;
当另一个线程读取该 volatile 变量时:必须从主内存重新读取,不能使用工作内存的旧值;
此外:
所有在 写 volatile 变量之前的普通写操作,也会被一起同步到主内存。
所有在 读 volatile 变量之后的普通读操作,也必须重新从主内存读取(如果有数据依赖)。
再总结就是:
volatile 保证可见性和部分有序性(禁止特定的重排序),但不保证原子性。