volatile实现原理总结

199 阅读13分钟

volatile 是JAVA中的关键字,用来修饰会被不同线程访问的变量

作用

  1. 保证变量的可见性
  2. 防止发生指令重排

原理

  1. CPU层面 - 缓存一致性协议、CPU内存屏障
  2. JVM层面 - JAVA内存模型(JMM)、JMM内存屏障、as-if-serial约束、happens-before规则

CPU层面

缓存一致性协议(MESI)

CPU 高速缓存(Cache Memory)是位于 CPU 与内存之间的临时存储器,主要是为了解决 CPU 运行处理速度与内存读写速度不匹配的矛盾。

javabf_cpu_01.png

CPU高速缓存提高了效率,但是带来了和主存的数据一致性问题,在单核CPU的情况下,处理一致性问题比较简单

  • 通写法(Write Through):每次 CPU 修改了缓存内容,立即更新到内存,也就意味着每次 CPU 写共享数据,都会导致总线事务。
  • 回写法(Write BACK):每次 CPU 修改了缓存数据,不会立即更新到内存,而是等到某个合适的时机才会更新到内存中去。

多核CPU情况下,每个CPU有各自的L1cache,读取/修改同一变量会造成同一时刻各个线程的数据与主存不一致,解决方案如下:

首先在通写法和回写法之外,又引入了两种操作:

  • 写失效:当一个 CPU 修改了数据,如果其他CPU有该数据,则通知其为无效。
  • 写更新:当一个 CPU 修改了数据,如果其他CPU有该数据,则通知其更新数据。

对于数据冲突的情况,通过加锁处理:

  • 总线锁:在多 CPU 情况下,某个 CPU 对共享变量操作时,在总线上发出一个 LOCK# 信号,总线把 CPU 和内存之间的通信锁住了,其他 CPU 不能操该内存地址的数据。(总线锁这种做法锁定的范围太大了,导致CPU利用率急剧下降,因为使用LOCK#是把CPU和内存之间的通信锁住了,这使得锁定期间其他处理器不能操作其内存地址的数据,所以总线锁的开销比较大)
  • 缓存锁:降低了锁的粒度,基于缓存一致性协议来实现。(从P6系列处理器开始,如果访问的内存区域已经缓存在处理器的缓存行中,LOCK#信号不会被发送。它会对CPU缓存中的缓存行进行锁定,在锁定期间,其他CPU不能同时缓存此数据。在修改之后通过缓存一致性协议来保证修改的原子性。这个操作被称为“缓存锁”)

缓存一致性协议需要满足以下两种特性:

  • 写传播(Write propagation):一个处理器对于某个内存位置所做的写操作,对于其他处理器是可见的
  • 写串行化(Write Serialization):对同一内存单元的所有写操作都能串行化。即所有的处理器能以相同的次序看到这些写操作

对于写传播:大致可以分为以下两种方式:

  • 嗅探:广播机制,即要监听总线上的所有活动
  • 基于目录:点对点,总线事件只会发给感兴趣的 CPU

MESI的实现就是基于 回写法 & 写失效 & 缓存锁 & 写传播 & 写串行化 & 嗅探
MESI 指的是缓存行的四种状态(Modified,Exclusive,Shared, Invalid),用 2 个 bit 表示

四种状态

  1. M: 被修改(Modified)
    当前 CPU 缓存有最新数据, 其他 CPU 拥有失效数据,当前 CPU 数据与内存不一致,但以当前 CPU 数据为准。
  2. E: 独享的(Exclusive)
    只有当前 CPU 有数据,其他 CPU 没有该数据,当前 CPU 数据与内存数据一致。
  3. S: 共享的(Shared)
    当前 CPU 与其他 CPU 拥有相同数据,并与内存中数据一致。
  4. I: 无效的(Invalid)
    当前 CPU 数据失效,其他 CPU 数据可能有可能无,数据应从内存中读取,且当前 CPU 与 内存数据不一致。

四种操作

  • Local Read(LR):当前 CPU 读操作
  • Local Write(LW):当前 CPU 写操作
  • Remote Read(RR):其他 CPU 读操作
  • Remote Write(RW):其他 CPU 写操作

状态转换

演示动画(很好用): www.scss.tcd.ie/Jeremy.Jone…

  1. Modified
  • LR,当前CPU读,已经是最新数据,直接读
  • LW,当前CPU写,直接修改,状态还是M
  • RR,其他CPU读,将当前数据写回主存,状态和其他CPU一样都改为S
  • RW,其他CPU写,将当前数据写回主存,随后RW将内存数据修改, 状态变为I
  1. Exclusive
  • LR, 直接读就完事了
  • LW, 直接改,状态改为M
  • RR,状态改为S
  • RW,状态改为I
  1. Shared
  • LR, 直接读
  • LW, 直接改,状态变为M
  • RR,啥也不发生
  • RW,其他CPU数据为最新,当前CPU数据状态改为I
  1. Invalid(重点)
  • LR,当前 CPU 读操作,当前 CPU 缓存不可用,需要读内存。
    1. 其他CPU没数据,状态改E
    2. 其他CPU有数据且都是S或E,状态改S
    3. 其他CPU有数据且有M,M那个CPU刷到主存,然后当前CPU再取,之后改为S
  • LW,当前 CPU 写操作,当前 CPU 缓存不可用,需要读内存。
    1. 其他CPU无数据,修改数据,状态改为M
    2. 其他CPU有数据且S或E, 修改数据,状态为M,其他CPU数据状态改为I
    3. 其他 CPU 有数据且状态为 M, 其他 CPU 先将数据写回内存,随后当前 CPU 写数据,状态改为 M
  • RR, 与我无瓜
  • RW, 与我无瓜

MESI优化

虽然MESI协议要比总线锁效率高,粒度小,但每次有变量有变动都要阻塞等待其他CPU的ACK信息,效率还是有问题,于是给了每个CPU一个写缓冲区(Load Buffer)和失效队列(Invalid Queue)

  • 写缓冲区(Load Buffer) 发生LW时,不再等待其他CPU的ACK信息,直接把修改写入写缓冲区,执行后面的指令,发生LR时,先去写缓冲区看看修改过没有会造成命令乱序,比如本来写A(S),B(E)两个数据,会导致B比A先写完
  • 失效队列(Invalid Queue) 发生RW时,不再立即更新后发送ACK,而是写入失效队列,直接发送ACK回执会造成读到失效数据

经过优化,效率是上去了,但是强一致性变成了最终一致性,因此需要CPU内存屏障保证数据的准确性。

  • 写屏障(Store Memory Barrier):告诉 CPU 在执行屏障之后的指令前,将所有在存储缓存(store buffer)中的数据同步到内存。
  • 读屏障(Load Memory Barrier):告诉 CPU 在执行任何的加载前,先处理所有在失效队列(Invalid)中的消息。

x86下的LOCK前缀指令

  1. 加总线锁或缓存锁,缓存锁锁定cache中的缓存行,其他CPU不能对该行进行读写操作,直到写操作完成,将数据刷入主存
  2. 具有类似于内存屏障的功能,禁止该指令与前面和后面的读写指令重排序
  3. 等待它之前所有的指令完成、并且所有缓冲的写操作写回内存(也就是将store buffer中的内容写入内存)之后才开始执行,并且根据缓存一致性协议,刷新store buffer的操作会导致其他cache中的副本失效。

volatile在hotspot中使用了lock前缀(写操作时)而不是MFENCE,为啥呢,可能就是用LOCK前缀省事吧,刚好lock前缀实现缓存锁的同时有内存屏障的功能,即保障了可见性也符合JMM的storeLoad语义实现了内存屏障

LOCK前缀可以加缓存锁,其实缓存锁和MESI并不是一回事,缓存锁和总线锁才是同一纬度的东西,只是缓存锁粒度更小,volatile只有在写入的时候才加锁。MESI保证了多个内核不用担心自己读到的是旧数据,但不能保证每个内核写入缓存行是原子性的,说白了就是两个内核在这里可能会产生写并发,导致+1后的结果同时写入, 最终结果不可控,通过Lock前缀,在L1/L2/L3缓存行加锁, 谁获得锁谁执行, 将写入缓存行变为原子性操作,解决了并发问题,之后再根据MESI协议,实现了缓存锁。不使用LOCK前缀MESI也会生效,但是会有并发问题。

参考:
qa.1r1g.com/sf/ask/2091… blog.csdn.net/qq_38322527… 这个很详细

JMM层面

JAVA内存交互-可见性

在 JVM 层面,定义了一种抽象的内存模型 (JMM)用来解决可见性和有序性问题。它定义了在共享内存中多线程读写的操作规范。

jmm_01.png

如果线程 A 和线程 B 之间需要通信:

  1. 首先线程 A 将本地内存 A 中更新过的共享变量刷新到主内存中去。
  2. 然后线程 B 再到主内存中去读取线程 A 之前更新过的共享变量。

内存交互:

mem.png

  1. Lock(锁)操作: 操作对象为线程,作用对象为主内存的变量,当一个变量被锁住的时候,其他线程只有等当前线程解锁之后才能使用,其他线程不能对该变量进行解锁操作。
  2. Unlock(解锁)操作: 同上,线程操作,作用于主内存变量,令一个被锁住的变量解锁,使得其他线程可以对此变量进行操作,不能对未锁住的变量进行解锁操作。
  3. Read(读): 线程从主内存读取变量值,load操作根据此读取的变量值为线程内存中的变量副本赋值。
  4. Load(加载): 将Read读取到的变量值赋到线程内存的副本中,供线程使用。
  5. Use(使用): 读取线程内存的作用值,用来执行我们定义的操作。
  6. Assign(赋值): 在线程操作中变量的值进行了改变,使用此操作刷新线程内存的值。
  7. Store(储存): 将当前线程内存的变量值同步到主内存中,与write操作一起作用。
  8. Write(写): 将线程内存中store的值写入到主内存中,主内存中的变量值进行变更。

  可能有人会不理解read和load、store和write的区别,觉得这两对的操作类似,可以把其当做一个是申请操作,另一个是审核通过(允许赋值)。例如:线程内存A向主内存提交了变更变量的申请(store操作),主内存通过之后修改变量的值(write操作)。如下图:

JMM层面内存屏障-有序性

  • LoadLoad 屏障:操作序列 Load1,LoadLoad,Load2,在 Load2 及后续读取操作要读取的数据被访问前,保证 Load1 要读取的数据被读取完毕
  • LoadStore 屏障:操作序列 Load1,LoadStore,Store2,在 Store2 及其后续写入操作被刷出前,保证 Load1 要读取的数据被读取完毕
  • StoreStore 屏障:操作序列 Store1,StoreStore,Store2,在 Store2 及后续写入操作执行前,保证 Store1 的写入操作对其他处理器可见
  • StoreLoad 屏障:操作序列 Store1,StoreLoad,Load2,在 Load2 及后续的读取操作执行前,保证 Store1 的写入对其他处理器可见

StoreLoad 屏障开销最大,并且兼具上面三种屏障的作用。这四种屏障是 Java 为了跨平台设计出来的规范,实际根据 CPU 的不同,可能会优化掉一些屏障。例如 X86 就只有 StoreLoad

as-if-serial

不管怎么重排序(编译器和处理器为了提高并行度),(单线程)程序的执行结果不能被改变。编译器,runtime 和处理器都必须遵守 as-if-serial 语义。

为了遵守 as-if-serial 语义,编译器和处理器不会对存在数据依赖关系的操作做重排序。

happens-before

happens-before 指的是:前面的一个操作的结果对后续操作时可见的,这两个操作可以在同一线程内,也可在不同线程之间。

volatile

JMM层面

  1. 内存语义 - 可见性
  • 当写一个volatile变量时,JMM会把该线程对应的本地内存中的共享变量值立即刷新回主内存中。
  • 当读一个volatile变量时,JMM会把该线程对应的本地内存设置为无效,直接从主内存中读取共享变量
  • 所以volatile的写内存语义是直接刷新到主内存中,读的内存语义是直接从主内存中读取

从内存交互的角度:

在对变量执行use操作之前,其前一步操作必须为对该变量的load操作;在对变量执行load操作之前,其后一步操作必须为该变量的use操作。 也就是说,使用volatile修饰的变量其read、load、use都是连续出现的,所以每次使用变量的时候都要从主内存读取最新的变量值,替换私有内存的变量副本值(如果不同的话)。
在对变量执行assign操作之前,其后一步操作必须为store;在对变量执行store之前,其前一步必须为对相同变量的assign操作。 也就是说,其对同一变量的assign、store、write操作都是连续出现的,所以每次对变量的改变都会立马同步到主内存中。

  1. 内存屏障 - 禁止重排/有序性

volatile实现四种内存屏障,当然在不同的环境下屏障的实现不同.如x86下只利用LOCK前缀命令实现了storeload就够

另外实现的其他禁制重拍:

  • 编译器优化的重排序:编译器在不改变单线程串行语义的前提下,可以重新调整指令的执行顺序
  • 指令级并行的重排序:处理器使用指令级并行技术来讲多条指令重叠执行,若不存在数据依赖性,处理器可以改变语- 句对应机器指令的执行顺序
  • 内存系统的重排序:由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是乱序执行

从内存交互的角度:

在主内存中有变量a、b,动作A为当前线程对变量a的use或者assign操作,动作B为与动作A对应load或store操作,动作C为与动作B对应的read或write操作;动作D为当前线程对变量b的use或assign操作,动作E为与D对应的load或store操作,动作F为与动作E对应的read或write操作;如果动作A先于动作D,那么动作C要先于动作F。
也就是说,如果当前线程对变量a执行的use或assign操作在对变量buse或assign之前执行的话,那么当前线程对变量a的read或write操作肯定要在对变量b的read或write操作之前执行。

CPU层面

  1. 可见性 - MESI+LOCK前缀加缓存锁实现
  2. 内存屏障 - LOCK前缀实现的内存屏障

参考资料: zhuanlan.zhihu.com/p/375706879 www.cnblogs.com/zhangweiche… www.kancloud.cn/luoyoub/jav… blog.csdn.net/TZ845195485…