1. 介绍
1.1 为什么需要MESI缓存一致性协议
现在处理器处理能力上要远胜于主内存(DRAM),主内存执行一次内存读写操作,所需的时间可能足够处理器执行上百条的指令,为了弥补处理器与主内存处理能力之间的鸿沟,引入了高速缓存(Cache),来保存一些CPU从内存读取的数据,下次用到该数据直接从缓存中获取即可,以加快读取速度,随着多核时代的到来,每块CPU都有多个内核,每个内核都有自己的缓存,这样就会出现同一个数据的副本就会存在于多个缓存中,在读写的时候就会出现数据不一致的情况。很早之前采用的锁总线,也就是说除读之外的操作需要锁住总线,但是锁总线的导致效率太慢,因此采用MESI缓存一致性协议。该协议主要有一些监听的操作,就像mq生产者和消费者一样。
1.2 CPU Cache
| 缓存名称 | 是否共享 | 描述 |
| 一级缓存(L1 Cache) | CPU CORE独享 | 制造成本很高因此它的容量有限,但是读取速度很快 |
| 二级缓存(L2 Cache) | CPU CORE独享 | 是一级缓存的缓冲器,存储那些CPU处理时需要用到、一级缓存又无法存储的数据,读取速度低于一级缓存 |
| 三级缓存(L3 Cache) | 多个CPU CORE共享的 | 可以看作是二级缓存的缓冲器,读写速度低于二级缓存,CPU主要通过三级缓存与总线通信 |
1.3 Cache Line
数据在缓存中不是以独立的项来存储的,它不是一个单独的变量,也不是一个单独的指针,它在数据缓存中以缓存行存在的,也称缓存行为缓存条目。目前主流的CPU Cache的Cache Line大小通常是64字节,并且它有效地引用主内存中的一块地址。一个Java的long类型是8字节,因此在一个缓存行中可以存8个long类型的变量。
1.4 什么是MESI
有了缓存,就务必要思考一致性问题,如何在读取的时候读取最新的数据,更改内容后如何及时写回主存。因此引入缓存一致性协议,而MESI是众多缓存一致性协议中的一种,也在Intel系列中广泛使用的缓存一致性协议。
缓存行(Cache line)的状态有Modified、Exclusive、 Share 、Invalid,而MESI 命名正是以这4中状态的首字母来命名的。该协议要求在每个缓存行上维护两个状态位,使得每个数据单位可能处于M、E、S和I这四种状态之一,各种状态含义如下:
| 状态 | 含义 | 描述 |
| M | 修改 | 表示缓存行数据被修改了,并且没有更新至主内存。处于这一状态的数据,只在本CPU中有缓存数据,而其他CPU中没有。简单的可理解为缓存行数据独占被修改且未同步 |
| E | 独享(互斥) | 表示缓存行数据是独占的。处于这一状态的数据,只有在本CPU中有缓存,其它CPU中没有缓存该数据,且其数据没有修改与主内存中一致。简单的可理解为缓存行数据独占且未被修改 |
| S | 共享 | 表示缓存行数据是共享的。处于这一状态的数据在多个CPU中都有缓存,且与内存一致 |
| I | 无效 | 表示缓存行数据是无效的。本CPU中的这份缓存已经无效。 |
1.5 MESI是如何保证缓存一致性
MESI协议对不同的状态增了不同的监听任务,监听任务的规则如下:
- 一个处于
M状态的缓存行,必须时刻监听所有试图读取该缓存行对应的主存地址的操作,如果监听到,则必须在此操作执行前把其缓存行中的数据写回主内存; - 一个处于
S状态的缓存行,必须时刻监听使该缓存行无效或者独享该缓存行的请求,如果监听到,则必须把其缓存行状态设置为I; - 一个处于
E状态的缓存行,必须时刻监听其他试图读取该缓存行对应的主存地址的操作,如果监听到,则必须把其缓存行状态设置为S;
1.6 MESI消息
| 消息名 | 消息类型 | 描述 |
| Read | 请求 | 通知其它处理器,主内存当前处理器准备读取某个数据。该消息包含待读取数据的内存地址 |
| Read Response | 响应 | 该消息包含被请求的读取消息的数据。可能是主内存提供的,也可能是嗅探到Read消息的其它处Cache提供的,主要看嗅探到Read消息的Cache中缓存行的状态 |
| Invalidate | 请求 | 通知其它处理器将其高速缓存中指定内存地址对应的缓存行状态置为I(无效) ,也就是通知其它处理器将指定内存地址的副本数据删除 |
| Invalidate Acknowledge | 请求 | 接收到Invalidate消息的处理器必须回复此响应,以表示删除了其高速缓存上的相应副本数据(这里的删除是逻辑删除,其实只是更新了缓存条件的Flag值) |
| Read Invalidate | 请求 | 从名字可以推断出该消息是一个复合消息,是由Read消息和Invalidate消息组合而成。它的作用是通知其它处理器,发送该消息的处理器准备更新一个数据,请求其它处理器删除其高速缓存中相应的副本数据。接收到该消息的处理器必须回复两个响应消息,Read Response、Invalidate Acknowledge消息,发送该消息的处理器期望收到一个Read Response以及多个Invalidate Acknowledge。 |
| Writeback | 请求 | 该消息包含需要写入主内存的数据及其对应的内存地址 |
1.7 MESI有什么问题
从上面处理过程中,其实不难发现,MESI主要是靠在总线上传递消息,并对消息增加不同的监听,来保证一个线程对共享变量的更新,对其它处理器上运行的线程是可见。但是消息传递是要时间的,一个请求,多个响应,每次都会涉及到CPU的切换,对于CPU这么频繁的读取,消息传递产生的时间是一种致命的影响,会导致引起缓存一致性流量风暴,导致各种各样的性能问题和稳定性问题。
1.8 写缓存区

1.9 无效队列
存储缓冲器的大小是有限的。如果存储缓冲区满了,那么依然需要等待Invalidate ack的回复后才可以继续执行运算。但是对方的CPU可能当时正在执行其他的操作。无法及时把缓存里面的值改为Invalidate状态,并发回Invalidate ack操作。这个时候为了继续优化这段CPU空闲的时间。就出现了无效队列!无效队列的作用就是CPU接收到Invalidate广播的时候马上返回给对方Invalidate ack响应。等当前的操作执行完再回来真正的把缓存里面的值标识为Invalidate状态。
需要注意的是,有些处理器是没有无效队列的(如X86)。

2. 源码
3. 实战
3.1 MESI处理流程
在这里我们只讨论多核情况下的数据读取X,我们以两核为例,CPUA 拥有 L1A高速缓存,CPUB拥有L1B高速。
MESI协议在数据的读定时,是通过往总线中发送消息请求和响应来保证数据的一致性的,下面我们看一下数据的读取流程缓存。
3.1.1 数据读取流程
CPUA需要读取数据X,会根据数据的地址在自己的缓存L1A中找到对应的缓存行,然后判断缓存行的状态(这里忽略了LoadBuffer)
- 如果缓存行的状态是
M、E、S,说明该缓存行的数据对于当前读请求是可用的,直接从缓存行中获取地址A对应的数据 - 如果缓存行的状态是
I,则说明该缓存行的数据是无效的,则CPUA会向总线发送Read消息,说’我现在需要地址A的数据,谁可以提供?‘,其它处理器(CPUB)会监听总线上的消息,收到消息后,会从消息中解析出需要读取的地址,然后在自己缓存(L1B)中查找缓存行,这时候根据找到缓存行的状态会有以下几种情况- 状态为S/E , CPUB会构造Read Response消息,将相应缓存行中的数据放到消息中,发送到总线同时更新自己缓存行的状态为S,CPUA收到响应消息后,会将消息中的数据存入相应的缓存行中,同时更新缓存行的状态为S
- 状态为
M,会先将自己缓存行中的数据写入主内存,并响应Read Response消息同时将L1B中的相应的缓存行状态更新为S - 状态为I或者在自己的缓存中不存在地址A的数据,那么主内存会构造Read Response消息,从主内存读取包含指定地址的块号数据放入消息(缓存行大小和内存块大小一致所以可以存放的下),并将消息发送到总线
- CPUA获接收到总线消息之后,解析出数据保存在自己的缓存中
3.1.2 数据写流程
任何一个处理器执行内存操作时,必须拥有相应数据的所有权。
CPUA会先根据内存地址在自己的缓存中L1A中找相应的缓存行,判断缓存行的不同状态,可能会了现下列几种情况(这里忽略了StoreBuffer)
(1)为E/M时,说明当前CPUA已经拥有了相应数据的所有权,此时CPUA会直接将数据写入缓存行中,并更新缓存行状态为M,此时不需要向总线发送任何消息;
(2)S时,说明数据被共享,其它CPU中有可能存有该数据的副本,则CPUA向总线发送Invalidate 消息以获取数据的所有权,其它处理器(CPUB)收到Invalidate消息后,会将其高速缓存中相应的缓存行状态更新为I,表示已经逻辑删除相应的副本数据,并回复Invalidate Acknowledge消息,CPUA收到所有处理器的响应消息后,会将数据更新到相应的缓存行之中,同时修改缓存行状态为E,此时拥有数据的所有权,会对缓存行数据进行更新,最终该缓存行状态为M;

(3)I 时,说明当前处理器中不包含该数据的有效副本,则CPUA向总线发送Read Invalidate消息 ,表明”我要读数据X,希望主存告诉我X的值,同时请示其它处理器将自己缓存中包含该数据的缓存行并且状态不是I的缓存行置为无效“;
- 其它处理器(CPUB)收到
Invalidate 消息后,如果缓存行不为I的话,会将其高速缓存中相应的缓存行状态更新为I,表示已经逻辑删除相应的副本数据,并回复Invalidate Acknowledge消息 - 主内存收到
Read消息后,会响应Read Response消息将需要读取的数据告诉CPUA - CPUA收到所有处理器的
Invalidate Acknowledge消息和主内存的Read Response消息后,会将数据更新到相应的缓存行之中,同时修改缓存行状态为E,此时拥有数据的所有权,会对缓存行数据进行更新,最终该缓存行状态为M
4. FAQ
4.1 通过上面的描述看来,在多个线程共享变量的情况下,MESI协议已经能够保障一个线程对共享变量的更新对其它处理器上运行的线程来说是可见的;既然如此,JAVA中的可见问题为何会存在?
A:为了进一步优化性能,引入写缓存和无效队列,因此而有可见性问题。
4.2 MESI频繁的消息请求与响应带来的性能问题如何解决?
4.3 流量风暴的原理?
A:在多核处理器架构上,所有的处理器是共用一条总线的,都是靠此总线来和主内存进行数据交互。当主内存的数据同时存在于多个处理的高速缓存中时,某一处理器更新了此共享数据后。会通过总线触发嗅探机制来通知其他处理器将自己高速缓存内的共享数据置为无效,在下次使用时重新从主内存加载最新数据。而这种通过总线来进行通信则称之为”缓存一致性流量“。因为总线是固定的,所有相应可以接受的通信能力也就是固定的了,如果缓存一致性流量突然激增,必然会使总线的处理能力受到影响。而恰好CAS和volatile 会导致缓存一致性流量增大。如果很多线程都共享一个变量,当共享变量进行CAS等数据变更时,就有可能产生总线风暴。
4.4 写缓存区数据什么时候刷新到缓存行中?
A:Store Buffer被引入使用。处理器把它想要写入到主存的值写到缓存,然后继续去处理其他事情。当所有失效确认(Invalidate Acknowledge)都接收到时,数据才会最终被提交。但是有以下风险:
- 处理器会尝试从存储缓存(Store buffer)中读取值,但它还没有进行提交。这个的解决方案称为Store Forwarding,它使得加载的时候,如果存储缓存中存在,则进行返回
- 保存什么时候会完成,这个并没有任何保证
4.5 伪共享
A:CPU的缓存是以缓存行(cache line)为单位进行缓存的,常见的缓存大小是64字节,当多个线程修改不同变量,而这些变量又处于同一个缓存行时就会影响彼此的性能。例如:线程1和线程2共享一个缓存行,线程1只读取缓存行中的变量1,线程2修改缓存行中的变量2,虽然线程1和线程2操作的是不同的变量,由于变量1和变量2同处于一个缓存行中,当变量2被修改后,缓存行失效,线程1要重新从主存中读取,因此导致缓存失效,从而产生性能问题。
对象=对象头+实例数据+padding=Mark word + 类型指针 + 实例数据 + padding,总大小保证8的倍数。
- 在32位系统下,存放Class指针的空间大小是4字节,Mark Word是4字节,对象头是8字节
- 在64位系统下,存放Class指针的空间大小是8字节,Mark Word是8字节,对象头是16字节
- 64位开启指针压缩的情况,存放Class指针的空间大小是4字节,MarkWord是8字节,对象头是12字节(64位默认开启对象压缩)
- 数组长度是4字节,数组对象头8字节(对象引用4字节(未开启指针压缩的64位为8字节)+ 数组MarkWord为4字节(64位未开启指针压缩的为 8字节))+对齐4字节=16字节
- 堆内存小于4G时,不需要启用指针压缩,jvm会直接去除高32位地址,即使用低虚拟地址空间;堆内存大于32G时,压缩指针会失效,会强制使用64位(即8字节)来对java对象寻址,这就会出现1的问题,所以堆内存不要大于32G为好
import java.util.concurrent.atomic.AtomicLong; / * Represents a padded {@link AtomicLong} to prevent the FalseSharing problem<p> * * The CPU cache line commonly be 64 bytes, here is a sample of cache line after padding:<br> * 64 bytes = 8 bytes (object reference) + 6 * 8 bytes (padded long) + 8 bytes (a long value) * * @author yutianbao */ public class PaddedAtomicLong extends AtomicLong { private static final long serialVersionUID = -L; / Padded 6 long (48 bytes) */ public volatile long p1, p2, p3, p4, p5, p6 = 7L; / * Constructors from {@link AtomicLong} */ public PaddedAtomicLong() { super(); } public PaddedAtomicLong(long initialValue) { super(initialValue); } / * To prevent GC optimizations for cleaning unused padded references * @return PaddingToPreventOptimization */ public long sumPaddingToPreventOptimization() { return p1 + p2 + p3 + p4 + p5 + p6; } }
讯享网
这里是如何计算的?按照PaddedAtomicLong方式是8 + 48 + 8,但是AtomicLong有几个对象属性(unsafe、valueOffset、VM_SUPPORTS_LONG_CAS、value),static属于类对象,有类指针间接查询,因此只有一个不是static,即value,在32位中上述代码计算应该没错,如果是64位的话,默认指针压缩的情况下是12字节,算上字节对齐填充为8的倍数,额外需要6个long属性即可。所以初步认定上述是针对32位而言的。

4.6 如何规避总线风暴
5. 参考资料
【聊聊缓存一致性协议】
【内存缓冲和无效队列】
【一篇文章让你明白CPU缓存一致性协议MESI】
【java的基本数据类型占据的内存大小】
【JAVA一个对象占用多少字节】

版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容,请联系我们,一经查实,本站将立刻删除。
如需转载请保留出处:https://51itzy.com/kjqy/122003.html