1. CPU缓存结构
- CPU
- 寄存器
- 一级缓存
- 二级缓存
- 三级缓存
- 内存
- 缓存以缓存行为基本单位与内存交换数据,一个缓存行默认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,却导致指令顺序改变的副作用
- 指令重排的原则:由依赖关系的指令是不会被重排的。即只可能指令重排无依赖关系的指令。