volatile是Java虚拟机提供的轻量级的同步机制。volatile关键字有如下两个作用:

  1. 保证被volatile修饰的共享变量对所有线程总数可见的,也就是当一个线程修改了一个被volatile修饰共享变量的值,新值总是可以被其他线程立即得知。
  2. 保证程序有序性,禁止指令重排序优化。

volatile保证有序性

volatile关键字可以禁止指令重排优化,从而避免多线程环境下程序出现乱 序执行的现象,底层是通过内存屏障(Memory Barrier)实现的。

硬件层的内存屏障

内存屏障,又称内存栅栏,是一个CPU指令,由于编译器和处理器都能执行指令重排优化。如果在指令间插入一条Memory Barrier则会告诉编译器和CPU,不管什么指令都不能和这条Memory Barrier指令重排序,也就是说通过插入内存屏障禁止在内存屏障前后的指令执行重排序优化。Memory Barrier的另外一个作用是强制刷出各种CPU的缓存数据,因此任何CPU上的线程都能读取到这些数据的最新版本。前面《Java内存模型》介绍过指令重排

不同硬件实现内存屏障的方式不同,Java内存模型屏蔽了底层硬件平台的差异,由 JVM来为不同的平台生成相应的机器码。 JVM中提供了四类内存屏障指令:LoadLoad、LoadStore、StoreLoad、StoreStore
微信截图_20210628183343.png

volatile内存语义的实现

JMM针对编译器重排制定的volatile重排序规则表
微信截图_20210628210002.png
有上面可知:

  1. 当第一个操作是volatile读时,不管第二个操作是什么,都不能重排序。这个规则确保volatile读之后的操作不会被编译器重排序到volatile读之前。
  2. 当第二个操作是volatile写时,不管第一个操作是什么,都不能重排序。这个规则确保volatile写之前的操作不会被编译器重排序 到volatile写之后。
  3. 当第一个操作是volatile写,第二个操作是volatile读时,不能重排序。

为了实现volatile的内存语义,JMM采取保守策略,但它可以保证在任意处理器平台,任意的程序中都能得到 正确的volatile内存语义。 下面是基于保守策略的JMM内存屏障插入策略:

volatile读
在每个volatile读操作的后面插入一个LoadLoad屏障和LoadStore屏障
微信截图_20210628215645.png

volatile写
在每个volatile写操作的前面插入一个StoreStore屏障,后面插入一个StoreLoad屏障。
微信截图_20210628220817.png
上述策略保证能正确实现volatile的内存语义。JMM在采取了保守策略时:在每个volatile写后面,或者在每个volatile读前面插入一个StoreLoad屏障。从整体执行效 率的角度考虑,JMM最终选择了在每个 volatile写的后面插入一个StoreLoad屏障。因为 volatile写-读内存语义的常见使用模式是:一个写线程写volatile变量,多个读线程读同一个volatile变量。当读线程的数量大大超过写线程时,选择在volatile写之后插入StoreLoad 屏障将带来可观的执行效率的提升。从这里可以看到JMM 在实现上的一个特点:首先确保正确性,然后再去追求执行效率。

在X86处理器中,仅会对写-读操作做重排序,不会对读-读、读-写和写-写操作做重序,因此在X86处理器中会省略掉这3种操作类型对应的内存屏障。在X86中,JMM仅需在 volatile写后面插入一个StoreLoad屏障即可正确实现volatile写-读的内存语义。这意味着在X86处理器中,volatile写的开销比volatile读的开销会大很多。
微信截图_20210628232206.png

volatile保证可见性

当我们对某个变量加了volatile 关键字后,对其做修改操作时,对应的汇编指令多了一个 Lock 前缀

  1. private volatile static boolean initFlag = false;
  2. public static void refresh(){
  3. log.info("refresh data.......");
  4. initFlag = true;
  5. log.info("refresh data success.......");
  6. }

微信截图_20210825002842.png
在修改内存操作时,使用 LOCK 前缀去调用加锁的读-修改-写操作(原子的)。这种机制用于多处理器系统中处理器之间进行可靠的通讯,具体描述如下:在 Pentium 和早期的 IA-32 处理器中,LOCK 前缀会使处理器执行当前指令时产生一个 LOCK#信号,这总是引起显式总线锁定出现
微信截图_20210825003510.png
CPU是通过地址总线访问内存地址,而加了总线锁,只有获取了锁的CPU核才能访问内存。这样做有什么问题呢?当某个CPU核获取了总线锁后,其他CPU核只有等待当前CPU释放锁后才能访问内存,无法发挥CPU多核处理能力,为此引入缓存一致性协议。

缓存一致性问题

现在 CPU 都是多核的,由于 L1/L2 Cache 是多个CPU核各自独有的,那么会带来多核心缓存⼀致性的问题,如果不能保证缓存⼀致性的问题,就可能造成结果错误。

假设 A 号核心和 B 号核心同时运行两个线程,都操作共同的变量 i,初始值为 0
微信截图_20210825093757.png
如果 A 号核心执行了 i++ 语句的时候,先把值为 1 的执行结果写⼊到 L1/L2 Cache 中,然后把 L1/L2 Cache 中,这个时候数据其实没有被同步到内存中的,如果这时旁边的 B 号核心尝试从内存读取 i 变量的值,则读到的将会是错误的值,因为刚才A 号核心更新 i 值还没写⼊到内存中,内存中的值还依然是 0,这个就是所谓的缓存⼀致性问题。

要解决这一问题,需要一种机制来同步两个不同 CPU 核的缓存数据,这种机制必须要保证以下两点:

  • 第一,某个 CPU 核心里的 Cache 数据更新时,必须要传播到其他核心的 Cache,这 个称为写传播(Wreite Propagation)
  • 第二,某个 CPU 核心里对数据的操作顺序,必须在其他核心看起来顺序是⼀样的,这个称为事务的串形化(Transaction Serialization)

对于第二点可能不太好理解,假设我们有⼀个含有 4 个核心的 CPU,这 4 个核都操作共同的变量 i(初始值为 0 )
微信截图_20210825093217.png
A号核心先把 i 值变为 100,而此时同⼀时间,B 号核心把 i 值变为 200,这里两个修改,都会传播到 C 和 D 号核心。

那么问题就来了,C 号核心先收到了 A 号核⼼更新数据的事件,再收到 B 号核心更新数据的事件,因此 C 号核心看到的变量 i 是先变成 100,后变成 200。 而如果 D 号核心收到的事件是反过来的,先看到变量 i 先变成 200,再变成100,虽然是做到了写传播,但是各个 Cache 里面的数据还是不⼀致的。
微信截图_20210825095215.png
所以,我们要保证 C 号核心和 D 号核心都能看到相同顺序的数据变化,比如变量 i 都是先变成 100,再变成 200,这样的过程就是事务的串形化。

总线嗅探

写传播的原则就是当某个 CPU 核心更新了 Cache 中的数据,要把该事件广播通知到其他核心。最常见实现的方式是总线嗅探(Bus Snooping)

当 A号 CPU 核心修改了 L1 Cache 中 i 变量的值,通过总线把这个事件广播通知给其他所有的核心,然后每个 CPU 核心都会监听总线上的广播事件,并检查是否有相同的数据在自己的 L1 Cache 里面,如果 B 号 CPU 核心的 L1 Cache 中有该数据,那么也需要把该数据更新到自己的 L1 Cache。

总线嗅探只是保证了某个 CPU 核心的 Cache 更新数据这个事件能被其他 CPU 核心知
道,但是并不能保证事务串形化。 于是,有⼀个协议基于总线嗅探机制实现了事务串形化,也用状态机机制降低了总线带宽压力,这个协议就是 MESI 协议,这个协议就做到了 CPU 缓存⼀致性。

MESI协议

MESI 协议中四个字母分别代表 Modified 已修改,Exclusive独占,Share 共享,Invalidated 已失效,用这四个状态来标记 Cache Line 四个不同的状态

状态 描述 监听任务
Modified 修改 该Cache Line 有效,数据和内存中的数据不一致,数据只存在本Cache Line 中 缓存行必须时刻监听所有试图读该缓存行数据相对于主存数据的操作,这种操作必须在将该缓存行写回主存并将状态变成S(共享)状态之前被延迟执行
Exclusive 独占 该Cache Line 有效,数据和内存中的数据一致,数据只存在本Cache Line 中 缓存行也必须监听其它缓存读主存中该缓存行的操作,一旦有这种操作,该缓存行需要变成S(共享)状态
Shared 共享 该Cache Line 有效,数据和内存中的数据一致,数据存在于多个Cache Line 中 缓存行也必须监听其它缓存使该缓存行无效或者独享该缓存行的请求,并将该缓存行变成无效(Invalid)
Invalidate 已失效 该Cache Line 无效

我们举个例子说明这四种状态的变化:

  1. 当A号 CPU 核心从内存读取变量 x 的值,数据被缓存在 A号 CPU 核心自己的 Cache 里面,此时其他 CPU 核心的 Cache 没有缓存该数据,于是标记 Cache Line 状态为 “E独占”,此时其 Cache 中的数据与内存是⼀致的

微信截图_20210825104133.png

  1. 然后B号 CPU 核心也从内存读取了变量 x 的值,此时会发送消息给其他 CPU 核心,由于A号 CPU 核心已经缓存了该数据,所以会把数据返回给 B 号 CPU 核⼼。在这个时 候, A 和 B 核心缓存了相同的数据,Cache Line 的状态就会变成 “S共享”,并且其 Cache 中的数据与内存也是⼀致的

微信截图_20210825105118.png

  1. 当A号 CPU 核心要修改 Cache 中 x 变量的值,发现数据对应的 Cache Line 的状态是共享状态,则要向所有的其他 CPU 核心广播⼀个请求,要求先把其他核心的 Cache 中对 应的 Cache Line 标记为 “I 无效” 状态,然后 A 号 CPU 核心才更新 Cache 里面的数据,同时标记 Cache Line 为 “M 已修改” 状态,此时 Cache 数据就与内存不⼀致了。

微信截图_20210825105524.png

  1. 如果 A 号 CPU 核心继续修改 Cache 中 x 变量的值,由于此时的 Cache Line 是状态,”M 已修改” 因此不需要给其他 CPU 核心发送消息,直接更新数据即可。
  2. 如果A号 CPU 核心的 Cache 里的 x 变量对应的 Cache Line 要被替换 ,发现Cache Line 状态是 “M 已修改” 状态,或者其他 CPU核心要从内存中重新读取变量 x,就会在替换前或者别的 CPU 核心读取前先把数据同步到内存,并且把状态改为 “E独占”

微信截图_20210825110213.png
所以,可以发现当 Cache Line 状态是”M 已修改”或者 “E 独占” 状态时,修改更新其数据不需要发送广播给其他 CPU 核心,这在⼀定程度上减少了总线带宽压力。

MESI状态转换

在了解状态转换之前,先搞清楚一些概念

  1. cache 分类(前提是所有的cache共同缓存了主内存中的某一条数据)

本地cache:指当前cpu的cache
触发cache:触发读写事件的cache
其他cache:指既除了以上两种之外的cache

  1. 触发事件 | 触发事件 | 描述 | | —- | —- | | 本地读取(Local read) | 本地cache读取本地cache数据 | | 本地写入(Local write) | 本地cache写入本地cache数据 | | 远端读取(Remote read) | 其他cache读取本地cache数据 | | 远端写入(Remote write) | 其他cache写入本地cache数据 |

微信截图_20210825000925.png
上图的解释:

状态 触发本地读取 触发本地写入 触发远端读取 触发远端写入
M状态(修改) 本地cache:M
触发cache:M
其他cache:I
本地cache:M
触发cache:M
其他cache:I
本地cache:M→E→S
触发cache:I→S
其他cache:I→S
同步主内存后修改为E独享,同步触发、其他cache后本地、触发、其他cache修改为S共享
本地cache:M→E→S→I
触发cache:I→S→E→M
其他cache:I→S→I
同步和读取一样,同步完成后触发cache改为M,本地、其他cache改为I
E状态(独享) 本地cache:E
触发cache:E
其他cache:I
本地cache:E→M
触发cache:E→M
其他cache:I
本地cache变更为M,其他cache状态应当是I(无效)
本地cache:E→S
触发cache:I→S
其他cache:I→S
当其他cache要读取该数据时,其他、触发、本地cache都被设置为S(共享)
本地cache:E→S→I
触发cache:I→S→E→M
其他cache:I→S→I
当触发cache修改本地cache独享数据时时,将本地、触发、其他cache修改为S共享.然后触发cache修改为独享,其他、本地cache修改为I(无效),触发cache再修改为M
S状态 (共享) 本地cache:S
触发cache:S
其他cache:S
本地cache:S→E→M
触发cache:S→E→M
其他cache:S→I
当本地cache修改时,将本地cache修改为E,其他cache修改为I,然后再将本地cache为M状态
本地cache:S
触发cache:S
其他cache:S
本地cache:S→I
触发cache:S→E→M
其他cache:S→I
当触发cache要修改本地共享数据时,触发cache修改为E(独享),本地、其他cache修改为I(无效),触发cache再次修改为M(修改)
I状态(无效) 本地cache:I→S或者I→E
触发cache:I→S或者I →E
其他cache:E、M、I→S、I
本地、触发cache将从I无效修改为S共享或者E独享,其他cache将从E、M、I 变为S或者I
本地cache:I→S→E→M
触发cache:I→S→E→M
其他cache:M、E、S→S→I
既然是本cache是I,其他cache操作与它无关 既然是本cache是I,其他cache操作与它无关

通过上面的描述,我们知道MESI协议的四种状态是用来标记 CacheLine 的,那么如果一些变量超过了 64Byte,一个缓存行装不下呢?此时缓存一致性协议会失效,直接升级为总线锁。