作用
- 保证可见性
- 保证有序性
CPU 的简单缓存模型
每个 CPU 都有自己的多级缓存,越靠近 CPU,容量越小,价格越贵,速度越快。现在的CPU 一般都有三级。在多线程环境下,不同的 CPU 缓存之间容易导致数据不一致的问题。解决方案:
- 总线加锁机制:CPU读写数据之前,通过总线对某个数据加锁。其他CPU不能碰,所以容易导致串行化,效率低,现已废弃。
- 缓存一致性协议:其中一种叫
MESI
协议,现在volatile
的底层原理,简单说就是让缓存及时过期。
CPU 缓存的结构
CPU 的缓存结构类似于 JDK1.7 HashMap
,是一个拉链散列结构。散列表中每个元素称为一个桶,bucket
,每个桶可以通过拉链法挂着多个缓存条目,cache entry
,每个缓存条目包含三部分信息:
- tag:标签,指向缓存数据在主存中的地址
- cache line:缓存行,缓存数据的值,可以包含多个数据,但对应主存地址必须连续
- flag:标记位,标记缓存条目的状态
CPU 怎么找到缓存中的数据 ?根据变量名,进行解码,得到三部分信息:
- index:散列表中的桶下标
- tag:定位到哪个缓存条目
- offset:缓存行的偏移量(因为缓存行可能有多个数据,比如数组)
根据这些信息就可以定位到缓存中的数据。
缓存条目的标记位取值:
- I:即
invalid
,对应的缓存行的数据已经过期 - S:即
shared
,各个CPU共享这份数据 - E:即
exclusive
,当前CPU正在修改,所以独占这份数据 - M:即
modified
,当前CPU修改完了,未刷入主存
所以叫MESI
协议。
CPU 的总线嗅探机制
每个CPU都有自己的缓存,就意味着任意一份数据都可能有多份副本。假如 CPU-1 修改了自己缓存中的数据,其他 CPU 读写自己缓存中的数据时,就可能读到过期的数据,所以引入总线嗅探机制:
CPU-1 准备修改缓存中的数据时,要先发送一个无效消息 invalidate
到总线,相当于广播,告诉其他CPU,我准备修改某个数据,你们要那个数据标记为无效,即 flag=I
;
CPU 的总线嗅探机制会检测到总线有 invalidate
消息,然后看自己的缓存中是否有这个数据,有就标记为无效,并且发送一条无效确认消息 invalidate ack
到总线;
CPU-1 发送无效消息出去之后,要从总线嗅探到其他所有CPU的无效确认,将自己缓存的数据标记为 E,然后修改,再标记为 M,相当于加锁,写操作,释放锁。至于要不要进一步刷入主存,何时刷入,不同的硬件有不同实现。
其他CPU读取数据时发现缓存过期了,就去主存或者CPU-1的缓存中加载。
CPU-1 修改变量 i 的流程:
写缓存区与无效队列的引入
每次修改数据都要发一个无效消息,等到其他所有CPU返回一个无效确认,才能修改,阻塞时间太长了,所以引入了写缓存区和无效队列,每个CPU都有自己的写缓冲区和无效队列。
写数据时,直接发送一条无效消息,然后直接写入写缓存区中;
收到无效消息时,放入无效队列,就直接返回无效确认,等到CPU空闲时才消费无效队列里面的消息;
收到其他所有CPU的无效确认后,就将写缓冲区的数据刷入缓存,是否刷入主存取决于硬件实现。
CPU-1修改变量i的过程就变成这样:
其中,第 7、8 步称为 flush
操作,第 9、10、11 步称为 reflush
操作。
缓存一致性协议会导致的问题
缓存一致性协议就是强制 CPU 的缓存更新后,强制刷入主存。在引入写缓冲区、无效队列之前,缓存一致性协议就可以保证 CPU 读取到最新的数据;但引入之后,修改后的数据可能留在写缓冲区,无效消息可能还在无效队列,未被消费,就导致了可见性的问题。
可见性问题的出现也会导致 CPU 执行的指令 “看起来”乱序,即所谓的内存重排序,比如CPU-1 先是修改缓存中的变量 a,再读取变量 b,但 flush
操作发生在读完变量 b 之后,所以“看起来”是先读后写。
另外一种发生重排序的可能:CPU 的指令级并行技术也会导致没有数据相关的指令乱序执行。
为解决这些问题,就引入了内存屏障。
使用 Store
屏障、Load
屏障来解决可见性问题,它们的作用如下:
- Store:CPU修改数据后,强制CPU阻塞等待无效确认,然后执行
flush
操作; - Load:CPU读取数据之前,强制清空无效队列的消息。
使用 Acquire
、Release
来解决有序性问题,作用:
- Acquire屏障:保证读指令、写指令的执行顺序与程序的顺序一致
- Release屏障:写数据后强制
flush
,读数据前强制清空无效队列,并且保证写指令的按序执行
有的书籍会把这些内存屏障称为 LoadLoad
、LoadStore
等等,其实只是不同的硬件实现或者JVM版本,底层的原理都一样,都是在控制 flush
操作和reflush
操作的时机,或者规定指令执行的顺序。
Java 内存模型的简化
JMM 是一个标准化的、抽象的概念,屏蔽了缓存、写缓冲区、无效队列等硬件细节,简化成工作内存的概念。JMM 对数据的读写抽象成一些标准操作:
面试注意
volatile
关键字的作用 ?
保证可见行、有序性
原理?
MESI协议、内存屏障;
然后主动讲一下硬件层面的原理,顺序:
1、CPU 缓存的结构
2、MESI 协议在硬件层面的原理
3、为什么要引入写缓冲区、无效队列
4、为什么会产生可见行、有序性的问题
5、内存屏障的原理
常见误区
volatile
可以保证原子性
错,在特定的情况下,比如32位的JVM操作64位的volatile
变量时,的确可以保证,但这不是 volatile
的原子性语义。在绝大多数情况下,都无法保证。
volatile
的底层原理就是禁用缓存
错