扩展分析内存屏障
前言
我们在volatile精讲篇提到了volatile的一个作用:在jvm编译和解释volatile相关字段的读写时会加入内存屏障,但是内存屏障其实是很大的一块内容,因此我们单独开一篇出来讲这个问题
免责声明
因为关于这块的内容实在太混乱了,而且由于硬件平台和JVM版本的不同,众多文献混于一体。导致在我探索的过程中,不断发现理论与实际的出入,或者说规范和实际的出入。一些细节的规则出现的非常突兀,并没有详细解释出处和原由。因此无法保证所有细节和分析都是对的。另外本文一些内容源自chatGPT,亦无法保证其阐述的一定正确。个人建议可以把本文作为一种角度的诠释,有助于解惑,如果你有好的看法或发现错误,可以在评论区留言
一、从Happens-before 到 内存屏障
1. 什么是Happens-before
我们知道由于硬件的效率的需要,指令在执行时是会被重排序的,但是重排序不能肆无忌惮没有任何限制。它必须要遵守一定的原则,有些地方可以重排序,有些地方不行:
这个规则即Happens-before规则,满足这些规则可以在避免被重排,这是由java内存模型规定的一组抽象规则,java虚拟机和java编译器都需要遵守这些规则:
-
次序规则
一个线程内,按照代码顺序,写在前面的操作先行发生于写在后面的操作 -
锁定规则
一个unLock操作先行发生于后面((这里的“后面”是指时间上的先后))对同一个锁的lock操作 -
volatile变量规则
对一个volatile变量的写操作先行发生于后面对这个变量的读操作,前面的写对后面的读是可见的,这里的“后面”同样是指时间上的先后 -
传递规则
如果操作A先行发生于操作B,而操作B又先行发生于操作C,则可以得出操作A先行发生于操作C;
我们必须在这里强调三个东西:
- 上述次序规则和指令重排并不冲突,实际上次序规则仅仅是要求一个线程内自己的逻辑和结果正确,并非严格意义的每一个指令都必须按照代码顺序执行,否则就没有指令重排这种东西了,但如果我们想人为的在某些地方禁止重排,就需要显式的建立遵循happens-before规则的代码
- 反过来讲,如果我们在代码里没有显式地建立起happens-before关系的操作,那么就无法保证重排的结果,多线程下结果自然更无法保证
- 怎样显式的建立happens-before关系?要使用volatile关键字,synchronized关键字,lock,Semaphore等锁机制,这些机制会在适当的时候插入内存屏障,维护程序的执行顺序。
2. volatile变量规则解读——指令重排规则
我们本篇文章就是做volatile的扩展分析,因此以volatile变量的读写来举例说明它是如何维护程序的可见顺序的。
首先两个普通读写如果没有关联关系,那将无法控制指令重排我们已经知道了;而两次volatile读写之间控制重排也容易理解,因为这就是我们要做的,要想让volatile能维护程序顺序,volatile读写作为锚点,自身肯定要保证顺序不能乱。
关键在于一个volatile操作 和 一个普通操作,它们之间是否能重排,为什么?我们配合一段代码来看
Integer a = 0,
Integer b = 0;
// 线程1
{
a = 10; // 序列1
b = 5; // 序列2
}
// 线程2
{
if (b = 5) { // 序列3
assert a == 10; // 序列4
}
}
上面的代码中,我们假定线程2比线程1后运行一些,我们的目的自然是想要确保通过断言,此时,就必须按照序列1 2 3 4的顺序来执行,然而序列1 和 2 可能会被重排,2 和 3 又跨线程可能不可见,这将导致断言可能无法通过。我们可以给变量a b 都使用volatile来修饰,那将确保结果正确,但如果只能给一个变量标上volatile呢?那我们怎么保证断言通过?
如果我们给a修饰上volatile,我们能得到什么排序?
序列1->序列2:我们得补充重排序规则:volataile写之后的指令不能重排序到volatile写之前;
序列3->序列4:有逻辑关系,必须遵守次序规则
序列2->序列3:跨线程,可能不可见,这我们就没法控制了,导致根本无法进入判断。
如果我们给b修饰上volatile呢?
序列1->序列2:得补充个重排序规则:volataile写之前的指令不能重排序到volatile写之后
序列2->序列3:happens-before 里的 ”volatile变量规则“
序列3->序列4:有逻辑关系,必须遵守次序规则,
其实我们已经看到了结果,第二个方案才行得通,你也许会说,你这仅仅是一个例子,我还是没明白为什么volatile的重排序规则是这样。其实可以简单说明:
因为现行的happens-before规则里,次序规则并没有严格意义上控制指令的执行顺序,只要保证本线程逻辑正确即可,然而当代码中出现volatile写的时候,鉴于volatile的规则(对一个volatile变量的写操作先行发生于后面对这个变量的读操作,前面的写对后面的读是可见的)支持跨线程,就意味着本线程可能会和其他线程以volatile指令为桥梁产生关联。因此本来可以随便乱序,反正没人看,到现在有人看了,那就必须保证当前该执行的指令都执行而且可见了,即volatile写之前的指令不允许排到后面去
同样的,当代码中有volatile读的时候,说明本线程可能在这个位置与其他线程发生关联,那么本线程也就不能随便乱序了,执行必须遵守次序规则,保证volatile读后面的指令能看到这次volatile读,即volatile读后面的指令不能重排到volatile读之前
更通俗的讲:次序规则本来只需保证本线程结果正确,真正的执行顺序其实是乱的。但现在因为代码里有volatile,将会与其他线程沟通,volatile相当于一个稳定的线程A 、B的传话筒,因此A线程在volatile写之前,得先把话准备好。而B线程则得在volatile读之后,再决定做什么
当然,这种规则导致volatile的禁止重排是单向的,更严格的办法自然是出现volatile,不管读写,前面的指令就不能跨过volatile排到后面,后面也不能排到前面,但那没有必要,至少在当前happens-before的原则下,这种控制会导致效率降低,却不能提供更多的帮助了,如下图的代码。
Integer a = 0;
volatile Integer b = 0;
// 线程1
{
a = 10; // 序列1
b = 5; // 序列2
a = 20; // 补充序列
}
// 线程2
{
if (b = 5) { // 序列3
assert a == 10; // 序列4
}
}
我们加入了a = 20的语句,如果volatile写单纯禁止前面的指令往后排,那么此处可见性顺序就是
序列1->序列2 ———— volatile补充规则
序列1->补充序列 ———— 同变量,必须遵守次序规则
序列2->序列3 ———— volatile 的 happes-before规则
序列3->序列4 ————逻辑关联,次序规则
无法确定补充序列和序列2、3、4的可见性顺序,线程2读到的a可能是10,也可能是20,断言结果无法保证。
而如果volatile写双向禁止重排,那么此处可见顺序就是
序列1->序列2->补充序列 ———— volatile补充规则
序列2->序列3 ———— volatile 的 happes-before规则
序列3->序列4 ————逻辑关联,次序规则
我们可以看到,即使有更严格的重排序规则,这个补充序列 和 序列3、序列4的可见关系我们仍然无法确定,导致
线程2读到的a可能是10,也可能是20,断言结果无法保证。
产生这种情况的原因主要是a = 20这个指令和线程2 没有产生可见性保障:
采用现行规则,无法保障线程1里是否会有重排情况,即a = 20可能在b = 5后执行,也可能在b = 5前执行,导致线程2看到的a值并不确定。
采用严格规则,a =20肯定在 b = 5之后,但a = 20具体什么时候执行,什么时候会让线程2看到?同样没有定数,所以线程2看到的a值也是不确定。
二、内存屏障是什么?
我们前面讲了volatile的指令重排规则,以及它这么规定的原因。那这样的规则,肯定是需要实现的,所以在每一次volatile的读写前后,都需要插入一些代码来作为锚点,来做某个方向的禁止重排,这样的机制我们成为内存屏障。
内存屏障是一种处理器提供的机制,由一种特殊的指令实现,能够控制处理器对内存的访问和缓存同步,以确保线程能够正确地访问共享数据。在JVM中,当一个线程访问volatile字段时,JVM会通过插入内存屏障来保证所有线程都能够正确地读取和写入该字段的值。
三、内存屏障的类型
我们前面说了,内存屏障是一种处理器提供的机制,因此显而易见的,不同的CPU提供的内存屏障是不同的。而作为JVM,JVM自己在逻辑上也定义了一些内存屏障的类型,JVM规定的内存屏障类型是针对Java程序员的。这两种内存屏障不可混为一谈,它们针对的是不同的层面,实际上JVM自己定义的屏障仅仅是一种逻辑,还要根据不同的CPU“翻译”成CPU的屏障才能得以落实(具体情况见第四章)
我们先来看看JVM规定的几种内存屏障:Load Load屏障、Store Store屏障、Load Store屏障、Store Load屏障、Acquire屏障和Release屏障
我们再来看看CPU提供的内存屏障,以x86架构为例,看X86的常用内存屏障(Memory Barrier),也叫内存栅栏(Memeory Fence)
-
Load Barrier(载入屏障):Load Barrier用于确保所有先前的读操作都已经完成,从而防止CPU在读取未更新的缓存数据。Load Barrier主要用于数据同步和保证访问序列的正确性。
-
Store Barrier(存储屏障):Store Barrier用于确保所有先前的写操作都已经完成,从而防止CPU在写入更新的缓存数据之前将数据缓存到本地缓存中。Store Barrier主要用于数据同步和保证访问序列的正确性。
-
Full Memory Barrier(全内存屏障):Full Memory Barrier用于确保所有先前的读写操作都已经完成,从而防止CPU在读取或写入未更新的缓存数据。Full Memory Barrier主要用于保证内存访问的原子性。
同样的,作为三种内存屏障的对应实现,x86平台提供了三种指令LFENCE、SFENCE、MFENCE用来禁止重排序,比如MFENCE:MFENCE指令会等待所有之前已发出的内存指令(包括load 和 store指令)都完成后才会继续执行。
我们仍以volatila的读写为例,说明JVM是怎么为volatila的读写去找合适的屏障的,还是先看这张图
然后再看JVM几种屏障的解释
屏障类型 | 指令示例 | 说明 |
---|---|---|
LoadLoad | Load1;LoadLoad;Load2 | 该屏障确保Load1数据的装载先于Load2及其后所有装载指令的的操作 |
StoreStore | Store1;StoreStore;Store2 | 该屏障确保Store1立刻刷新数据到内存(使其对其他处理器可见)的操作先于Store2及其后所有存储指令的操作 |
LoadStore | Load1;LoadStore;Store2 | 确保Load1的数据装载先于Store2及其后所有的存储指令刷新数据到内存的操作 |
StoreLoad | Store1;StoreLoad;Load1 | 该屏障确保Store1立刻刷新数据到内存的操作先于Load2及其后所有装载装载指令的操作.它会使该屏障之前的所有内存访问指令(存储指令和访问指令)完成之后,才执行该屏障之后的内存访问指令 |
最后得到这样的一张图(官方图)
那么我们是不是可以根据这张图得出一个结论:
在volatile读的时候限制后面的指令向前排,即在volatile读后加入loadload 和 loadstore
在volatile写的时候限制前面的指令向后排,即在volatile写前加入loadstore 和 storestore
恭喜你,只猜对了一半,理论是理论,实际总是会有不同:以x86平台为例,volatile读你猜对了。而volatile写前面不需要加loadstore,因为本线程的load对外暴不暴露不会有任何影响,因此不需要消耗性能来控制它的重排,我们只需要保证写的内容不重排到后面去即可。反而是volatile写后面则需要加上storeload屏障,这里不是为了禁止重排,其实就是happens-before里提到的volatile变量规则的实现,不加这个,volatile写就不会让别的线程可见。
所以x86平台真实的内存屏障情况是:
在volatile读的时候限制后面的指令向前排,即在volatile读后加入loadload 和 loadstore
在volatile写的时候限制前面的指令向后排,即在volatile写前加入storestore,同时需要向后保证可见性,需要在volatile写之后加入storeload
四、JVM内存屏障的实现
我们上面说了,JVM 和 CPU 都有自己规定的内存屏障类型,他们之间有关联,JVM根据规则为代码在逻辑上找到合适的屏障,然后翻译成对应CPU的屏障,最终把CPU的屏障插入代码,那么你是不是以为学完了?别急,以x86为例,jvm真的会使用x86提供的内存屏障指令吗?我们借用一张图来说明:
源码来源:orderAccess_linux_x86.inline.hpp
可以看到在jdk8u中,在针对x86平台时,Load Load屏障、Load Store屏障、Acquire屏障是一样的
// 上面代码的意思就是加载栈顶的值,存储到寄存器,并非调用x86的内存屏障指令,
// 不过因为栈的特性,操作栈顶元素其实就意味着之前的读取操作都已经结束了,
// 所以实际上这种方法又叫“栈顶内存屏障“
__asm__ volatile ("movq 0(%%rsp), %0" : "=r" (local_dummy) : : "memory");
而Store Store屏障 和 Release屏障也是一样的内容。 Store Load屏障则比较独特 执行了个
// 这是一个带lock前缀的加0指令。它会进行总线锁定,保证指令执行的原子性
__asm__ volatile ("lock; addl $0,0(%%rsp)" : : : "cc", "memory");
纵观上述内容,我们发现JVM对x86的插入内存屏障好像并不是想象的那样有fence族指令,而是通过嵌入内存访问指令来实现的,这些指令提供了比fence族指令更细粒度的内存屏障。
当然这并不是说x86的fence族指令没用,只是JVM出于消耗和性能考虑,选用了嵌入内存访问指令的方式,而没有选用更为“正式”的CPU内存屏障