Java 中 volatile 这个热门的关键字,在面试中经常会被提及,在各种技术交流群中也经常被讨论,但似乎讨论不出一个完美的结果,带着种种疑惑,准备从 JVM,C++,汇编的角度重新梳理一遍.
volatile 的两大特性:禁止重排序,内存可见性,这两个概念,不太清楚的同学可以看这篇文章 -> java volatile 关键字解惑
概念是知道了,但还是很迷糊,它们到底是如何实现的?
本文会涉及到一些汇编方面的内容,如果多看几遍,应该能看懂.
重排序
为了理解重排序,先看一段简单的代码
public class VolatileTest {
int a = 0;
int b = 0;
public void set() {
a = 1;
b = 1;
}
public void loop() {
while (b == 0) continue;
if (a == 1) {
System.out.println("i'm here");
} else {
System.out.println("what's wrong");
}
}
}
VolatileTest 类有两个方法,分别是 set() 和 loop(),假设线程 B 执行 loop 方法,线程 A 执行 set 方法,会得到什么结果?
答案是不确定,因为这里涉及到了编译器的重排序和 CPU 指令的重排序.
编译器重排序
编译器在不改变单线程语义的前提下,为了提高程序的运行速度,可以对字节码指令进行重新排序,所以代码中 a,b 的赋值顺序,被编译之后可能就变成了先设置 b,再设置 a.
因为对于线程 A 来说,先设置哪个,都不影响自身的结果.
CPU 指令重排序
CPU 指令重排序又是怎么回事? 在深入理解之前,先看看 x86 的 cpu 缓存结构.
1,各种寄存器,用来存储本地变量和函数参数,访问一次需要 1cycle,耗时小于 1ns; 2,L1 Cache,一级缓存,本地 core 的缓存,分成 32K 的数据缓存 L1d 和 32k 指令缓存 L1i,访问 L1 需要 3cycles,耗时大约 1ns; 3,L2 Cache,二级缓存,本地 core 的缓存,被设计为 L1 缓存与共享的 L3 缓存之间的缓冲,大小为 256K,访问 L2 需要 12cycles,耗时大约 3ns; 4,L3 Cache,三级缓存,在同插槽的所有 core 共享 L3 缓存,分为多个 2M 的段,访问 L3 需要 38cycles,耗时大约 12ns;
当然了,还有平时熟知的 DRAM,访问内存一般需要 65ns,所以 CPU 访问一次内存和缓存比较起来显得很慢.
对于不同插槽的 CPU,L1 和 L2 的数据并不共享,一般通过 MESI 协议保证 Cache 的一致性,但需要付出代价.
在 MESI 协议中,每个 Cache line 有 4 种状态,分别是:
1,M(Modified) 这行数据有效,但是被修改了,和内存中的数据不一致,数据只存在于本 Cache 中
2,E(Exclusive) 这行数据有效,和内存中的数据一致,数据只存在于本 Cache 中
3,S(Shared) 这行数据有效,和内存中的数据一致,数据分布在很多 Cache 中
4,I(Invalid) 这行数据无效
每个 Core 的 Cache 控制器不仅知道自己的读写操作,也监听其它 Cache 的读写操作,假如有 4 个 Core: 1,Core1 从内存中加载了变量 X,值为 10,这时 Core1 中缓存变量 X 的 cache line 的状态是 E; 2,Core2 也从内存中加载了变量 X,这时 Core1 和 Core2 缓存变量 X 的 cache line 状态转化成 S; 3,Core3 也从内存中加载了变量 X,然后把 X 设置成了 20,这时 Core3 中缓存变量 X 的 cache line 状态转化成 M,其它 Core 对应的 cache line 变成 I(无效)
当然了,不同的处理器内部细节也是不一样的,比如 Intel 的 core i7 处理器使用从 MESI 中演化出的 MESIF 协议,F(Forward) 从 Share 中演化而来,一个 cache line 如果是 F 状态,可以把数据直接传给其它内核,这里就不纠结了.
CPU 在 cache line 状态的转化期间是阻塞的,经过长时间的优化,在寄存器和 L1 缓存之间添加了 LoadBuffer,StoreBuffer 来降低阻塞时间,LoadBuffer,StoreBuffer,合称排序缓冲 (Memoryordering Buffers (MOB)),Load 缓冲 64 长度,store 缓冲 36 长度,Buffer 与 L1 进行数据传输时,CPU 无须等待.
1,CPU 执行 load 读数据时,把读请求放到 LoadBuffer,这样就不用等待其它 CPU 响应,先进行下面操作,稍后再处理这个读请求的结果. 2,CPU 执行 store 写数据时,把数据写到 StoreBuffer 中,待到某个适合的时间点,把 StoreBuffer 的数据刷到主存中.
因为 StoreBuffer 的存在,CPU 在写数据时,真实数据并不会立即表现到内存中,所以对于其它 CPU 是不可见的;同样的道理,LoadBuffer 中的请求也无法拿到其它 CPU 设置的最新数据;
由于 StoreBuffer 和 LoadBuffer 是异步执行的,所以在外面看来,先写后读,还是先读后写,没有严格的固定顺序.
内存可见性如何实现
从上面的分析可以看出,其实是 CPU 执行 load,store 数据时的异步性,造成了不同 CPU 之间的内存不可见,那么如何做到 CPU 在 load 的时候可以拿到最新数据呢?
设置 volatile 变量
写一段简单的 java 代码,声明一个 volatile 变量,并赋值
public class VolatileTest {
static volatile int i;
public static void main(String[] args) {
i = 10;
}
}
这段代码本身没什么意义,只是想看看加了 volatile 之后,编译出来的字节码有什么不同,执行
javap -verbose VolatileTest
之后,结果如下:
让人很失望,没有找类似关键字 synchronize 编译之后的字节码指令(monitorenter,monitorexit),volatile 编译之后的赋值指令 putstatic 没有什么不同,唯一不同是变量 i 的修饰 flags 多了一个 ACC_VOLATILE 标识.
不过,我觉得可以从这个标识入手,先全局搜下 ACC_VOLATILE,无从下手的时候,先看看关键字在哪里被使用了,果然在 accessFlags.hpp 文件中找到类似的名字.
通过 is_volatile() 可以判断一个变量是否被 volatile 修饰,然后再全局搜"is_volatile" 被使用的地方,最后在
bytecodeInterpreter.cpp
文件中,找到 putstatic 字节码指令的解释器实现,里面有 is_volatile() 方法.
当然了,在正常执行时,并不会走这段逻辑,都是直接执行字节码对应的机器码指令,这段代码可以在 debug 的时候使用,不过最终逻辑是一样的.
其中 cache 变量是 java 代码中变量 i 在常量池缓存中的一个实例,因为变量 i 被 volatile 修饰,所以
cache->is_volatile()
为真,给变量 i 的赋值操作由
release_int_field_put
方法实现.
再来看看
release_int_field_put
方法
内部的赋值动作被包了一层,
OrderAccess::release_store
究竟做了魔法,可以让其它线程读到变量 i 的最新值.
奇怪,在 OrderAccess::release_store 的实现中,第一个参数强制加了一个 volatile,很明显,这是 c/c++ 的关键字.
c/c++ 中的 volatile 关键字,用来修饰变量,通常用于语言级别的 memory barrier ,在 "The C++ Programming Language" 中,对 volatile 的描述如下:
A volatile specifier is a hint to a compiler that an object may change its value in ways not specified by the language so that aggressive optimizations must be avoided.
volatile 是一种类型修饰符,被 volatile 声明的变量表示随时可能发生变化,每次使用时,都必须从变量 i 对应的内存地址读取,编译器对操作该变量的代码不再进行优化,下面写两段简单的 c/c++ 代码验证一下
#include < iostream > int foo = 10;
int a = 1;
int main(int argc, const char * argv[]) {
// insert code here...
a = 2;
a = foo + 10;
int b = a + 20;
return b;
}
代码中的变量 i 其实是无效的,执行 g++ -S -O2 main.cpp 得到编译之后的汇编代码如下:
可以发现,在生成的汇编代码中,对变量 a 的一些无效负责操作果然都被优化掉了,如果在声明变量 a 时加上 volatile
#include < iostream > int foo = 10;
volatile int a = 1;
int main(int argc, const char * argv[]) {
// insert code here...
a = 2;
a = foo + 10;
int b = a + 20;
return b;
}
再次生成汇编代码如下:
和第一次比较,有以下不同:
1,对变量 a 赋值 2 的语句,也保留了下来,虽然是无效的动作,所以 volatile 关键字可以禁止指令优化,其实这里发挥了编译器屏障的作用;
编译器屏障可以避免编译器优化带来的内存乱序访问的问题,也可以手动在代码中插入编译器屏障,比如下面的代码和加 volatile 关键字之后的效果是一样
#include < iostream > int foo = 10;
int a = 1;
int main(int argc, const char * argv[]) {
// insert code here...
a = 2;
__asm__ volatile("": ::"memory"); //编译器屏障
a = foo + 10;
__asm__ volatile("": ::"memory");
int b = a + 20;
return b;
}
编译之后,和上面类似
2,其中_a(%rip) 是变量 a 的每次地址,通过 movl $2, _a(%rip) 可以把变量 a 所在的内存设置成 2,关于 RIP,可以查看 x64 下 PIC 的新寻址方式:RIP 相对寻址
所以,每次对变量 a 的赋值,都会写入到内存中;每次对变量的读取,都会从内存中重新加载.
感觉有点跑偏了,让我们回到 JVM 的代码中来.
执行完赋值操作后,紧接着执行
OrderAccess::storeload()
,这又是啥?
其实这就是经常会念叨的内存屏障,之前只知道念,却不知道是如何实现的.从 CPU 缓存结构分析中已经知道:一个 load 操作需要进入 LoadBuffer,然后再去内存加载;一个 store 操作需要进入 StoreBuffer,然后再写入缓存,这两个操作都是异步的,会导致不正确的指令重排序,所以在 JVM 中定义了一系列的内存屏障来指定指令的执行顺序.
JVM 中定义的内存屏障如下,JDK1.7 的实现
1,loadload 屏障(load1,loadload, load2) 2,loadstore 屏障(load,loadstore, store)
这两个屏障都通过 acquire() 方法实现
其中__asm__,表示汇编代码的开始. volatile,之前分析过了,禁止编译器对代码进行优化. 把这段指令编译之后,发现没有看懂.... 最后的 "memory" 是编译器屏障的作用.
在 LoadBuffer 中插入该屏障,清空屏障之前的 load 操作,然后才能执行屏障之后的操作,可以保证 load 操作的数据在下个 store 指令之前准备好
3,storestore 屏障(store1,storestore, store2) 通过 "release()" 方法实现:
在 StoreBuffer 中插入该屏障,清空屏障之前的 store 操作,然后才能执行屏障之后的 store 操作,保证 store1 写入的数据在执行 store2 时对其它 CPU 可见.
4,storeload 屏障(store,storeload, load) 对 java 中的 volatile 变量进行赋值之后,插入的就是这个屏障,通过 "fence()" 方法实现:
看到这个有没有很兴奋?
通过 os::is_MP() 先判断是不是多核,如果只有一个 CPU 的话,就不存在这些问题了.
storeload 屏障,完全由下面这些指令实现
__asm__ volatile ("lock; addl $0,0(%%rsp)" : : : "cc", "memory");
为了试验这些指令到底有什么用,我们再写点 c++ 代码编译一下
#include < iostream > int foo = 10;
int main(int argc, const char * argv[]) {
// insert code here...
volatile int a = foo + 10;
// __asm__ volatile ("lock; addl $0,0(%%rsp)" : : : "cc", "memory");
volatile int b = foo + 20;
return 0;
}
为了变量 a 和 b 不被编译器优化掉,这里使用了 volatile 进行修饰,编译后的汇编指令如下:
从编译后的代码可以发现,第二次使用 foo 变量时,没有从内存重新加载,使用了寄存器的值.
把
__asm__ volatile ***
指令加上之后重新编译
相比之前,这里多了两个指令,一个 lock,一个 addl. lock 指令的作用是:在执行 lock 后面指令时,会设置处理器的 LOCK# 信号(这个信号会锁定总线,阻止其它 CPU 通过总线访问内存,直到这些指令执行结束),这条指令的执行变成原子操作,之前的读写请求都不能越过 lock 指令进行重排,相当于一个内存屏障.
还有一个:第二次使用 foo 变量时,从内存中重新加载,保证可以拿到 foo 变量的最新值,这是由如下指令实现
__asm__ volatile ( : : : "cc", "memory");
同样是编译器屏障,通知编译器重新生成加载指令 (不可以从缓存寄存器中取).
读取 volatile 变量
同样在
bytecodeInterpreter.cpp
文件中,找到 getstatic 字节码指令的解释器实现.
通过
obj->obj_field_acquire(field_offset)
获取变量值
最终通过
OrderAccess::load_acquire
实现
inline jint OrderAccess::load_acquire(volatile jint* p) { return *p; }
底层基于 C++ 的 volatile 实现,因为 volatile 自带了编译器屏障的功能,总能拿到内存中的最新值.
来源: https://juejin.im/post/5a69f60d5188257350516be8