做Java开发的老哥们都知道不管在面试还是学习一些底层知识的时候总是会看到一个叫“JMM”的东西,这个JMM其实就是Java内存模型。
JMM决定一个线程对共享变量的写入何时对另一个线程可见(即解决“内存可见性问题”)。
如果线程A与线程B之间要通信的话,必须要经历下面2个步骤:
1)线程A把本地内存A中更新过的共享变量刷新到主内存中去。
2)线程B到主内存中去读取线程A之前已更新过的共享变量。
JMM只是Java语言层面的抽象,底层还是依赖CPU等硬件层面的支持。
看到这张图是不是和上面的JMM图很像呢。
事实上数据不只有读操作,还会有写操作。这也引入了新的问题:
1)数据写入CPU Cache之后,CPU Cache和内存数据就会不一致。
2)数据写入某个CPU核心 Cache之后,就和其他CPU核心中缓存的数据不一致。
这和JMM要解决的问题是一样的:保证数据一致性的前提下的内存可见性。
其实就是要把Cache里的数据同步到内存中,有两种方法:写直达、写回。
保持内存与Cache一致性最简单的方式是,把数据同时写入内存和Cache中,这种方式称为写直达。
优点:直观简单。
缺点:无论数据在不在Cache里,每次写操作都会写回到内存,性能会受到很大影响。
在写回机制中,当发生写操作是,新的数据仅仅被写入Cache,只有当修改过的Cache 被替换时才需要写到内存中(一种“懒处理”思想),减少了数据写回内存的频率,提高系统性能。
可以看到只有当缓存不命中,同时数据对应的Cache中德Cache Block为脏的时候,才会将数据写回到内存。
现在 CPU 都是多核的,由于 L1/L2 Cache 是多个核心各自独有的,那么会带来多核心的缓存一致性(Cache Coherence) 的问题,如果不能保证缓存一致性的问题,就可能造成结果错误。
举个例子:
解决这个问题需要保证两点:写传播、事务串形化。
写传播
某个 CPU 核心里的 Cache 数据更新时,必须要传播到其他核心的Cache,这个称为写传播(Write Propagation)。
事务串形化
某个 CPU 核心里对数据的操作顺序,必须在其他核心看起来顺序是一样的,这个称为事务串形化(Transaction Serialization)。
举个例子来说明:
虽然是做到了写传播,但是各个 Cache 里面的数据还是不一致的。
那么保证3号核心和4号核心都能看到相同顺序的数据变化,比如 i 都是先变成100,再变成200,这样的过程就是事务的串形化。
要实现事务串形化,要做到两点:
接下来看看具体是怎么实现以上两点的。
写传播最常见实现的方式是总线嗅探(Bus Snooping)。
以上面2核心CPU的例子来说明总线嗅探的工作机制:
优点:简单。
缺点:CPU 需要每时每刻监听总线上的一切活动,但是不管别的核心的 Cache 是否缓存相同的数据,都需要发出一个广播事件,这无疑会加重总线的负载。
总线嗅探只是保证了某个 CPU 核心的 Cache 更新数据这个事件能被其他 CPU 核心知道,但是并不能保证事务串形化。
MESI协议基于总线嗅探机制实现了事务串形化,也用状态机机制降低了总线带宽压力,做到了 CPU 缓存一致性。
MESI 协议这4 个字母代表4个状态,分别是: Modified(已修改)、Exclusive(独占)、Shared(共享)、Invalidated(已失效)。
「已修改」状态就是前面提到的脏标记,代表该 Cache Block 上的数据已经被更新过,但是还没有写到内存里。
「已失效」状态表示的是这个 Cache Block 里的数据已经失效了,不可以读取该状态的数据。
「独占」和「共享」状态代表 Cache Block 里的数据是干净的,也就是说,这个时候 Cache Block 里的数据和内存里面的数据是一致性的。
「独占」和「共享」的差别在于,独占状态的时候,数据只存储在一个 CPU 核心的 Cache 里,而其他 CPU 核心的 Cache 没有该数据。这个时候,如果要向独占的 Cache 写数据,就可以直接自由地写入,而不需要通知其他 CPU 核心,因为只有你这有这个数据,就不存在缓存一致性的问题了,于是就可以随便操作该数据。
在「独占」状态下的数据,如果有其他核心从内存读取了相同的数据到各自的 Cache ,那么这个时候,独占状态下的数据就会变成共享状态。
「共享」状态代表着相同的数据在多个 CPU 核心的 Cache 里都有,所以当我们要更新 Cache 里面的数据的时候,不能直接修改。
而是要先向所有的其他 CPU 核心广播一个请求,要求先把其他核心的 Cache 中对应的 Cache Block 标记为「无效」状态,然后再更新当前 Cache 里面的数据。
举个具体的例子来看看这四个状态的转换:
所以,可以发现当 Cache Block 状态是「已修改」或者「独占」状态时,修改更新其数据不需要发送广播给其他 CPU 核心,这在一定程度上减少了总线带宽压力。
对于不同状态触发的事件操作,可能是来自本地 CPU 核心发出的广播事件,也可以是来自其他 CPU 核心通过总线发出的广播事件。详细流转过程如下:
要想实现缓存一致性,关键是要满足两点:写传播、事物的串行化。
基于总线嗅探机制的 MESI 协议,就满足上面了这两点,因此它是保障缓存一致性的协议。