从底层了解JVM的volatile实现,CPU Cache缓存一致性MESIStore BufferInvalidate Queue等知识
Posted Leo Han
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了从底层了解JVM的volatile实现,CPU Cache缓存一致性MESIStore BufferInvalidate 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里加上缓存之后,虽然能够提升速度,但是同时也带来了问题:
- 数据如何更新
- 多个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 Response | Read消息的响应消息,包含了被请求读取数据,这个消息可能是内存返回的也可能是其他CPU通过总线嗅探到Read消息返回 |
Invalidate | 通知其他CPU删除指定内存地址的Cache Line,而这里的删除其实就是设置Cache Line的状态为I |
Invalidate Acknowledge | Invalidate消息的响应消息,接收到Invalidate消息的CPU必须响应此消息,表示自己已经删除了本地缓存中对应的Cache Line |
Read Invalidate | Read和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 Buffer
和Invalidate Queue
,作用如下:
- 在更新缓存行的时候,CPU发出失效指令之后不在等待其他CPU返回响应而是直接写入到Store Buffer中,等其他CPU返回相应后,在将Store Buffer中的数据写入到Cache Line中,在读取的时候会首先判断Store Buffer中存不存在该数据,存在则优先从Store Buffer中加载(
需要注意的是,Store Buffer的写入其他CPU事无法立马感知到的
) - 其他CPU在收到失效请求的时候把请求放入到
Invalidate Queue
之后立马返回响应,后续等CPU需要用到该数据的时候,再去检查Invalidate Queue
里面的请求,并处理数据
引入Store Buffer
和Invalidate Queue
后虽然提升了性能,但是却带来了全局的一致性问题。
这里我们用网上常说的一个例子:
// CPU0 执行
void foo()
a = 1;
b = 1;
// CPU1 执行
void bar()
while(b == 0) continue;
assert(a == 1);
假设在开始执行这两个方法之前,CPU0上缓存了b=0
,CPU1上缓存了a=0
,那么可能出现非我们预期的结果:
- CPU0执行
a=1
,因为a不在CPU0的缓存中,所以直接执行a=1 写入到Store Buffer
中,并发送Read Invalidate消息
- CPU1执行
while(b == 0) continue
由于b不在缓存行中,发送一条read
消息 - CPU0执行
b=1
相应的Cache Line也存有该数据,Cache Line处于M
或者E
,直接将值保存到Cache Line中 - CPU0接收到read消息,将Cache Line中b的最新值返回给CPU1,并将Cache Line状态设置
S
- CPU1接收到包含b数据的Cache Line放入到自己的缓存中并执行
while(b == 0) continue;
,这时候b=1,跳出循环 - CPU1执行
assert(a == 1);
这时候CPU1看到的仍然是a=0
的旧值,因此assert失败 - CPU1接受到CPU0发出的
Read Invalidate
消息,将自己本地的包含a的Cache Line失效 - 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提供了如下几种内存屏障:
屏障类型 | 示例 | 说明 |
---|---|---|
LoadLoad | Load1;LoadLoad;Load2 | 确保Load1的读取操作在Load2及后续所有读取操作之前发生 |
StoreStore | Store1;StoreStore;Strore2; | 确保Store1的写操作在Strore2及后续操作之前发生 |
LoadStroe | Load1;LoadStore;Stroe2 | 确保Load1读操作在Store2之前发生 |
StoreLoad | Store1;StoreLoad;Load2 | 确保Store1的写操作在Load2已经后续操作之前发生 |
volitale在每个写操作前面插入一个StoreStore屏障;在每个volatile之后插入一个StoreLoad屏障
,这样确保在写之前将非volatile的普通写操作结果刷新到主内存(非volatile写对其他线程可见),而StoreLoad则保证volitale写不会与后面可能有的volitale读写操作重排序
volitale在每个读操作后面加上LoadLoad、LoadStore屏障
,这样来确保后续的普通读和普通写操作和前面的volatile发生重排序。
这样通过volatile关键字,java实现了内存的可见性和顺序性。
以上是关于从底层了解JVM的volatile实现,CPU Cache缓存一致性MESIStore BufferInvalidate Queue等知识的主要内容,如果未能解决你的问题,请参考以下文章
Java多线程编程-(11)-从volatile和synchronized的底层实现原理看Java虚拟机对锁优化所做的努力