从底层了解JVM的volatile实现,CPU Cache、缓存一致性MESI、Store BufferI、nvalidate Queue等知识

在之前我们聊过java的内存模型,以及通过volatile、synchorinized等关键字来确保多线程下并发的原子性、内存可见性、顺序性 语义。
今天我们来聊聊,JVM为什么要这么干,背后的底层原因是什么。

在现代计算机硬件体系下,一般CPU都是多核模式,同时可以有多个线程并发工作。CPU在工作时,需要加载相关的数据,然后到CPU的相关计算单元进行计算。CPU的速度特别快,而一般数据都是在硬盘、内存里。所以如果要加载磁盘上的文件,一般会先通过相关驱动程序将磁盘上的文件读取到内存中,CPU在从内存中加载数据进行运算,但是CPU从内存加载数据相比CPU的速度还是太慢了,于是乎就有了大家经常听到的CPU缓存,或者听说过的cache line的东西。现代CPU一般都有三层缓存,c分别为L1 CACHE,L2 CACHE,L3CACHE。CPU加载数据的时候先从L1 CACHE加载数据,如果没有在从L2加载,L2没有的话,在从L3。有的人可能就会有疑问为什么不字节整一个L1 CACHE就行了,搞那么多级,其实主要还是成本,L1 CACHE的成本最高。注意这三层缓存是在CPU内部的,也就是CPU的制造厂商集成封装在CPU内部的。(说句题外话,cpu利用缓存也是运用了计算机中常说的28法则,这里就是我们经常用到的数据只会占所用数据的20%左右,以及常说的时间局部性和空间局部性原理)

在多核模式下,这时候CPU的结构如下:
在这里插入图片描述
可以看到,一般L1 cache分为数据和指令两个缓存,L1,L2 cache单个CPU独有,L3 CACHE多个CPU共享。
CPU cache中的数据是从内存中一块一块读取过来的,并不是每次读取一个数据只读取一个数据,而是读取一个块,在CPU cache中,这样的块称为Cache Line(缓存行),cpu cache会按照Cache Line将一个cache分为多个cache line,并编号
在内存中的每个数据都有一个地址,而CPU从主内存加载数据到CPU cache的时候,则是通过内存中的地址和和cache中缓存行编号取模,从而得到这个内存块数据应该放在缓存行的哪个位置,但是由于缓存行的个数肯定是比内存块的个数少,因此可能会出现多个内存块加载到同一个缓存行的情况。因此CPU Cache Line中除了缓存的数据外还有组标记有效位两个信息。
CPU在读取数据的时候并不是按照Cache Line来读取的(从内存加载数据到CPU Cache中是按照Cache Line加载的),而是按需读取一个数据片段,一般为一个字,我们看下内存地址数据映射到CPU CACHE中的一个大概图示:
在这里插入图片描述

如上图,当我们从内存地址加载一个数据到CPU CACHE的时候,按照Cache Line大小加载这个内存地址的临近数据到CPU Cache中,并按照上述设置有效位,组标记,存储实际数据。
当CPU需要读取一个内存地址的数据的时候,首先判断该地址数据是否在CPU CACHE中,通过内存地址的这个标志位来判断,如果在对应的Cache Line中,组标记一样,那么表示这个内存地址的数据被加载到了CPU CACHE中,然后通过Offset信息从缓存行上读取需要的数据。如果组标记不一样,那么该内存地址数据没有被加载到CPU CACHE中,那么去内存装载数据到CPU CACHE中去。

tips: Linux下查看L1,L2,L3cache大小以及CPU每次从内存装载数据缓存行的大小
在这里插入图片描述
在这里插入图片描述

当我们在CPU里加上缓存之后,虽然能够提升速度,但是同时也带来了问题:

  1. 数据如何更新
  2. 多个CPU之间如何保证数据的一致性

首先对于数据更新来说,有两种策略:

  • 写直达 Write through:这时候当cpu缓存中的数据发生更改时,同步写回到内存中,这种方式比较低效
  • 写回 Write back:这时候数据更新只写回到cache中,不直接写到内存中,当cache中的数据因为cache容量不够需要被替换时才写回到内存中

可以明显的发现,比起写直达,写回的方式要更高效。但是在多CPU模式下随之而来的一个问题是,多个CPU之间如何保证数据的一致性?我们知道现在程序都是多线程运行的,多个CPU可能会加载同一个数据,那么当多个CPU两个或以上发生了数据更新,这时候CPU之间的同一份数据的缓存就不一致了。
因此,针对这个问题,业界也提出了CPU缓存一致性的处理方案,其中比较著名的是MESI
MESI是四个首字母的简写,表示的是CPU中缓存行的四种状态:

  • M:Modified,修改,该Cache Line中的数据有效,数据被修改了,和内存中的数据不一致,数据只存在于该Cache Line中
  • E:Exclusive,独享,互斥,该Cache Line中的数据只存在该CPU中,其他CPU没有缓存该Cache Line,且该CPU的Cache Line和内存中一直,如果其他CPU读取该缓存时,那么变为S状态
  • S:Shared,共享,该Cache Line有效,和内存中的数据一样,且其他CPU中有缓存了该Cache Line对应的数据
  • I:Invalid,无效,该Cache Line无效。
    CPU和内存通过总线进行消息的传递。CPU通过总线嗅探感知其他CPU发出的请求消息,CPU也需要对总线中的消息进行响应,而消息类型一般有如下几类:
消息类型响应
Read通知其他CPU和内存,当前CPU准备读取某个数据,当前消息包含需要读取数据的内存地址
Read ResponseRead消息的响应消息,包含了被请求读取数据,这个消息可能是内存返回的也可能是其他CPU通过总线嗅探到Read消息返回
Invalidate通知其他CPU删除指定内存地址的Cache Line,而这里的删除其实就是设置Cache Line的状态为I
Invalidate AcknowledgeInvalidate消息的响应消息,接收到Invalidate消息的CPU必须响应此消息,表示自己已经删除了本地缓存中对应的Cache Line
Read InvalidateRead和Invalidate消息的组合,主要是用于通知其他CPU,当前CPU准备更新数据,请求其它CPU删除器本地对应Cache Line。接受到该消息的CPU必须返回Read Response和Invalidate Acknowledge消息
Write Back需要写入内存的数据和其对应的内存地址

CPU中对缓存的操作存在如下四种情况:

  • Local Read:当前CPU读缓存行
  • Local Write:当前CPU写缓存行
  • Remote Read:其他CPU读取本地缓存行
  • Remote Write:其他CPU写本地缓存行

在MESI协议下,每个CPU不仅控制自己对Cache Line的读写,也监听其他CPU对该Cache Line的读写操作并做出响应。
我们看看这些状态怎么转换的

状态事件操作
E(独占)Local Read从本地Cache中读取数据,状态不变
Local Wrtie修改本地Cache数据,状态变为M
Remote Read其他CPU读取同一个数据,当前CPU通过总线嗅探发生地址冲突,将当前Cache Line数据响应给总线同时状态变为S
Remote Wrtie其他CPU修改了同个缓存行数据,并向总线发送了消息,当前CPU通过总线嗅探到了该操作,将本地Cache Line设置为I
S(共享)Local Read从本地Cache中读取数据,状态不变
Local Wrtie修改本地Cache数据,状态变为M,同时发送消息给总线,其它CPU如果有改缓存行数据将其他CPU缓存行状态设置为 I(无效)
Remote Read状态不变
Remote Wrtie监听嗅探到其他CPU对当前CPU同一缓存行数据的修改,当前CPU的Cache Line状态变为 I (无效)
M(已修改)Local Read从本地Cache中读取数据,状态不变
Local Wrtie修改本地Cache中的数据,状态不变
Remote Read将当前CPU中已经修改的Cache Line刷回到内存中,其他CPU能读取到最新的数据,当前CPU的Cache Line状态变为S
Remote Wrtie当前CPU获得总线控制权,将当前CPU中已经修改的Cache Line刷回到内存中,然后将本地Cache Line设置为I无效状态;发起修改请求的CPU没有得到相应再次发起请求,从内存中得到最新的数据
I(已失效)Local Read如果其他CPU没有这份数据,从内存中读取,状态变为E; 如果其他CPU有这份数据且状态为M,其他CPU将修改后的数据更新会内存,当前CPU从内存读取数据,且两个CPU的Cache Line状态为M 如果前天CPU有这份数据且状态为E或者S,通过总线嗅探返回数据,该数据对应Cache Line变为S
Local Wrtie从内存中读取数据,在本地缓存修改,状态为M,如果其他CPU有这个Cache Line状态更新为I (如果Cache Line状态为M还需要先将数据写回到内存
Remote Read状态不变
Remote Wrtie将状态不变

到这里我们大概了解了一下MESI的一些内容,通过这些内容,我们发现MESI也存在一些问题:

  • 当CPU更新缓存行的时候,需要等待其他CPU的同一个Cache Line失效才能执行,是一个同步等待,比较耗时

基于这些原因,后续又对MESI进行了优化,增加了Store BufferInvalidate Queue,作用如下:

  • 在更新缓存行的时候,CPU发出失效指令之后不在等待其他CPU返回响应而是直接写入到Store Buffer中,等其他CPU返回相应后,在将Store Buffer中的数据写入到Cache Line中,在读取的时候会首先判断Store Buffer中存不存在该数据,存在则优先从Store Buffer中加载(需要注意的是,Store Buffer的写入其他CPU事无法立马感知到的
  • 其他CPU在收到失效请求的时候把请求放入到Invalidate Queue之后立马返回响应,后续等CPU需要用到该数据的时候,再去检查Invalidate Queue里面的请求,并处理数据

引入Store BufferInvalidate Queue后虽然提升了性能,但是却带来了全局的一致性问题。
这里我们用网上常说的一个例子:

// CPU0 执行
void foo() { 
    a = 1;
    b = 1;
}
// CPU1 执行
void bar() {
    while(b == 0) continue;
    assert(a == 1);
}

假设在开始执行这两个方法之前,CPU0上缓存了b=0,CPU1上缓存了a=0,那么可能出现非我们预期的结果:

  1. CPU0执行a=1,因为a不在CPU0的缓存中,所以直接执行a=1 写入到Store Buffer中,并发送Read Invalidate消息
  2. CPU1执行 while(b == 0) continue由于b不在缓存行中,发送一条read消息
  3. CPU0执行b=1相应的Cache Line也存有该数据,Cache Line处于M或者E,直接将值保存到Cache Line中
  4. CPU0接收到read消息,将Cache Line中b的最新值返回给CPU1,并将Cache Line状态设置S
  5. CPU1接收到包含b数据的Cache Line放入到自己的缓存中并执行while(b == 0) continue;,这时候b=1,跳出循环
  6. CPU1执行assert(a == 1);这时候CPU1看到的仍然是a=0的旧值,因此assert失败
  7. CPU1接受到CPU0发出的Read Invalidate消息,将自己本地的包含a的Cache Line失效
  8. CPU0收到ACK响应,将Store Buffer中的对应Cache Line刷新到缓存中

而在加入了Invalidate Queue之后,实际上也是会出现上述问题,最主要的原因,这时候不同CPU操作同一个共享变量,但是并没有立马使双方都可见。但是如果同步又会降低CPU的工作效率,且出现这种情况的频率不是特别多,因此后续硬件层面对软件层面提供了内存屏障语义,让程序自己去选择什么时候需要将数据同步一致,内存屏障主要是处理Store Buffer和Invalidate Queue,保证全局顺序性
而一般内存屏障又分为读屏障写屏障两类

  • 读屏障主要用于处理Invalidate Queue,会强制CPU执行Invalidate Queue中的所有invalidate操作,使自身CPU缓存失效,从而使CPU从内存或者其他CPU获取最新的数据
  • 写屏障主要用于处理Store Buffer,强制CPU刷新Store Buffer到CPU Cache中去,进而刷新到内存,对其它CPU可见

通过内存屏障,确保了即使在Store Buffer和Invalidate Queue下能够保证全局缓存的一致性。

另外一点,单个变量可以通过MESI和内存屏障保证一致性,但是如果是多个变量,又不一样了。现代CPU为了保证高速运转,实际运行的时候提供了流水线、分支预测
我们以经典的MIPS五级流水线来说,一条指令CPU执行的时候分为如下几个阶段:

取指(IF)将指令从存储器中读取到寄存器中
译码(ID)对取回的指令进行翻译,识别出不同指令类型,以及获取操作数的方法
执行(EXE)指令执行
访存取数(MEM)根据指令需要,有可能需要访问内存,读取操作数
结果写回(WB)将指令执行的结果写回到某种存储形式,一般是CPU寄存器,这样后续指令能够快速存取;也可能被写回内存

上述各个阶段CPU内部都有专门的部件去工作,如果我们每条指令必须等上一条指令执行完之后才能开始执行,那么上面这五个阶段只有一个阶段在工作,CPU很多部件空闲,为了提高CPU效率,采用了流水线方式:
在这里插入图片描述

这样的话就能够充分利用CPU各个部件,加快执行时间,在CPU内部上来看,相当于是多条指令可以并行执行了

流水线的优点:

  • 提高 CPU 主频:流水线将组合逻辑分割成多个小块,因为每段的关键路径变短了,所以能提高系统主频。
  • 提高系统吞吐量:因为流水线让任务以类似并行方式处理,提高硬件模块的利用率,所以能提高吞吐量(Throughput)。

流水线的缺点:

  • 由于流水线让许多指令被同时执行,假如分支预测错误的话整个流水线上所有的指令全部要被取消,流水线要被重新充满,就需要从存储器或者 CPU 缓存中调用指令,导致延迟时间,在这段时间里 CPU 是没有任何工作的。

接下来我们回到java来,我们知道,java是一个跨平台的应用,在不同硬件,不同操作系统下都能够运行。
而通过上面的知识我们也了解,不同CPU,不同硬件,不同操作系统在底层有很多特性是不一样的,基于此,java提供了java的统一内存模型,来屏蔽底层不同硬件、操作系统的带来的差异,给开发者一个统一的内存模型。
在这里插入图片描述

在JMM同一内存模型的试图下,每个线程在工作的时候会首先将数据从内存加载到工作内存,工作内存处理完之后在写回到主内存中,听起来和CPU Cache是不是很像。

那么这里的底层实现也还是基于上面说的这些,只不过java通过JMM屏蔽了底层表不同硬件、操作系统的差异。

那么结合我们前面说的,在不同线程之间就会出现内存一致性的问题了。
对于多线程并发常见的三个问题:原子性、可见性、顺序性,volatile只能保证可见性和顺序性,无法保证原子性

volatile的实现是基于java提供的内存屏障来实现的。JVM提供了如下几种内存屏障:

屏障类型示例说明
LoadLoadLoad1;LoadLoad;Load2确保Load1的读取操作在Load2及后续所有读取操作之前发生
StoreStoreStore1;StoreStore;Strore2;确保Store1的写操作在Strore2及后续操作之前发生
LoadStroeLoad1;LoadStore;Stroe2确保Load1读操作在Store2之前发生
StoreLoadStore1;StoreLoad;Load2确保Store1的写操作在Load2已经后续操作之前发生

volitale在每个写操作前面插入一个StoreStore屏障;在每个volatile之后插入一个StoreLoad屏障,这样确保在写之前将非volatile的普通写操作结果刷新到主内存(非volatile写对其他线程可见),而StoreLoad则保证volitale写不会与后面可能有的volitale读写操作重排序

volitale在每个读操作后面加上LoadLoad、LoadStore屏障,这样来确保后续的普通读和普通写操作和前面的volatile发生重排序。

这样通过volatile关键字,java实现了内存的可见性和顺序性。

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值