多线程程序中经常出现一类诡异的问题:一个线程明明已经把变量改成了新值,另一个线程却始终读到旧值,程序看起来毫无逻辑错误。这种问题的根源之一就是指令重排。现代CPU和编译器都会对指令执行顺序做优化调整,单线程下结果不受影响,但多线程共享数据时,重排可能让一个线程观察到另一个线程操作的不一致中间状态。要理解并解决这类问题,内存屏障是绕不开的知识点。本文结合可复现的实验代码,带你逐步看清指令重排的真实面貌,以及内存屏障如何阻止它。

一、指令重排到底是怎么发生的
指令重排有两个来源。第一个是编译器重排:编译器在生成目标代码时,在不改变单线程语义的前提下,会调整语句顺序、把变量缓存到寄存器、消除它认为多余的读写。第二个是处理器重排:CPU为了充分利用流水线,采用乱序执行技术,并且每个核心都有自己的写缓冲区,一个写操作可能先停留在缓冲区里,没有立即同步到主内存,其他核心自然看不到。
举个最经典的例子,假设线程一依次执行a = 1和flag = true,线程二的逻辑是while (!flag) {} int r = a;。按照直觉,线程二跳出循环后读到的a应该是1。但由于重排,线程一可能先执行flag = true再执行a = 1,线程二跳出循环后读到的a可能是0。这不是bug,而是编译器和CPU在各自权限内做的合法优化。
需要特别说明的是,重排遵循as-if-serial原则:在单线程视角下,存在数据依赖的指令不会被重排。a = 1和b = a之间存在依赖,顺序不会颠倒。但两个没有依赖的写操作,比如上面的a和flag,就可能被自由调换。问题恰恰出在这里:编译器认为没有依赖,但业务逻辑上存在隐含的先后关系,这种隐含关系编译器无从感知。
二、用实验代码复现可见性问题
光讲理论容易犯困,直接写一段可以反复运行的Java代码来复现问题。下面的程序启动两个线程,线程一负责写数据,线程二负责自旋等待并读取数据,主线程负责统计异常结果的次数。
public class ReorderDemo {
private static int a = 0;
private static boolean flag = false;
public static void main(String[] args) throws InterruptedException {
int errorCount = 0;
for (int i = 0; i < 100000; i++) {
a = 0;
flag = false;
Thread t1 = new Thread(() -> {
a = 1; // 写普通变量
flag = true; // 写标志位
});
Thread t2 = new Thread(() -> {
while (!flag) { // 自旋等待
}
if (a == 0) {
System.out.println("第 " + i + " 次发生重排,读到旧值");
}
});
t1.start();
t2.start();
t1.join();
t2.join();
}
}
}在未做任何同步处理的情况下,多跑几次大概率能观察到输出,具体次数取决于CPU架构、JIT编译情况以及运行环境。在x86机器上复现概率较低,因为x86是强内存模型,只允许写读重排这一种情况;换到ARM架构的手机或开发板上,复现概率会明显上升,因为ARM是弱内存模型,允许的重排类型更多。这也解释了一个常见现象:并发bug在开发机上跑不出来,一到生产环境就频繁出现。
除了线程一侧重排,线程二侧也存在问题。JIT编译器可能发现flag在循环中从未被修改过,于是干脆把flag的值缓存到寄存器,循环变成死循环,线程二永远退不出去。这是编译器优化带来的可见性问题,同样属于重排的范畴。
三、内存屏障的工作机制与分类
内存屏障(Memory Barrier)是一条特殊的CPU指令,作用是禁止屏障两侧的指令跨越屏障重排,并强制处理器刷新或失效写缓冲区与缓存行。按照效果可以分为三类:读屏障(Load Barrier)保证屏障之后的读操作能读到最新数据,写屏障(Store Barrier)保证屏障之前的写操作对其他处理器可见,全屏障(Full Barrier)同时具备两种效果。
以x86架构为例,mfence指令是全屏障,lfence是读屏障,sfence是写屏障。ARM架构则提供了dmb、dsb、isb等指令,粒度更细。不同的内存模型决定了需要插入屏障的数量:x86由于本身约束较强,多数场景下普通写操作已经隐含了类似写屏障的效果,而ARM需要程序员或编译器显式插入更多屏障。
回到前面的例子,解决问题的思路是在写侧flag = true之前插入写屏障,确保a = 1先于flag可见;在读侧读取flag之后插入读屏障,确保后续对a的读取不会提前执行。在Java中不需要手写汇编,把flag声明为volatile即可:
public class VolatileDemo {
private static int a = 0;
private static volatile boolean flag = false;
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
a = 1;
flag = true; // volatile写,前面插入StoreStore屏障
});
Thread t2 = new Thread(() -> {
while (!flag) { // volatile读,后面插入LoadLoad屏障
}
System.out.println(a); // 一定能读到1
});
t1.start();
t2.start();
t1.join();
t2.join();
}
}volatile的语义由JMM(Java内存模型)定义:volatile写之前的所有普通写操作,对后续volatile读的线程可见。这套语义在字节码层面体现为对volatile变量的特殊访问指令,在机器码层面则由JVM根据目标架构插入相应的屏障指令。可以用-XX:+PrintAssembly参数打印汇编代码验证,会看到volatile写操作附近出现了lock addl这样的指令,它利用缓存锁定实现了全屏障的效果。
四、不同语言中的屏障实践与注意事项
C和C++没有关键字形式的内存屏障,标准库提供了atomic类型和std::memory_order枚举来控制屏障强度。默认的memory_order_seq_cst提供顺序一致性,屏障最重也最安全;memory_order_acquire配合读操作、memory_order_release配合写操作,可以实现和volatile类似的效果,且性能开销更小。
#include <atomic>
#include <thread>
#include <cstdio>
int a = 0;
std::atomic<bool> flag{false};
void writer() {
a = 1;
flag.store(true, std::memory_order_release); // 写侧:release屏障
}
void reader() {
while (!flag.load(std::memory_order_acquire)) { // 读侧:acquire屏障
}
printf("%d\n", a); // 保证读到1
}
int main() {
std::thread t1(writer), t2(reader);
t1.join();
t2.join();
return 0;
}使用内存屏障时有几个常见误区值得注意。第一,volatile只保证可见性和有序性,不保证原子性,count++这种复合操作加volatile依然会丢失更新,需要用原子类或锁。第二,屏障是有性能代价的,全屏障会强制刷新流水线和缓存,在高频调用的热点路径上滥用会显著拖慢程序,应该按需选择acquire和release这类较轻的屏障。第三,不要依赖观察结果做判断,某台机器上没复现重排不代表代码正确,内存模型层面的保证必须以语言规范为准,而不是以特定硬件的行为为准。
理解指令重排与内存屏障的关系,本质上是理解并发程序的正确性不能建立在直觉上,而要建立在内存模型的正式规范上。把可见性问题的排查思路从碰运气转变为依据规范分析,你的多线程代码才能真正经得起不同架构和不同优化级别的考验。