无法写入高速缓存_内存与CPU的高速缓存的关系

我们个人PC都有一个叫做内存的硬件,有4G、8G、16G不等的容量。但我们的CPU运行时执行的指令并不是直接从内存中获取,而是从CPU自身的高速缓存中获得指令并运行,指令运行完毕再写回缓存,然后待到特定的时机才把数据在写回主内存。那CPU是如何将比自己容量大的多的内存放进自己的高速缓存中的呢?在内存的映射进CPU时又有什么问题需要注意?

内存如何映射进CPU的高速缓存中

下面有一张存储器层次结构图,能够直观的看到为了填补CPU高速运行与读取内存时花费的“漫长”时间之间的沟壑,现代CPU都引入高速缓存(L1,L2,L3 Cache)。

f5f1b7813a0177216321a8ae649ca22c.png

CPU执行指令时先从L1 Cache中读取,读取不到再从L2 Cache读取,还读不到继续从L3中读取。如果L3还是没有所需的数据就需要从内存中读取,当读取到后依次写入L3、L2、L1 Cache中,然后再继续执行指令。

各个存储器只和相邻的一层存储器打交道,并且随着一层层向下,存储器的容量逐层增大,访问速度逐层变慢,而单位存储成本也逐层下降,也就构成了我们日常所说的存储器层次结构。

CPU在读取内存的数据进高速缓存时并不是说需要什么数据就根据这个数据的内存地址读取,而是一块(64KB)一块的读取,这样做的好处是根据空间上的局部性原理,同时也不会浪费总线的带宽。

空间局部性原理:在需要运行执行的数据的周围大概率也会被CPU执行

我们把读取到的这一块内存地址叫做Data Block,它将会跟另外一些数据组成一个Cache Line放进CPU的高速缓存中。

cf578cbf65277977a8a006d42693aa76.png

对照上面这副图,我们可以把内存是如何映射进CPU的流程讲清楚。

因为我们的高速缓存的容量远远小于内存的容量,所以根本无法把全部的内存数据装载进来,现代CPU其实是先把高速缓存划分成一块一块的缓存块(图中的Cache Line),然后把主内存也是划分成一块一块的。然后通过一个映射策略将内存块映射进高速缓存中。

比如映射策略采用的是mod运算(求余运算)。内存被我们划分成32块,高速缓存被划分成8块。CPU想要运行第21块内存块,则映射到高速缓存中就是21 mod 8 = 5。因此就将第21块内存块放进第5块Cache Line中,算出来的‘5’就对应了图中的索引(Index)

刚刚的例子中用21余8等于5,那么5、13余8也会等于5,那CPU怎么知道是取21内存块还是5或者是13内存块的呢?这时候就需要组标记(Tag),通过组标记我们能够定位到到底取哪一块内存块的数据。组标记中的内容是CPU要访问的内存地址(二进制)的高位,而索引中放置的内容是低位,因此也就确定了想要读取的数据的真正地址内容。

有效位(Valid Bit)用于标记这个Cache Line是否有效,如果有效位是0,则无论Cache Line中的数据如何都将会从内存中重新加载进来。

由于我们从内存中读取的数据是一块一块的读取,而CPU只想要其中的一些数据,因此把这一块读进高速缓存后CPU通过偏移量(Offset)来定位到真实的CPU想要执行的数据

一个内存的访问地址映射到高速缓存,最终包括高位代表的组标记、低位代表的索引,以及在对应的 Data Block 中定位对应字的位置偏移量。

将Cache Line中的数据写回主内存

当CPU把指令执行完毕后会有访存、写回这两个指令操作,同样的CPU的寄存器只会与下一级的高速缓存打交道。那么现在写入到高速缓存中的数据怎么再次写回内存呢?

对于写入策略通常有两种一种是写直达(Write-Through),另一种是写回(Write-Back)

写直达是最简单的一种策略,在写回内存时会先判断是否存在于高速缓存中了,如果存在我们就先更新Cache中的数据并写回主内存,如果不在就直接写回主内存。这种方式都是要写回主内存,因此效率方面并不是很高。而写回只会写回对于的Cache Line中,并不会写回主内存。

写回策略的流程图如下

b46b30920464a725f650e39bcf97e1e0.png

数据只会被写回Cache Line中并标记为脏数据,当Cache Line接收到其他写回内存的请求时(如多核CPU访问相同的内存时通过总线发出的访问请求)才会被写回内存。因此次策略的效率优于写直达方式。

Cache Line引发的数据一致性问题

如今的CPU都是多核的,每一个核心有着自己的LI、L2 Cache,然后共享L3 Cache。那么此时就会引发一个问题,1号CPU与与2号CPU同时将内存加载进自己的高速缓存中,1号CPU对数据进行了修改,但此时2号CPU中的数据还是以前的数据。这就是缓存一致性问题。

8d30f90dbf99d91d53dcd0e1b019e6cd.png

解决缓存一致性主要有两个思想

  • 写传播(Write Propagation):在一个 CPU 核心里,我们的 Cache 数据更新,必须能够传播到其他的对应节点的 Cache Line 里。

  • 事务的串行化(Transaction Serialization):在一个 CPU 核心里面的读取和写入,在其他的节点看起来,顺序是一样的。

基于这两个思想我们就有总线嗅探(Bus Snooping)跟基于总线嗅探的缓存一致性协议,如MESI、MSI等。

总线嗅探:把所有的读写请求都通过总线(Bus)广播给所有的 CPU 核心,然后让各个核心去“嗅探”这些请求并对自身的Cache做出相应的操作

MESI协议是一种写失效(Write Invalidate)的协议。在这个协议中只有一个CPU核心负责写入数据,完成写入后就通过总线向其他CPU核心进行广播,如果其他核的CPU也存在被修改的数据,则将改Cache Line标记为失效。

MESI就是用来标记Cache Line中的状态:

  • M:代表已修改(Modified)

  • E:代表独占(Exclusive)

  • S:代表共享(Shared)

  • I:代表已失效(Invalidated)

已修改比较好理解,就是我们上文中提到的写回策略中的‘脏数据’。已失效同样也是上文提及到的,如果已失效就需要从内存中重新读取数据。

而独占跟共享就比较有意思,这两个状态的Cache Line中的数据都是‘干净’的,也就是缓存中的数据跟主内存的数据是一毛一样的。当只有一个CPU核心的Cache Line中拥有这个内存数据块时,我们就标记为独享。这个时候我们对这个数据进行读写不用通过总线通知其他CPU核心,只要读写到本核的缓存中并修改为【已修改状态】即可,也不用立马写回内存。当有其他CPU要对这块内存进行数据的读取时,就会立刻将修改的数据写回主内存,并修改Cache Line的状态为【共享状态】。当然,后来读取到这个数据的CPU对应的Cache Line也会被标记为【共享状态】。

有了MESI为什么还要Java语言中的volatile关键字?

看到这里,有人就会问,既然CPU层面已经有了总线嗅探、缓存一致性协议来保证数据的一致。那么volatile关键字的作用到底是什么?

对此我在网上找了一段比较解释的通的答案

1,并不是所有的硬件架构都提供了相同的一致性保证,JVM需要 volatile 统一语义(就算是MESI,也只解决CPU缓存层面的问题,没有涉及其他层面)。

2,可见性问题不仅仅局限于CPU缓存内,JVM自己维护的内存模型中也有可见性问题。使用 volatile 做标记,可以解决JVM层面的可见性问题。

3,另外,编译器的指令重排序,如果不加 volatile 也会导致可见性问题。

第一个比较好理解,就是虽然CPU有缓存一致性协议保证,但是其他硬件层面可能也会有一致性问题,而且volatile的语义统一所有层面的一致性。

第二点的意思是说JVM内维护了自己的一套内存模型,比如不同的栈内存中存在自己的内存副本。这些内存副本互不干扰,但是在副本中使用了堆内存中的数据也会引发JVM层面的一致性问题,因此需要volatile保证变量的可见性。

第三点解释是说不加volatile关键字,编译器为了运行效率会将编译的指令进行重新排序。而重新排序的指令我们无法保证其运行顺序。另外,没有相互依赖的指令会因为CPU中的乱序执行技术先执行,因此更没有办法确保它的数据的可见性了。而加了volatile我们可以控制访问顺序(Happen-before)并且通过内存屏障防止了编译器的重排序跟CPU层面的乱序执行。有了内存屏障,每次读取数据数据都是重新从内存加载进高速缓存在执行,写入时也会第一时间写回内存。

[参考]:徐文浩  [深入浅出计算机组成原理]

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值