Java 并发详解 ⑥:volatile

深入解析 volatile 、CAS 的实现原理

处理器如何实现原子操作
处理器提供 总线锁定缓存锁 定两个机制来保证复杂内存操作的原子性。

  • 使用总线锁保证原子性
    所谓总线锁就是使用处理器提供的一个 LOCK#信号,当一个处理器在总线上输出此信号时,其他处理器的请求将被阻塞住,那么该处理器可以独占使用共享内存。
  • 使用缓存锁保证原子性

底层用 lock 实现,如果是多核添加 lock 指令。
lock 用于在多处理器中执行指令时对共享内存的独占使用。
它的作用是能够将当前处理器对应缓存的内容刷新到内存,并使其他处理器对应的缓存失效
另外还提供了有序的指令无法越过这个内存屏障的作用

volatile 变量自身具有以下特性:

  • [可见性](#1. 保证线程可见性):对一个 volatile 变量的读,总是能看到 (任意线程) 对这个 volatile 变量最后的写入。
  • [禁止指令重排](#2. 禁止指令重排)

想了解以上特性的原理,需先了解 [处理器缓存](#0. 处理器缓存)。

0. 处理器缓存

CPU 缓存(Cache Memory)是位于 CPU 与内存之间的临时存储器,它的容量比内存小的多但是交换速度却比内存要快得多。

L1(一级缓存)分为 数据缓存 和 _指令缓存_,L2(二级缓存)和 L3(三级缓存)只有 数据缓存

作用:
缓存的出现主要是为了解决 CPU 运算速度与内存读写速度不匹配的矛盾,因为 CPU 运算速度要比内存读写速度快很多,这样会使 CPU 花费很长时间等待数据到来或把数据写入内存。

读缓存: CPU 依次从一级缓存、二级缓存、三级缓存中获取数据,若未命中则到内存中获取,再去更新缓存

缓存系统中是以缓存行(cache line)为单位存储的。缓存行是 2 的整数幂个连续字节,一般为 32-256 个字节。最常见的缓存行大小是 64 个字节。

因此当 CPU 在执行一条读内存指令时,它是会将内存地址所在的缓存行大小的内容都加载进缓存中的。也就是说,一次加载一整个缓存行。

写操作: 两种模式

  • 直写(write-through):更新内存数据再更新缓存(或丢弃)。保证该数据在缓存与内存中一致
    透过本级缓存,直接把数据写到下一级缓存(或直接到内存)中。
    如果对应的段被缓存了,会同时更新缓存中的内容(甚至直接丢弃)。
  • 回写(write-back):先更新缓存,再由缓存回写至内存。缓存暂时与内存不一致,但最终会写回内存。
    仅修改本级缓存中的数据,并且把对应的缓存段标记为 “脏” 段。
    脏段会触发回写,也就是把里面的内容写到对应的内存或下一级缓存中。

1. 保证线程可见性

对一个 volatile 变量的读,总是能看到 (任意线程) 对这个 volatile 变量最后的写入。

1.1. 窥探技术

所有内存传输都发生在一条共享的总线上,而所有的处理器都能看到这条总线。

  • 同一个指令周期中,只有一个缓存可以读写内存,会通知其他处理器
  • 缓存不断地窥探总线上发生的数据交换。只要某个处理器写内存,其他处理器马上就知道这块内存在它们自己的缓存中对应的缓存行已经失效。

1.2. MESI 协议

缓存一致性协议:
在多核处理器系统中,每个处理器核心都有它们自己的一级缓存、二级缓存等。

这样一来当多个处理器核心在对共享的数据进行写操作时,就需要 保证该缓存数据在所有处理器核心中的可见性 / 一致性

MESI 是缓存行四种状态的首字母缩写,任何多核系统中的缓存行都处于这四种状态之一。

  • 失效(Invalid): 该处理器缓存中无该缓存行,或缓存中的缓存行已经失效了。
  • 共享(Shared): 多组缓存都可以拥有指向同一内存地址的缓存行。且缓存行只能被读取,不能被写入。
    该状态下缓存行数据是主内存的一份拷贝,其数据与主内存数据保持一致。
  • 独占(Exclusive): 如果一个处理器持有了某个「独占」状态的缓存行,其他处理器中的同一缓存行会变成「失效」状态。
    缓存行数据是主内存的一份拷贝,其数据与主内存数据保持一致。
  • 已修改(Modified): 属于脏段,表示该缓存行已经被所属的处理器修改了。如果一个缓存行处于「已修改」状态,那么它在其他处理器缓存中的拷贝马上会变成「失效」状态。
    已修改缓存行如果被丢弃或标记为「失效」状态,那么先要把它的内容回写到内存中,即需保证已经修改的数据一定要回写至内存。

写操作过程:
只有当缓存行处于「独占」状态或「已修改」状态时处理器才能对其进行写操作

当处理器想对某个缓存段进行写操作时,如果它没有独占权

  1. 会先发送一条申请独占权的请求给总线,这会通知其他处理器
  2. 其他处理器把它们拥有的同一缓存行的拷贝改为「失效」状态
  3. 修改数据,更改状态为「已修改」状态

读操作过程:
当处理器想对某个缓存段进行读操作时

  1. 若缓存行处于「独占」状态或「已修改」状态时,直接读
  2. 若其他处理器中有同一缓存行的拷贝且处于「独占」状态或「已修改」状态时(由于窥探总线技术,所以也会知道)需把状态改为「共享」状态才能进行读操作

    其他处理器中的缓存行若为「已修改」状态需先把它的内容回写到内存中

操作系统通过内存屏障保证缓存间的可见性,JVM 通过给 volatile 变量加入内存屏障保证线程之间的可见性。

其实,volatile 对于可见性的实现,内存屏障也起着至关重要的作用。因为内存屏障相当于一个数据同步点,他要保证在这个同步点之后的读写操作必须在这个点之前的读写操作都执行完之后才可以执行。并且在遇到内存屏障的时候,缓存数据会和主存进行同步,或者把缓存数据写入主存、或者从主存把数据读取到缓存。

已经有了缓存一致性协议,为什么还需要 volatile?

  1. 并不是所有的硬件架构都提供了相同的一致性保证,Java 作为一门跨平台语言,JVM 需要提供一个统一的语义。
  2. 操作系统中的缓存和 JVM 中线程的本地内存并不是一回事,通常我们可以认为:MESI 可以解决缓存层面的可见性问题。使用 volatile 关键字,可以解决 JVM 层面的可见性问题。
  3. 缓存可见性问题的延伸:由于传统的 MESI 协议的执行成本比较大。所以 CPU 通过 Store Buffer 和 Invalidate Queue 组件来解决,但是由于这两个组件的引入,也导致缓存和主存之间的通信并不是实时的。也就是说,缓存一致性模型只能保证缓存变更可以保证其他缓存也跟着改变,但是不能保证立刻、马上执行。

2. 禁止指令重排

DCL 单例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class Singleton {
private static volatile Singleton singleton;

private Singleton() {}

public static Singleton getInstance() {
if (singleton == null) {
synchronized(Singleton.class) {
if (singleton == null) {
// 若 singleton 没有加 volatile 会出现指令重排问题
singleton = new Singleton();
}
}
}
return singleton;
}
}

创建对象步骤: T t = new T();

对应指令

1
2
3
4
5
0 new #2 <com/test/jvm/volatile/T>
3 dup
4 invokespecial #3 <com/test/jvm/volatile/T.<init>>
7 astore_1
8 return
  1. 申请内存:new 命令
  2. 初始化成员变量:invokespecial 命令
  3. 赋值:astore_1 将对象引用返回给 t

由于指令重排,2 和 3 可能会互换位置。这时变量可能会先拿到一个尚未初始化成员变量的对象,若刚好此时有线程进入 DCL 会直接拿到该变量去使用

读写屏障

  • loadfence
  • storefence