CPU的缓存一致性与volatile关键字

本文详细介绍了CPU缓存结构及缓存一致性原理,包括MESI协议的运作机制、缓存行伪共享问题及其解决方案,以及Java中如何利用@Contended注解避免伪共享。此外,还探讨了存储队列与失效队列的优化策略,读写屏障的作用,解释了为何JMM需要volatile关键字确保指令顺序。
摘要由CSDN通过智能技术生成

1. CPU缓存结构

  1. CPU
  2. 寄存器
  3. 一级缓存
  4. 二级缓存
  5. 三级缓存
  6. 内存
  • 缓存以缓存行为基本单位与内存交换数据,一个缓存行默认64byte
  • 在Java中,我们说的线程的工作内存其实是指高速缓存与寄存器

2. CPU缓存一致性

2.1 MESI协议

  • MESI是指4个状态的首字母,每个cache line有4个状态,可以用2个bit表示:
状态描述监听任务
M:修改数据只存在本cache中,且和内存中的数据不一致。缓存行必须时刻监听所有视图读该缓存行所对应的主存的操作,这些操作必须在缓存将该缓存行写回主存并将状态改为S状态之前被延迟执行
E:独享数据仅存在本缓存中,且和内存中的数据一致缓存行也必须监听其他缓存读取该缓存行对于的主存操作,一旦有这种操作,该缓存行需要变成S状态
S:共享缓存行的数据与内存的数据一致,且数据存在很多缓存行中缓存行必须监听其他缓存使该缓存行无效的请求
I:无效该缓存行无效
状态触发本地读取触发本地写入触发远端读取触发远端写入
M

本地cache:M

其他cache:I

本地cache:M

其他cache:I

本地:M->E->S

触发:I->S

其他:I->S

本地:M->E->S->I

触发:I->S->E->M

其他:I->S->I

E

本地:E

其他:I

本地:E->M

其他:I

本地:E->S

触发:I->S

其他:I->S

本地:E->S->I

触发:I->S->E->M

其他:I->S->I

S

本地:S

其他:S

本地:S->E->M

其他:S->I

本地:S

触发:S

其他:S

本地:S->I

触发:S->E->M

其他:S->I

I

本地:I->E或I->S

其他:E、M、I->S、I

本地:I->S->E->E

其他:M、E、S->S->I

2.2 缓存行伪共享

  • 目前主流的CPU缓存行大小默认64字节
  • 多线程下,假设一个缓存行如果存放了两个共享变量a,b
    • 线程1会访问共享变量a,线程2会访问共享变量b。
    • 由于缓存刷新是以缓存行为基本单位的,所以当线程1修改共享变量a会导致其他线程对应的缓存行失效,则线程2虽然不访问共享变量a,但因为共享变量b与共享变量a在同一个缓存行里,则导致线程2的缓存行也失效,则它需要重新从内存读取共享变量b

2.3 Java中如何解决伪共享

  • java8中增加了一个主键@sun.misc.Contended,加上这个主键,自动补齐缓存行,使得单个共享变量独享一个缓存行。
  • 该注解默认无效,需要在jvm启动时设置-XX:-RestrictContended

2.4 Store Buffer(存储队列)

  • 如果没有存储队列,当修改本地缓存里面的一条信息,需要将无效状态通知到其他拥有该缓存数据的CPU缓存中,并等待确认。等待确认的过程会阻塞处理器
  • 所以引入存储队列(Store Buffer)。处理器把它想要修改的缓存数据的store操作写入存储队列(可以将存储队列理解为一个待更新缓存行列表)同时向其他拥有该缓存行数据的CPU发送Invalidate请求,然后继续处理其他指令(这里不用等收到所有确认才能继续处理其他指令),当收到所有的Invalidate 确认信息后将数据从Store Buffer更新到缓存行。
    • 但是存储队列里的数据什么时候更新到缓存,这个时没有任何保证的。

 

2.5 Store Forwarding优化

  • 当CPU0暂存数据更新到StoreBuffer0中后,如果后面有对该数据行的读取,则不会去读Cache0中的未更新的缓存行数据,而是去读StoreBuffer0中的缓存行数据。这就是Store Forwarding优化。

2.5 失效队列(invalid queue)

  • 为什么要有失效队列:
    • 引入了store buffer,再辅以store forwarding,写屏障后,大部分的问题得以解决,但还有一个问题:store buffer的大小有限,所有的写入操作如果对应的缓存行在其他CPU中存在,则都会使用store buffer。特别是出现内存屏障后,后续的所有写入操作都会挤压到store buffer 中(直到store buffer 中屏障前的条目处理完)。因此store buffer很容易满。当store buffer满了之后,则当前CPU会阻塞后面的指令执行。
    • Invalidate ACK耗时的原因是CPU要先将对应的缓存行置为无效后再返回Invalidate ACK。解决思路还是化同步为异步:CPU不必要等处理了缓存行后才返回Invalidate ACK,而是可以将Invalid消息放到某个请求队列Invalid queue。然后立即返回Invalidate ACK。
  • 规则
    • 如果一个CPU修改了缓存数据行,则会向其他拥有该缓存行数据的CPU发送Invalidate请求。
    • 对于所有收到缓存行Invalidate请求的CPU,会立刻发送Invalidate Acknowledge消息
    • 但是Invalidate请求的处理动作并不真正立即执行,而是被放到失效队列,在方便的时候才会执行
    • 处理器也不会发送任何消息给所处理的缓存条目,直到它先处理了Invalidate请求。(由读屏障保证
  • 带来的问题:
    • 假设CPU1 收到了Invalidate请求,它将Invalidate请求加入Invalid queue然后立即返回了Invalidate 确认。此时CPU1 需要访问对应的缓存行,但对应的缓存行并没有标志为失效,则还是访问了旧的数据。所以会造成缓存不一致。所以需要读屏障来保证。

3. MESI优化带来的问题

  • 因为引入存储队列和失效队列会导致指令执行顺序的变化,但是我们又想保证这种顺序一致性,则在靠硬件是无法解决的,需要在软件层面支持。CPU提供了读写屏障指令。
  • 存储队列和失效队列,通过推迟操作的执行而保证了处理器的执行速度,
  • 但是处理器什么时候这个推迟执行时被允许的,而什么时候这个推迟执行又不被允许,它无法判断。
  • 所以处理器把这个判断交给写代码的人,给他们提供了内存屏障,由他们来控制是否可以推迟执行

3.1 内存屏障之写屏障

  • 写屏障:是一条告诉处理器在后面的指令执行之前,将所有在store Buffer中的数据行更新到缓存中。
  • 两种实现方式:
    • 第一种:直接将store buffer中的所有数据行更新到缓存中。期间会阻塞后面的指令执行
    • 第二种:将store buffer中的所有数据行打个标记,保证之后进入store buffer的数据行要后于打过标记的数据行更新缓存。(即当之后进入store buffer的数据行更新缓存之前,要确保所有打过标记的数据已经更新到缓存)

3.1 内存屏障之读屏障

  • 读屏障:是告诉处理器在执行任何的加载之前,先应用所有已经在失效队列中的失效操作的指令。

4. 有了CPU缓存一致性,为什么JMM还需要volatile关键字

  • CPU缓存一致性保证了多核之间的cache一致性。但是单核多线程还是会出现指令重排的问题。
  • MESI协议保证了对于缓存行在多个核上的可见性,但是多个变量之间的读写先后顺序无法保证。
  • 总结:在CPU层次上,它们只会保证具有控制依赖、数据依赖、地址依赖等依赖关系的指令间提交的先后顺序。然而对于完没有没有依赖关系的指令,比如x=1;y=2; 它们是不会保证执行提交的顺序。所以之后需要我们使用volatile关键字,实现指令的有序性。

5. 补充

5.1 指令重排

  • 指令重排分为两种类型,主动的和被动的
  • 主动:编译器回主动重排代码使得特定的CPU执行更快
  • 被动:为了异步化指令的执行,引入Store buffer 和Invalidate Queue,却导致指令顺序改变的副作用
  • 指令重排的原则:由依赖关系的指令是不会被重排的。即只可能指令重排无依赖关系的指令。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值