内存可见性、MESI协议、volatile关键字一文搞通
前言
volatile是我们在学习java线程方面的内容时,经常被提到的一个词,很多人知道他与可见性有关,看过《深入了解java虚拟机的》同学也许能讲得更多点,如禁止指令重排序等。但如果更深入一点:可见性是什么,volatile在java中是如何实现的? 恐怕会陷入混乱了。
这次我们就好好聊聊可见性 和 volatile,争取讲得深入浅出,让大家彻底明白。
一、voaltile是什么意思?
volatile 是java中的一个关键字,用来修饰变量,它的字面意思是“”易变的“。当开发者给一个变量修饰上 volatile ,意味着告诉jvm这个变量可能会被多个线程同时修改,要格外注意该变量的情况,即保证可见性。
二、可见性问题的探索
在讲清楚voaltile之前,需要带大家复习和思考一些问题
1.多线程环境下的可见性问题
随着计算机的发展,我们目前家用机的CPU都能做到8核乃至更多核心,加之超线程技术,一个处理器上能够同时运行的线程数也是大大增加。此时,当一个线程修改了共享变量的值,其他线程往往无法及时“看到”,还继续使用“老旧“的数据,从而导致程序出现难以预测的行为,这类问题称为可见性问题
2.共享变量为什么“不可见”
那么多线程下,为什么共享变量会不可见呢?可以看这张图:
RAM是主存储器,我们程序里的变量会存储其中。但是我们都知道CPU的速度比内存要快得多,不可能CPU的每次运算都实时的从内存中取数据,处理完后又放回内存。那CPU相当于每次瞬间做完工作,然后就会陷入漫长的等待。因此引入了缓存,引入了寄存器等更快速的介质来存储数据,会把一些常用数据存储在这些高速介质上。
需要注意的是,缓存中存储的数据是RAM中的备份,而每个CPU核心都有自己的缓存,也就是说一个数据可能在不同的缓存里都有备份。这样当某个线程修改了该数据,只有该线程所在核心的备份被更新了。RAM和其他核心的缓存用的仍然是“”旧“数据,那么这次修改对其他核心的线程就是不可见的。
3.RAM的更新时机
其实通过上一问我们已经知道,缓存之间,或者缓存与RAM之间的数据好像很容易不一致,主要是由于RAM的数据“老旧”,不由的我们会产生疑问
当CPU更新数据时,“新数据“是什么时候写回RAM的?CPU通常会有两种策略
3.1 写回(write-back)
当CPU写命中缓存,缓存中的数据被修改后,C并且将缓存中该数据对应位置块的“脏位”标记为1,表示该块数据已被修改。当CPU需要将该数据替换出缓存时,才将其写回RAM
3.2 写直达(write-through)
当CPU写时,无论是否命中缓存,都会直接将其写回RAM
可以看到两种策略的特点鲜明:
- “写回”策略讲究写的效率,不到万不得已不写RAM,对于一个线程频繁修改的数据,这样做无疑会节省很多时间,但代价就是RAM迟迟得不到更新,容易造成数据不一致。
- “写直达”每一次更新都直接写入RAM,意味着缓存和RAM数据总是一致的,但带来的问题就是写的效率变低了,如果数据其实只有本线程在用,将造成时间的浪费。
【 扩展1】:写直达法在未命中缓存时,对于是否将修改过的主存块取到cache,有两种选择。一种是取来并且为它分配一个行位置,称为WTWA法(Write–Through–with–Write–Allocate),这样逻辑复杂,但缓存会存下最新的数据。另一种是不取称为WTNWA法(WriteThrough–with.NO-Write–Allocate),逻辑简单,但缓存中就没有刚刚的数据。
【 扩展2 】:后文提到的MESI协议是支持写回(write-back)缓存的最常用协议
4.缓存的更新时机
从上文我们已经知道了如果CPU某个核心做了写处理,RAM会在某个时间被更新。但是还有一个问题,其他核心的缓存的数据什么时候被更新呢?这也就引申出了缓存一致性问题
如上图,A核心对变量 i 做了 +1 处理,但B核心却是不知道的,为了使B核心的缓存能够及时更新,我们似乎可以采取一种传播策略
4.1 写传播(write-propagation)
顾名思义,写传播就是在写一个缓存的时候,需要广播到其他核心的缓存,它的一种常见实现方式就是总线嗅探
例如上文的 A 号 CPU 核心修改了缓存 中 i 变量的值,通过总线把这个事件广播通知给其他所有的核心,然后每个 CPU 核心都会监听总线上的广播事件,并检查是否有相同的数据在自己的缓存中,如果 B 号 CPU 核心的缓存中有该数据,那么也需要把该数据更新到自己的缓存里。
很显然,这种方式的逻辑十分清晰。但也有其问题,
- 每一次修改都会在总线广播,会导致总线负载过重。
- 如果有两个核心同时修改了某个数据,最终的数据会是什么呢?这将无法控制和预知
5 缓存一致性协议(MESI 协议)
很显然,结合上文的 3.1-写回 和4.1-写传播,虽然解决了数据更新的部分问题,但存在着不足,比如:
- 如果一个缓存被更新,而此刻其他缓存没存储该数据,由于内存更新的滞后特点。当其他缓存要从内存读取该数据时,读到的仍然是“旧”数据。
- 如果有两个核心同时修改一个数据,那么结果将不可知,这对程序来说是致命漏洞。
很自然的,我们想防止这些场景,就需要保证
- 内存及时更新,至少在其他缓存要从内存读取该数据的时候能知道内存里的数据是新是旧
- 控制并发,保证一个数据在同一时间只能被一个核心修改
基于这样的要求,于是产生了CPU 缓存一致性协议 ,即MESI协议
MESI协议其实是缓存中数据的四种状态的缩写,这四种状态决定着数据更新将如何同步
M : modified —— 修改,数据只存在于本Cache中,但被本核心修改,还没同步到内存
E : exclusive —— 独享,数据只存在于本Cache中,和内存数据一样
S : shared —— 共享,数据存在于很多Cache中,和内存数据一样
I : invalid ——无效,本核心Cache line无效。
当然这四种状态不是乱来的,需要严格按照定义来执行,比如说本地缓存中有一条数据状态为 E-独享,或者M-已修改,意味着其他缓存要么没有这条数据,要么就只能是 I-废弃。更全面的关系图如下。
任意一对缓存,对应缓存行的相容关系:
看起来很复杂,其实只要一张状态变化图就可以概括,如果你看不懂,没关系,并不是让你死记硬背,记住核心规则后你将很容易理解。
我们直接举个最复杂的场景用来诠释
这里有四个核心A、B、C、D,有一个数据X在核心A的缓存中,且被该核心改过,但没有传回主内存,而其他缓存则没有这个数据X。
这样的情况下,核心A里的数据X,其状态就是M——修改状态,此时,核心B想修改数据X,那么首先:
核心B通过总线告知自己需要获取数据X,总线向各核心索取X的最新数据
核心A将数据X传回总线,核心B和主内存都从总线读取最新的数据X,然后A、B核心的缓存块状态为S——共享
核心B对自己缓存中的数据X修改,B核心缓存块为M——修改,同时将该消息告知总线,A核心缓存为I——无效
如果你看明白了上面这个例子,其实你已经理解了流程的核心原理,即各核心通过总线嗅探来检测和传播数据,并严格遵守状态转换规则,实时更新本缓存的状态。这里,可以给大家介绍一个MESI过程的可视化网站,方便大家加深理解:MESI网站
三、独木难支的MESI
经过上面的内容,大家应该知道了数据在CPU缓存和主内存这些介质间流转的流程了。按照道理讲,MESI协议已经解决了多核心间缓存的一致性问题了,为什么我还继续说有问题呢?
其实答案很简单,对于用户代码而言,我们更关注线程间的可见性,并不是核心缓存数据的一致性,而仅靠MESI无法完全解决该问题:
1. MESI是针对核心缓存的协议,但是CPU核心里还有寄存器,不同核心里寄存器的数据还没规定要实时共享
2. 由于指令重排的影响,单个线程的执行顺序无法确定。导致我们在多个线程间使用共享变量时,仍可能违背本意的获取到新值或旧值
我们可以举个例子
// 线程1执行的代码
x = 1;
flag = true;
// 线程2执行的代码
while (!flag) {
// do something
}
print x;
如上图,线程1的两行代码可能重排序,导致线程2最后的结果可能是0 而不是1;然而从代码看,这违背了程序的原意
四、新的规定——volatile
看到了上文的第三大点,你已经知道了问题的症结,那么做到真正的可见性,解决策略呼之欲出了:
- 不使用寄存器长期存储数据,实时的从内存中读取新数据或写入新数据
- 或者使不同核心间的寄存器和其他存储都能像缓存一样保证一致性
- 禁用各个阶段的指令重排
然而,我们知道,无论是寄存器还是指令重排,都对效率大有裨益,鉴于绝大部分情况不会如此碰巧的出现共享数据不一致的问题。我们不可能因噎废食直接废弃掉这些优化——因此,关键字volatile诞生了。
现在我们可以说,Umm,应该说是一种规定:
如果我在java代码中使用关键字volatile修饰了某数据,就要保证该数据的可见性和有序性。这项规定被包含在JSR133规范中,更官方的讲:
————————————————————————————————
使用 volatile 关键字修饰的变量,其读取和修改操作具有以下特性:
可见性:当一个线程修改了 volatile 变量的值时,其它线程能够立即看到修改后的值。
有序性:当一个线程访问了 volatile 变量时,它能够保证前面的所有操作都已经完成,后面的所有操作还没有开始
————————————————————————————————
实际上volatile不仅仅是java关键字,也是C++的关键字,C++也提供了类似的规定。然而,我们应该意识到,这是一项规范,JSR133是针对JVM的规范,要实现它,需要虚拟机开发人员、编译器开发人员以及更底层的各种系统及硬件的实现和支持,而现在我们还没有谈实现的方法,我们将在下一节继续讨论。
当然,作为业务开发人员,我们可以不在乎它这项规定如何实现,只需要知道它能保证什么效果即可。这样的话,是可以跳过下面的章节的
五、volatile的实现
1. 编译/解释期
我们知道字节码是一种中间语言,还需要jvm的执行引擎进行“翻译”,转成机器语言后才能正常执行,而HotSpot VM包含有“编译器”和“解释器”两种执行引擎。
1.1 禁止编译器优化
在JIT即时编译器中就存在着优化能力,它会重排、合并我们的代码,我们要做的第一步就是去除编译优化的负面影响
public class VolatileClass {
private static boolean isRunning = true;
private static int i = 0;
public static void main(String[] args) {
Thread thread = new Thread(() -> {
while (isRunning) {
i++;
}
});
thread.start();
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
isRunning = false;
System.out.println("isRunning has been changed to false");
}
}
我们可以实测上面的代码,当run方法被执行后,使用另一个线程执行stop方法,可以看到run方法停不下来;这是因为编译器优化,导致isRunning被放入了寄存器中,每次执行直接从寄存器中取值,甚至取值都不会取,直接指令无限循环,此时其他线程虽然修改了isRunning,但寄存器的值不受影响,从而无法停止下来。
我们可以为这段程序加一个jvm参数:-Xint (强制使用解释器执行)
再次执行后,效果显而易见,程序可以停下来了
上述例子,说明了编译器会优化合并我们的代码,但多线程下会出现问题,所以禁用编译器优化在这里起了作用。当我们把isRunning变量转为volatile,然后去掉-Xint参数,就会发现,也能及时停止了(参考我的另一篇文章:分析JIT编译器优化-volatile的影响)。也就是说volatile制止了编译器关于isRunning的优化,这里的优化其实是多种的,指令的重排也在其中。
1.2 插入内存屏障
除了禁止优化,在编译和解释期,volatile还有一个任务要做,因为我们知道,CPU执行指令时,CPU也会对指令顺序做一些调整,并且最后回写内存的顺序也不一定就是指令执行的顺序,这些都会导致并发下的不可控结果。当然,硬件厂商大多提供了解决的方案,即内存屏障,只是需要在执行引擎在读写volatile修饰的字段时,插入内存屏障就能避免CPU执行时出现这类问题。
我们对上一节的代码略作改动,使得变量i变为volatile修饰,即
public class VolatileClass {
private volatile static boolean isRunning = true;
private volatile static int i = 0; // 此处以volatile修饰
public static void main(String[] args) {
Thread thread = new Thread(() -> {
while (isRunning) {
i++;
}
});
thread.start();
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
isRunning = false;
System.out.println("isRunning has been changed to false");
}
}
我们来看他的汇编语句
前三句是i++ ,最后一句就是内存屏障
0x0000022e1a5cecc0: mov 0x68(%r10),%r8d ————读取 i 到寄存器
0x0000022e1a5cecc4: inc %r8d ————寄存器里的值自增1
0x0000022e1a5cecc7: mov %r8d,0x68(%r10) ————寄存器里的值回写至 i
0x0000022e1a5ceccb: lock addl $0x0,(%rsp) ————关键在于前面的lock前缀,会导致cache line的刷新
可以看到最后语句的 lock addl $0x0, (%rsp),这句指令其实是针对x86架构的,因为我是Intel i7 的处理器,所以在编译到这里,给 i 完成自增后,会添加这样一条指令,我们现在只需要知道这条指令的作用是原子性的刷新缓存进内存,实现了可见性,关于内存屏障的详细情况和解释,估计写起来不会比这篇文章短多少,因此我另外开了一篇文章详谈内存屏障的问题:volatile的扩展分析(2)——happens-before 与 内存屏障
2. 指令执行期
现代计算机为了提高CPU效率,提出了指令级并行技术(Instruction-LevelParallelism,ILP)来将多条指令重叠执行。
指令级并行技术虽然只用单个处理器来解析指令,但会设计多个计算单元,比如执行load指令和store指令的内存访问单元、执行整数运算的算术逻辑单元、执行浮点运算的浮点计算单元。处理器也会用流水线技术让用户觉得这些计算单元有重复的好几个可以同时使用。
这样,当处理器在解析指令找到可以并行的部分后,就能把它们发送到并存的计算单元上同时执行了。但是这样的顺序,实际上与原程序顺序未必一致,即乱序执行了,因此需要重排序缓冲区(re-order
buffer, ROB))可以使指令在乱序执行,之后按照原有顺序提交。
当然,这种执行过程乱序 —— 结果保持有序的能力是CPU本身就应该承诺的功能,和我们现在讲的volatile 和 lock addl $0x0, (%rsp) 无关。
以x86为例,真正和 lock addl $0x0, (%rsp)有关的,是另一个缓冲器 ——store buffer 。 这是一个缓存存储指令的缓冲器,当CPU执行存储指令时,数据不会立即写入缓存、内存,而是存在store buffer里
如果你认真阅读过 “第二章-可见性问题的探索”。当你看到这样一个缓冲区和他的存储作用,就应当意识到,它也有重排序的问题,并且由于它会延迟写回内存,这也会导致可见性问题。
万幸的是,x86处理器帮我们解决了第一点:它的store buffer ordering机制确保了所有存储的指令按照他们出现在程序中的顺序执行,并且在store buffer中所有存储指令都被提交前,后续的指令不会被执行
至于存储数据可见性的问题,我们在下一节继续探讨
3. 存储期
前面我们讲到了store buffer这样的缓冲区,会先缓存存储指令,然后再写回内存,这样的延迟很容易导致可见性问题。
所以 lock addl $0x0, (%rsp) 发挥了作用,我们首先要知道,在执行lock前缀指令时,如果这个指令涉及到存储,他就会将数据写回内存。这过程中,他将确保store buffer的内容立即刷新至缓存,也即store buffer flush,它会在执行lock前缀指令前完成
而到了缓存,相信你没有忘记缓存一致性协议吧,即我们提到过的MESI,此刻这个数据已经被其他核心察觉了,当其他核心读取它们的缓存时,就能使用到最新的数据。
六、总结
我们详细的讲解了volatile是一个规则,和这个规则的实现,在这里给一个总结罢:
- volatile首先在编译期就告知JIT编译器,禁止对volatile的相关变量的代码优化(这些优化包括重排),同时编译器和解释器在处理volatile字段的读写时,都会插入一些内存屏障语句
- 得益于这些内存屏障语句,当CPU在执行到对这些变量的读写时,也会执行内存屏障语句,保证读取或更新都是实时的,即更新能被其他核心立即察觉,也即实现了可见性
上面是有volatile修饰时,会做的一些操作。
还有一些有益于可见性的基础逻辑,即使是没有volatile,也是存在的。比如CPU的重排序缓冲区(re-order buffer, ROB)能保证乱序执行的指令按顺序提交。比如store buffer自己就天然有序,能按程序顺序保存存储指令。比如MESI协议保证缓存的一致性。
只有这些基础逻辑,加上volatile修饰后的种种操作,才最终保证了数据在各CPU核心间的可见性。当然可见性并不是原子性,因为迄今为止,我们并没有去控制寄存器里的内容,如果两个核心都把数据 i 读到了寄存器,然后两个核心都执行了 i++ 。那么它们虽然会写回,但写回的数据都是 i + 1。而不会是 i + 2。因此被volatile修饰并不意味着读写是原子性的,也不意味着读写会上锁。