背景
本文仅是为了帮助入门新人了解什么是内存屏障、内存屏障都做了什么等基础问题,并无深入说明。
内存屏障的实现涉及大量硬件架构层面的知识,又需要操作系统或JVM的配合才能发挥威力,单纯从任何一个层面都无法理解。
如有疏漏,还望指正!
什么是内存屏障
内存屏障(Memory Barrier)是一个CPU指令。是硬件之上、操作系统或JVM
之下,对并发作出的最后一层支持。再向下是是硬件提供的支持;向上是操作系统或JVM
对内存屏障作出的各种封装。内存屏障是一种标准,各厂商可能采用不同的实现。
接下来大概说一下CPU缓存,作为背景板。
CPU缓存
学过《计算机组成原理》的同学应该都听过一个词:时钟周期。什么是时钟周期呢?通俗点来讲就是CPU完成一个基本动作需要的时间周期。对硬件有点认识的同学都知道看CPU好不好一定要看的一个参数:多少多少GHZ。这个GHZ跟时钟周期之间是存在一定的换算关系的,感兴趣的同学可以去自行研究。说明一下:不了解这层换算关系不影响你看后面的内容,但是你对时钟周期一定要有一个基本认知。
在很早以前,CPU里面是没有缓存这块区域的,就是CPU直接读写内存。那后面为什么在CPU中增加了缓存呢?因为CPU的运行效率与读写内存的效率存在着巨大的鸿沟,在读写内存过程中带来的等待浪费了很大的CPU算力。按照DDR4的内存规格,向内存中写入数据,据权威资料,需要107个CPU时钟周期,即CPU的运行效率是写内存的107倍。如果CPU只执行写操作需要一个时钟周期,那我等待这个写完成需要等待106个时钟周期,是不是很浪费CPU算力?那如何解决呢?就跟我们工作中发现MySQL出现读写瓶颈如何解决是一样的思维:加缓存。
拿我们今天主流的CPU架构来说,现在的CPU主要采用三层缓存:
-
L1、L2缓存成为本地核心内缓存,即一个核一个。如果你的机器是4核,那就是有4个L1+4个L2。
-
L3缓存是所有核共享的。即不管你的CPU是几核,这个CPU中只有一个L3。
-
L1缓存的大小是64K,即32K指令缓存+32K数据缓存。L2是256K,L3是2M。这不是绝对的,目前Intel CPU基本是这样的设计。
这里还补充一个知识点:缓存行(Cache-line)。缓存行是CPU缓存存储数据的最小单位,大小为64B。这块如果展开来讲要讲很久很久,本篇文章就不展开讲了,有兴趣的同学可以自行研究。如果你没有学习过计算机硬件相关知识,可能看不懂。
那采用三层缓存架构的CPU读写内存的顺序是怎样的呢?以下面的代码为例:
// 将10赋值给eax寄存器
mov eax,10
// 将寄存器eax的值写回内存(内存地址为0x12345678)
mov [0x12345678], eax
1、读的时候,从L1->L2->L3->内存这样一层一层向下检测。读到数据了,再采用逆向的方式一层一层向上传递。对于读,现在的CPU又加入了新的优化策略:预读。即CPU对有规律的程序或用到的数据会提前读入缓存。对这块有兴趣的同学就自行研究吧。
2、写的时候,如果这个写操作是CPU0执行的,那CPU0的寄存器eax中就有了这个值,
根据哲学的矛盾相对论:任何问题的解决方案都是一个利与弊共存的矛盾体。加缓存的确有效提升了CPU的执行效率,但是CPU缓存间的数据一致性、CPU缓存与内存间的数据一致性就是不得不去思考与解决的问题了。而且还得保证解决这两层数据一致性的效率要高于不加缓存前浪费的CPU算力,不然这个方案就是一套伪方案:听起来高大上,不解决问题。
缓存的一致性
童鞋们应该都了解过MESI协议吧,没有了解过的可以百度普及一下。这里拓展两点:
一、CPU运算单元与L1缓存间为什么要增加buffer?CPU实现各个核的缓存与内存间的数据一致性的思路有点像socket的三次握手:CPU0修改了某个数据,需要广播告诉其他CPU,这时候CPU0进入阻塞状态等待其他CPU修改其缓存中的状态,待其他CPU都修改完状态返回应答消息后才进入运行状态。虽然这个阻塞的时间很短,但是在CPU的世界里就很长了,为了保证这部分阻塞时间得到充分利用,于是加入了buffer。将预读信息存储进去,这样CPU解除阻塞后就可以直接处理buffer中的请求。
二、MESI协议(Modified Exclusive Shared Or Invalid,简称MESI,即缓存一致性协议)的实现思路是:如果CPU0修改了某个数据,需要广播给其他CPU,缓存中没有这个数据的CPU丢弃这个广播消息,缓存中有这个数据的CPU监听到这个广播后会将相应的缓存行改为invalid状态,这样其他CPU在下次读取这个数据的时候发现缓存行失效,就去内存中读取。这里面童鞋们有没有发现一个问题:只要存在数据修改,CPU就需要去内存取数据,那为什么不实现CPU缓存能共享数据呢?这样CPU在下次读取的时候去CPU0的缓存行去读取就可以啦,而且性能更高。现在的CPU也的确实现了这个思路,对应的协议就是:AMD的MOESI、Intel的MESIF。感兴趣的童鞋自己去研究吧。
内存屏障的由来
对于CPU的写,目前主流策略有两种:
1、write back:即CPU向内存写数据时,先把真实数据放入store buffer中,待到某个合适的时间点,CPU才会将store buffer中的数据刷到内存中,而且这两个操作是异步的。这在多线程环境中,有些情况下是可以接受的,但是有些情况是不可接受的,为了让程序员有能力根据业务需要达到同步完成,就设计了内存屏障。
2、write through:即CPU向内存写数据时,同步完成写store buffer与内存。
当前CPU大多数采用的是write back策略。可能有童鞋要问了:为什么呢?因为大多数情况下,CPU异步完成写内存产生的部分延迟是可以接受的,而且这个延迟极短。只有在多线程环境下需要严格保证内存可见等极少数特殊情况下才需要保证CPU的写在外界看来是同步完成的,需要借助CPU提供的内存屏障实现。如果直接采用策略2:write through,那每次写内存都需要等待数据刷入内存,极大影响了CPU的执行效率。
内存屏障实现思路
为什么要插入屏障?本质是业务层面不能接受写store buffer与刷回内存这两个异步操作产生的哪怕是极少的延迟,即对内存可见性的要求极高。
内存屏障到底是什么?内存屏障什么都不是,它只是一个抽象概念,就像OOP。如果这样说你不理解,那你把他理解成一堵墙,这堵墙正面与反面的指令无法被CPU乱序执行即这堵墙正面与反面的读写操作需有序执行。
CPU提供了三个汇编指令串行化运行读写指令达到实现保证读写有序性的目的:
SFENCE:是一种Store Barrier写屏障。在该指令前的写操作必须在该指令后的写操作前完成。
LFENCE:是一种Load Barrier读屏障。在该指令前的读操作必须在该指令后的读操作前完成。
MFENCE:是一种全能型的屏障,具备SFENCE和LFENCE的能力。在该指令前的读写操作必须在该指令后的读写操作前完成。
另外,还有一种Lock前缀。Lock不是一种内存屏障,但是它能完成类似内存屏障的功能。Lock会对CPU总线和高速缓存加锁,可以理解为CPU指令级的一种锁。它后面可以跟ADD, ADC, AND, BTC, BTR, BTS, CMPXCHG, CMPXCH8B, DEC, INC, NEG, NOT, OR, SBB, SUB, XOR, XADD, and XCHG等指令。
何谓串行化?你可以理解成CPU把读、写、读写请求放入了一个队列,按照先进先出的顺序执行下去;何谓读操作完成,即CPU执行一次读操作,把值读到了寄存器中;何谓写操作完成,即CPU执行一次写操作,数据刷到内存中了。
内存屏障的主要类型
不同硬件实现内存屏障的方式不同,Java内存模型屏蔽了这种底层硬件平台的差异,由JVM来为不同的平台生成相应的机器码。
Java内存屏障主要有Load和Store两类。
对Load Barrier来说,在读指令前插入读屏障,可以让高速缓存中的数据失效,重新从主内存加载数据
对Store Barrier来说,在写指令之后插入写屏障,能让写入缓存的最新数据写回到主内存
对于Load和Store,在实际使用中,又分为以下四种:
LoadLoad 屏障
序列:Load1,Loadload,Load2
确保Load1所要读入的数据能够在被Load2和后续的load指令访问前读入。通常能执行预加载指令或/和支持乱序处理的处理器中需要显式声明Loadload屏障,因为在这些处理器中正在等待的加载指令能够绕过正在等待存储的指令。 而对于总是能保证处理顺序的处理器上,设置该屏障相当于无操作。
StoreStore 屏障
序列:Store1,StoreStore,Store2
确保Store1的数据在Store2以及后续Store指令操作相关数据之前对其它处理器可见(例如向主存刷新数据)。通常情况下,如果处理器不能保证从写缓冲或/和缓存向其它处理器和主存中按顺序刷新数据,那么它需要使用StoreStore屏障。
LoadStore 屏障
序列: Load1,LoadStore,Store2
确保Load1的数据在Store2和后续Store指令被刷新之前读取。在等待Store指令可以越过loads指令的乱序处理器上需要使用LoadStore屏障。
StoreLoad 屏障
序列: Store1,StoreLoad,Load2
确保Store1的数据在被Load2和后续的Load指令读取之前对其他处理器可见。StoreLoad屏障可以防止一个后续的load指令 不正确的使用了Store1的数据,而不是另一个处理器在相同内存位置写入一个新数据。正因为如此,所以在下面所讨论的处理器为了在屏障前读取同样内存位置存过的数据,必须使用一个StoreLoad屏障将存储指令和后续的加载指令分开。Storeload屏障在几乎所有的现代多处理器中都需要使用,但通常它的开销也是最昂贵的。它们昂贵的部分原因是它们必须关闭通常的略过缓存直接从写缓冲区读取数据的机制。这可能通过让一个缓冲区进行充分刷新(flush),以及其他延迟的方式来实现。
Java中的使用
volatile
volatile变量规则:对volatile变量的写入操作必须在对该变量的读操作之前执行。
volatile变量规则只是一种标准,要求JVM实现保证volatile变量的偏序语义。结合程序顺序规则、传递性,该偏序语义通常表现为两个作用:
- 保持可见性
- 禁用重排序(读操作禁止重排序之后的操作,写操作禁止重排序之前的操作)
补充:
程序顺序规则:如果程序中操作A在操作B之前,那么在线程中操作A将在操作B之前执行。
传递性:如果操作A在操作B之前执行,并且操作B在操作C之前执行,那么操作A必须在操作C之前执行。
volatile如何解决内存可见性与处理器重排序问题
在编译器层面,仅将volatile作为标记使用,取消编译层面的缓存和重排序。
如果硬件架构本身已经保证了内存可见性(如单核处理器、一致性足够的内存模型等),那么volatile就是一个空标记,不会插入相关语义的内存屏障。
如果硬件架构本身不进行处理器重排序、有更强的重排序语义(能够分析多核间的数据依赖)、或在单核处理器上重排序,那么volatile就是一个空标记,不会插入相关语义的内存屏障。
如果不保证,以x86架构为例,JVM对volatile变量的处理如下:
- 在写volatile变量v之后,插入一个sfence。这样,sfence之前的所有store(包括写v)不会被重排序到sfence之后,sfence之后的所有store不会被重排序到sfence之前,禁用跨sfence的store重排序;且sfence之前修改的值都会被写回缓存,并标记其他CPU中的缓存失效。
- 在读volatile变量v之前,插入一个lfence。这样,lfence之后的load(包括读v)不会被重排序到lfence之前,lfence之前的load不会被重排序到lfence之后,禁用跨lfence的load重排序;且lfence之后,会首先刷新无效缓存,从而得到最新的修改值,与sfence配合保证内存可见性。
在另外一些平台上,JVM使用mfence代替sfence与lfence,实现更强的语义。
二者结合,共同实现了Happens-Before关系中的volatile变量规则。
JVM对内存屏障作出的其他封装
final关键字
如果一个实例的字段被声明为final,则JVM会在初始化final变量后插入一个sfence。
类的final字段在
final字段的初始化在
CAS
在x86架构上,CAS被翻译为"lock cmpxchg..."。cmpxchg是CAS的汇编指令。在CPU架构中依靠lock信号保证可见性并禁止重排序。
lock前缀是一个特殊的信号,执行过程如下:
- 对总线和缓存上锁。
- 强制所有lock信号之前的指令,都在此之前被执行,并同步相关缓存。
- 执行lock后的指令(如cmpxchg)。
- 释放对总线和缓存上的锁。
- 强制所有lock信号之后的指令,都在此之后被执行,并同步相关缓存。
因此,lock信号虽然不是内存屏障,但具有mfence的语义(当然,还有排他性的语义)。
与内存屏障相比,lock信号要额外对总线和缓存上锁,成本更高(X86架构)。
锁
JVM的内置锁通过操作系统的管程实现。且不论管程的实现原理,由于管程是一种互斥资源,修改互斥资源至少需要一个CAS操作。因此,锁必然也使用了lock信号,具有mfence的语义。
锁的mfence语义实现了Happens-Before关系中的监视器锁规则。
CAS具有同样的mfence语义,也必然具有与锁相同的偏序关系。尽管JVM没有对此作出显式的要求。
参考:
https://www.jianshu.com/p/64240319ed60
MESI协议
内存屏障译文
干货不错 :haha: