最近在看《深入理解 Linux 内核》,讲到内核同步的时候提到了编译器屏障 __asm__volatile__("" ::: "memory")
,这行内联汇编的语句作用如下:
1)asm 用于指示编译器在此插入汇编语句
2)volatile 用于告诉编译器,严禁将此处的汇编语句与其它的语句重组合优化。即:原原本本按原来的样子处理这这里的汇编。
3) memory 强制 gcc 编译器假设 RAM 所有内存单元均被汇编指令修改,这样 CPU 中的通用寄存器中的数据将作废
这个语义与 C 中的定义是等价的,但是在 Java 中,volatile 还有另外一层加强的语义:
声明成 volatile 的字段,Java 线程模型能确保所有线程看到这个变量的值是一致的。
Volatile 语义分析
Java 中的 volatile 死循环例子
在 Java 里提到 volatile,经常会拿出下面这个例子来证明可见性问题:
1 | public class TestVolatile { |
run
字段不加 volatile 修饰,这段程序会死循环,增加之后就会正常退出,并以此证明 Java 中增强的 volatile 语义。但是这个证明方法是错误的。
C 语言等价代码
以下是等价的 C 代码:
1 |
|
O2 编译死循环
使用 O2 编译代码:
➜ gcc main.c -lpthread -o test_volatile && ./test_volatile -O2
➜ ./test_volatile
Thread create success
Change stop flag: 1
Change stop flag finish: 0
出现了与 Java 代码一样的效果,这个死循环无法退出了。
关闭编译优化
接下来我们关掉编译优化:
➜ gcc main.c -lpthread -o test_volatile && ./test_volatile
➜ ./test_volatile
Thread create success
Change stop flag: 1
Change stop flag finish: 0
stop: code=666
代码正常退出。
使用 volatile + O2 编译
然后我们将 int stop -> volatile int stop
,依然使用 O2,输出如下,循环正常推出。
➜ gcc main.c -lpthread -o test_volatile && ./test_volatile -O2
➜ ./test_volatile
Thread create success
Change stop flag: 1
Change stop flag finish: 0
stop: code=666
由此可见,C 语言等价代码中循环能否正常退出,更多是一种编译器的行为。下面我们分析一下编译出来的具体代码。
分析编译结果
分析 C 编译结果
同时使用 O2 编译一份有无 volatile 的可执行文件,使用 objdump -d
反汇编,我们只关注 func 方法,左边是有 volatile,右边没有:
紫色框的就是 while 循环的逻辑所在之处,先逐行分析左边的相关指令:
test %eax, %eax
将两个操作数做 与 运算,并根据结果设置相关标志位 ZF
。jne 1278
如果标志位 ZF
不等于 0,跳转到 1278,即下一条要解析的指令地址。mov 0x2dea(%rip),%eax # 4068 <stop>
这条指令其实是取内存中的 stop 字段的值。
显而易见,右边的指令对比左边的区别就是 jne
跳转指令不去取内存中 stop 的值。而这,就是循环能否正常停止的关键。
分析 Java jit 编译结果
由 C 的结果,我们可以大胆推测,Java 中是否能将循环停止,也是编译器的行为。
首先,我们尝试设置成按模板解释器执行:
1 | java -Xint TestVolatile |
纯解释器执行的情况下,有无 volatile 都是可以正常停下来,也就是说是 jit 的优化行为导致的循环无法停下来。我们接下去继续验证。
我们直接对比一下有无 volatile jit 生成的代码的差异:(jit 生成代码反汇编的配置可以参考这篇文章。)
分别在有无 volatile 下编译运行:
java -XX:+UnlockDiagnosticVMOptions -XX:+PrintNMethods -Xcomp -XX:CompileCommand=compileonly,TestVolatile.whileLoop TestVolatile > not_volatile
java -XX:+UnlockDiagnosticVMOptions -XX:+PrintNMethods -Xcomp -XX:CompileCommand=compileonly,TestVolatile.whileLoop TestVolatile > volatile
对比一下两者反汇编的差异:
一样是左边有 volatile,右边没有,先逐行分析左边指令:
test %eax,0x168de31a(%rip) # 0x00007f1a7f9e2000
这行指令是 Hotspot 用来实现 safepoint 的代码,先直接无视。movzbl 0x68(%r10),%r8d
读取 run 值到 r8 寄存器。test %r8d,%r8d
将两个操作数做 与 运算,并根据结果设置相关标志位 ZF
。jne 0x00007f1a69103ce0
如果标志位 ZF
不等于 0,跳转到 3ce0,即第一条指令的位置。
显而易见,右边的指令并没有读取内存中 run
值的指令,而是直接无条件jmp
跳转到 Hotspot 的 safepoint 代码就继续执行,根本不可能读取到 run
值的变化。
以上实验环境如下:
gcc version 8.3.0 (Debian 8.3.0-6)
java version “1.8.0_261”
Java(TM) SE Runtime Environment (build 1.8.0_261-b12)
Java HotSpot(TM) 64-Bit Server VM (build 25.261-b12, mixed mode)
Java 中的 volatile
由此可见,即使没有 Java 增强的 volatile 语义,所谓内存可见性导致循环无法停止的问题是不存在的,循环无法退出完全是编译器的优化行为。
Java 中的 volatile 一方面与 gcc 定义的 volatile 语义一致,可以提示编译器该变量每一次都需要从内存中获取(先忽略 cache-line),不允许编译器进行优化;另一方面,Java 内存模型约束 volatile 定义的字段在 CPU 层面不会进行指令重排序。
在 JVM 的字节码解释器中,如果 putstatic 字节码或 putfield 字节码的变量是 Java 层面的 volatile 关键字修饰的,就会在指令执行的最后插入一道 StoreLoad 屏障:
1 | __ams__ volatile("lock; addl $0, $0(%%esp)" : : : "cc", "memory") |
这条指令可以把 CPU 缓存的写操作刷新到主存中。
再回到上面的 Java 循环的例子中,在我们禁止掉编译优化后,有无 volatile 循环都是可以正常停止下来的。区别在于有 volatile 的情况,一旦标记位修改,马上就会刷新到主存,另一个线程就可以读取到最新的数值。没有 volatile 的情况,则必须等 CPU 把修改的值刷新到主存后,另外一个线程才能读取到最新的数值。
这种写操作的延迟刷新,在我们的例子里可能只是会让循环停止产生延迟,但是在某些场景,会出现令人迷惑的情景: A线程 写操作在前,但是随后做读取操作的 B线程 依然读取到了旧值,我们从外部观察,就像是发生了 读写重排序,这个重排序并不是 CPU 真的对指令进行重排,而是由于缓存写导致的重排。
TSO 模型
为什么需要有 lock
指令才能确保写操作对其他线程马上可见呢?这是因为主存的写速度与 CPU 相比实在是太慢了,必须增加一层缓存(StoreBuffer),通过 lock
执行可以刷新 StoreBuffer。相关的模型叫:TSO,可以直接参考这篇文章: