引子
小艾吃饭路上碰上小牛,忙问:你昨天面大厂面的咋样了?听说他们最喜欢问多线程相关知识。
小牛说:对啊,第一个问题我就讲了20分钟,直接把面试官讲服了。
小艾忙问:什么问题能讲这么久?是不是问你情感经历了?
小牛说:…问的 volatile 关键字。
小艾说: volatile 关键词的作用一般有如下两个:
- 可见性:当一个线程修改了由 volatile 关键字修饰的变量的值时,其它线程能够立即得知这个修改。
- 有序性:禁止编译器关于操作 volatile 关键词修饰的变量的指令重排序。
你说这两个说了20分钟?口吃?
小牛说:你知道 volatile 的实现原理吗?
小艾说:缓存一致性协议嘛,这有啥?
小牛说:既然硬件保证了缓存一致性协议,无论该变量是否被 volatile 关键词修饰,它都该满足缓存一致性协议呀。你这说的有点自相矛盾哦。
小艾说:那 volatile 的实现原理是什么?
小牛说:且听我慢慢道来。
缓存的引入
我们知道,当前 CPU 能处理的运算速率极快,而内存数据传输效率偏低,所以往往会出现 CPU 等待内存传输数据的情况,浪费了宝贵的时间。为了缓解 CPU 和内存传输速率不匹配的问题, CPU 厂商在 CPU 内部都设置了高速缓存。
大家都知道局部性原理吧,所谓局部性原理,就是在 CPU 访问存储设备时,无论是存取数据还是指令,都倾向于访问一片连续的局部空间。并且这个原理可以分为时间局部性和空间局部性。
- 时间局部性:如果一个信息项正在被访问,那么在近期它很可能还会被再次访问。
- 空间局部性:如果一个存储器的位置被引用,那么将来它附近的位置也会被引用。
如果将最近使用的变量写入缓存,充分利用变量的时间和空间局部性,就能极大地提升 CPU 的使用效率。
我们希望针对频繁读写的某个内存变量,提升本核心的访问速率。因此我们会给每个核心设计 缓存区( Cache ) 来缓存该变量。由于在硬件上缓存的读写速度比内存快,所以通过这种方式可以提升变量访问速度。
缓存的结构可以如下设计:
其中,一个缓存区可以分为 N 个缓存行(Cache line),缓存行是和内存进行数据交换的最小单位。每个缓存行包含两个部分,Tag用于指示数据对应的内存地址;Block则用以存储数据。
但是这样设计会有这么一个问题,如果涉及到并发任务,多个核心读取同一个变量值,由于每个核心读取的是自己那一部分的缓存,所以往往会出现每个核心的缓存数据不一致的问题。此时核心如何知道自己缓存的变量是否是最新的呢?换句话说,有没有方法能保证各个核心之间缓存一致呢?
缓存一致性协议
为了保证缓存的一致性,业界有两种思路:
- 写失效( Write Invalidate ):当一个核心修改了一份数据,其它核心如果有这份数据,就把 valid 标识为无效;
- 写更新( Write update ):当一个核心修改了一份数据,其它核心如果有这份数据,就都更新为新值,并且还是标记 valid 有效。
业界有多种实现缓存一致性的协议,诸如 MSI、MESI、MOSI、Synapse、Firefly Dragon Protocol 等,其中最为流行的是 MESI 协议。
MESI 协议就是根据写失效的思路,设计的一种缓存一致性协议。为了实现这个协议,原先的缓存行修改如下:每个缓存行包含三个部分,多出了一部分 valid 位代表了该缓存变量的状态,分为四种:
- M( Modified ):表示核心的数据被修改了,缓存数据属于有效状态,但是数据只处于本核心对应的缓存,还没有将这个新数据写到内存中。由于此时数据在各个核心缓存区只有唯一一份,不涉及缓存一致性问题;
- E( Exclusive ):表示数据只存在本核心对应的缓存中,别的核心缓存没这个数据,缓存数据属于有效状态,并且该缓存中的最新数据已经写到内存中了。同样由于此时数据在各个核心缓存区只有一份,也不涉及缓存一致性问题;
- S( Shared ):表示数据存于多个核心对应的缓存中,缓存数据属于有效状态,和内存一致。这种状态的值涉及缓存一致性问题;
- I( Invalid ):表示该核心对应的缓存数据无效。
为了保证缓存一致性,每个核心要写新数据前,需要确保其他核心已经置同一变量数据的缓存行状态位为 Invalid 后,再把新数据写到自己的缓存行,并之后写到内存中。
MESI协议包含以下几个行为:
- 读( Read ):当某个核心需要某个变量的值,并且该核心对应的缓存没这个变量时,就会发出读命令,希望别的核心缓存或者内存能给该核心最新的数据;
- 读命令反馈( Read Response ):读命令反馈是对读命令的回应,包含了之前读命令请求的数据。举例来说,CPU中的某个核心,核心0发送读命令,请求变量 a 的值,而另一个核心1对应的缓存区包含变量 a ,并且该缓存的状态是 M 状态,所以核心0会给核心1的读命令发送读命令反馈,给出该值;
- 无效化( Invalidate ):无效化指令是一条广播指令,它告诉其他所有核心,缓存中某个变量已经无效了。如果变量是独占的,只存在某一个核心对应的缓存区中,那就不存在缓存一致性问题了,直接在自己缓存中改了就行,也不用发送无效化指令;
- 无效化确认( Invalidate Acknowledge ):该指令是对无效化指令的回复,收到无效化指令的核心,需要将自己缓存区对应的变量状态改为 Invalid ,并回复无效化确认,以此保证发送无效化确认的缓存已经无效了;
- 读无效( Read Invalidate ):这个命令是读命令和无效化命令的综合体。它需要接受读命令反馈和无效化确认;
- 写回( Writeback )这个命令的意思是将核心中某个缓存行对应的变量值写回到内存中去。
下图给了个一个应用 MESI 读写数据的例子。在该图中,假设 CPU 有两个核心,核心0表示第一个核心,核心1表示第二个核心。这里给出了核心0想写新数据到自己缓存的例子。
- 首先核心0先完成新数据的创建;
- 核心0向全体其他核心发送无效化指令,告诉其他核心其所对应的缓存区中的这条数据已经过期无效。本图例中只有一个其他核心,为核心1;
- 其他核心收到广播消息后,将自己对应缓存的数据的标志位记为无效,然后给核心0回确认消息;
- 收到所有其他核心的确认消息后,核心0才能将新数据写回到它所对应的缓存结构中去。
根据上图,我们可以发现,虽然 MSEI 解决了缓存的一致性问题,但是它的时间效率还是有待优化的。我们可以看到,影响 MESI 协议的时间瓶颈主要有两块:
- 无效化指令:核心0需要通知所有的核心,该变量对应的缓存在其他核心中是无效的。在通知完之前,该核心不能做任何关于这个变量的操作。
- 确认响应:核心0需要收到其他核心的确认响应。在收到确认消息之前,该核心不能做任何关于这个变量的操作,需要持续等待其他核心的响应,直到所有核心响应完成,将其对应的缓存行标志位设为 Invalid ,才能继续其它操作。
MESI的加速策略
MESI 的加速策略自然是针对无效化指令和确认响应这两部分来完成的,我们来看一下它们对应的加速策略:
- 针对无效化指令的加速:在缓存的基础上,引入 Store Buffer 这个结构。 Store Buffer 是一个特殊的硬件存储结构。通俗的来讲,核心可以先将变量写入 Store Buffer ,然后再处理其他事情。如果后面的操作需要用到这个变量,就可以从 Store Buffer 中读取变量的值,核心读数据的順序变成 Store Buffer → 缓存 → 内存。这样在任何时候核心都不会卡住,做不了关于这个变量的操作了。引入 Store Buffer 后的结构如下所示:
- 针对确认响应的加速:在缓存的基础上,引入 Invalidate Queue 这个结构。其他核心收到核心0的 Invalidate 的命令后,立即给核心0回 Acknowledge ,并把 Invalidate 这个操作,先记录到 Invalidate Queue 里,当其他操作结束时,再从 Invalidate Queue 中取命令,进行 Invalidate 操作。所以当核心0收到确认响应时,其他核心对应的缓存行可能还没完全置为 Invalid 状态。引入 Invalidate Queue 后的结构如下所示:
MESI的加速策略存在的问题
上一节讲了两种缓存一致性协议的加速方式。但是这两个方式却会对缓存一致性导致一定的偏差,下面我们来看一下两个出错的例子:
例子1:关于Store Buffer带来的错误,假设CPU有两个核心,核心0表示第一个核心,核心1表示第二个核心。
...
public void foo(){
a=1;
b=1;
}
public void bar(){
while(b==0) continue;
assert(a==1):"a has a wrong value!";
}
...
如果核心0执行 foo() 函数,核心1执行 bar() 函数,按照之前我们的理解,如果 b 变量为1了,那 a 肯定为1了, assert(a==1) 肯定不会报错。但是事实却不是这样的。
假设初始情况是这样的:在执行两个函数前核心1的缓存包含变量 a=0 ,不包含缓存变量 b ,核心0的缓存包含变量 b=0 ,不包含缓存变量 a 。
核心0执行 foo() 函数,核心1执行 bar() 函数时,计算机的指令程序可能会如下展开:
- 核心0执行a=1。由于核心0的缓存行不包含变量 a ,因此核心0会将变量 a 的值存在 Store Buffer 中,并且向其他核心进行 read Invalidate 操作,通知 a 变量缓存无效;
- 核心1执行 while(b==0) ,由于核心1的缓存没有变量 b ,因此它需要发送一个读命令,去找 b 的值;
- 核心0执行 b=1 ,由于核心0的缓存中已经有了变量 b ,而且别的核心没有这个变量的缓存,所以它可以直接更改缓存 b 的值;
- 核心0收到读命令后,将最新的 b 的值发送给核心1,并且将变量 b 的状态由 E (独占)改变为 S (共享);
- 核心1收到 b 的值后,将其存到自己核心对应的缓存区中;
- 核心1接着执行 while(b==0) ,因为此时 b 的新值为1,因此跳出循环;
- 核心1执行 assert(a==1) ,由于核心1缓存中 a 的值为0,并且是有效的,所以断言出错;
- 核心1终于收到了第一步核心0发送的 Invalidate 了,赶紧将缓存区的 a=0 置为 nvalid ,但是为时已晚。
所以我们看到,这个例子出错的原因完全是由 Store Buffer 这个结构引发的。如果规定将 Store Buffer 中数据完全刷入到缓存,才能执行对应变量写操作的话,该错误也能避免了。
例子2:关于 Invalidate Queue 带来的错误,同样假设 CPU 有两个核心,核心0表示第一个核心,核心1表示第二个核心。
...
public void foo(){
a=1;
b=1;
}
public void bar(){
while(b==0) continue;
assert(a==1):"a has a wrong value!";
}
...
核心0执行 foo() 函数,核心1执行 bar() 函数,猜猜看这次断言会出错吗?
假设在初始情况是这样的:变量 a 的值在核心0和核心1对应的缓存区都有,状态为 S (共享),初值为0,变量 b 的值是0,状态为 E (独占),只存在于核心0对应的缓存区,不存在核心1对应的缓存区。假设核心0执行 foo() 函数,核心1执行 bar() 函数时,程序执行过程如下:
- 核心0执行 a=1 ,此时由于 a 变量被更改了,需要给核心1发送无效化命令,并且将 a 的值存储在核心0的 Store Buffer 中;
- 核心1执行 while(b==0) ,由于核心1对应的缓存不包含变量 b ,它需要发出一个读命令;
- 核心0执行 b=1 ,由于是独占的,因此它直接更改自己缓存的值;
- 核心0收到读命令,将最新的 b 的值发送给核心1,并且将变量 b 的状态改变为S (共享);
- 核心1收到核心0在第一步发的无效化命令,将这个命令存到 Invalidate Queue 中,打算之后再处理,并且给核心0回确认响应;
- 核心1收到包含 b 值的读命令反馈,把该值存到自己缓存下;
- 核心1收到 b 的值之后,打破 while 循环;
- 核心1执行 assert(a==1) ,由于此时 Invalidate Queue 中的无效化 a=0 这个缓存值还没执行,因此核心1会接着用自己缓存中的 a=0 这个缓存值,这就出现了问题;
- 核心1开始执行 Invalidate Queue 中的命令,将 a=0 这个缓存值无效化。但这时已经太晚了。
所以我们看到,这个例子出错的原因完全是由 Invalidate Queue 这个结构引发的。如果规定将 Invalidate Queue 中命令完全处理完,才能执行对应变量读操作的话,该错误也能避免了。
内存屏障
既然刚刚我们遇到了问题,那如何改正呢?这里就终于到了今天的重头戏,内存屏障了。内存屏障简单来讲就是一行命令,规定了某个针对缓存的操作。这里我们来看一下最常见的写屏障和读屏障。
- 针对 Store Buffer :核心在后续变量的新值写入之前,把 Store Buffer 的所有值刷新到缓存;核心要么就等待刷新完成后写入,要么就把后续的后续变量的新值放到 Store Buffer 中,直到 Store Buffer 的数据按顺序刷入缓存。这种也称为内存屏障中的写屏障( Store Barrier )。
- 针对 Invalidate Queue :执行后需等待 Invalidate Queue 完全应用到缓存后,后续的读操作才能继续执行,保证执行前后的读操作对其他 CPU 而言是顺序执行的。这种也称为内存屏障中的读屏障( Load Barrier )。
对于 JVM 的内存屏障实现中,也采取了相似的技术。 JVM 的内存屏障有四种,这四种实际上也是上述的读屏障和写屏障的组合。我们来看一下这四种屏障和他们的作用:
-
LoadLoad屏障:对于这样的语句
第一大段读数据指令; LoadLoad; 第二大段读数据指令;
LoadLoad指令作用:在第二大段读数据指令被访问前,保证第一大段读数据指令执行完毕,这样能保证第一大段读数据指令和第二大段读数据指令之间不会发送重排序。
-
StoreStore屏障:对于这样的语句
第一大段写数据指令; StoreStore; 第二大段写数据指令;
StoreStore指令作用:在第二大段写数据指令被访问前,保证第一大段写数据指令执行完毕,这样能保证第一大段写数据指令和第二大段写数据指令之间不会发送重排序。
-
LoadStore屏障:对于这样的语句
第一大段读数据指令; LoadStore; 第二大段写数据指令;
LoadStore指令作用:在第二大段写数据指令被访问前,保证第一大段读数据指令执行完毕,这样能保证第一大段读数据指令和第二大段写数据指令之间不会发送重排序。
-
StoreLoad屏障:对于这样的语句
第一大段写数据指令; StoreLoad; 第二大段读数据指令;
StoreLoad指令作用:在第二大段读数据指令被访问前,保证第一大段写数据指令执行完毕,这样能保证第一大段写数据指令和第二大段读数据指令之间不会发送重排序。
volatile有序性和可见性的实现
我们知道,为了保证代码的运行效率,很多编译器会对编译完成的代码进行优化,对指令进行重排序,提升代码效率。 java 代码在编译的过程中,会有两阶段出现指令的重排序,第一个阶段就在 JVM 源码编译过程中,第二个阶段就在于 JVM 指令编译成汇编指令的过程中。
针对 JVM 源码编译过程中, volatile 关键字是这么实现禁止指令重排序的:
现在假设有两条指令,分析两条指令重排序发生的场合:
- 当第一个操作是 volatile 修饰变量的读操作,不管第二个操作是什么,都不能将这两个指令进行重排序;
- 当第一个操作是 volatile 修饰变量的写操作,第二个操作是 volatile 修饰变量的读或写操作,不能将这两个指令进行重排序;
- 当第一个操作是普通读写,第二个操作是 volatile 修饰变量的写操作时,都不能将这两个指令进行重排序。
在 JVM 指令编译成汇编指令的过程中,它是这么实现的:
- 针对 volatile 修饰变量的写操作:在写操作前插入 StoreStore 屏障,在写操作后插入 StoreLoad 屏障;
- 针对 volatile 修饰变量的读操作:在每个 volatile 读操作前插入 LoadLoad 屏障,在读操作后插入 LoadStore 屏障。
通过这种方式,可以保证 volatile 写操作不会和之前的写操作重排序,也能保证 volatile 写操作不会和之后的读操作重排序,也能保证 volatile 读操作不会和之后的读操作、写操作重排序。通过禁止指令重排序的的方式,就可以实现 volatile 的有序性。
而关于可见性的实现,其底层实现也是通过内存屏障实现的。通过内存屏障,能保证线程能读到的数据是最新的。
总结
讲了这么多,我们来总结一下。
volatile关键字保证了两个性质:
- 可见性:可见性是指当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值。
- 有序性:对一个 volatile 变量的写操作,执行在任意后续对这个 volatile 变量的读操作之前。
单单缓存一致性协议无法实现volatile。
缓存一致性可以通过 Store Buffer 和 Invalidate Queue 两种结构进行加速,但这两种方式会造成一系列不一致性的问题。
因此后续提出了内存屏障的概念,分为读屏障和写屏障,以此修正 Store Buffer 和 Invalidate Queue 产生的问题。
通过读屏障和写屏障,又发展出了 LoadLoad 屏障、 StoreStore 屏障、 LoadStore 屏障、 StoreLoad 屏障。
JVM也是利用了这几种屏障,实现 volatile 关键字。
感谢各位少侠阅读,我们将会为大家带来更多精彩原创文章。
参考:
- Java多线程编程核心指南
- Java并发实现原理:JDK源码剖析
- https://www.jianshu.com/p/ef8de88b1343
- Paul E. McKenney Memory Barriers: a Hardware View for Software Hackers
- https://www.cnblogs.com/xiaolincoding/p/13886559.html
- https://blog.youkuaiyun.com/lc13571525583/article/details/90345760