volatile 底层原理

JMM 三大特性

可见性

指当一个线程修改了某一个共享变量的值,其他线程是否能够立即知道该变更。

刷新主存时机引起的可见性问题

多线程环境下,由于每个线程都只能操作自己的工作(本地)内存,不能操作其他线程的工作内存,因而变量的传递通过主内存进行。

如果两个线程起始都从主存中获取一个对象副本保存在本地内存中,线程1 对本地内存中的对象进行操作并刷新回主存,而线程2 中的本地内存并没有发生变化。

举例:主内存就类似于 Git 仓库中的主分支,本地内存就相当于每个开发者笔记本中的本地分支。开发者 A 提交并合并了主分支的代码(远程仓库主分支更新),而开发者 B 笔记本中的本地分支不会有变化,因而不可见。

指令重排序引起的可见性问题

单线程的指令重排序会通过策略保证和顺序执行结果相同。

多线程环境下,不同线程的指令重排序后,实际的执行顺序被打乱,会导致运行时看起来像没有执行一部分代码。

static boolean b1, b2;

public static void main(String[] args) {
    Thread t1 = new Thread(new Runnable() {
        public void run() {
            b1 = true;
            b2 = true;

        }
    });

    // 例如先执行 b2 = true,然后出现了 if 判断
    Thread t2 = new Thread(new Runnable() {
        public void run() {
            if (b2 && !b1) {
                System.out.println("!");
            }
        }
    });

    t1.start();
    t2.start();
}

原子性

一个操作是不可中断的。即使是在多个线程一起执行的时候,一个操作一旦开始,就不会被其他线程干扰。

由于 JMM 的结构,在多线程情况下 本地内存 和 主内存之间需要考虑原子性问题。例如,线程同时对本地内存中的值做自增操作,并刷新回主存,实际上两个线程各自增一次,结果应该相当于自增两次,而主存中的值只自增一次。

有序性

对于一个线程的执行代码而言,我们习惯认为代码的执行总是从上到下,有序执行。

指令重排可以保证串行语义一致,但没有义务保证多线程间的语义也一致。

happens-before

JMM 中的操作执行结果需要对另一操作可见,两操作之间需要有 happens-before 关系。

  • 前一个操作的结果应当可以被后一个操作获取(对后一个操作可见),前一个操作顺序应在后一个之前。
  • 存在 happens-before 关系不代表一定要按照 happens-before 的操作顺序执行,前提是重排序后的执行结果与 happens-before 顺序执行结果一致。

volatile

特性

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

不完全的原子性:对任意单个 volatile 变量的读/写具有原子性,但类似于 volatile++ 这种复合操作不具有原子性。

禁止重排序(包括编译重排、CPU指令重排和使用等效内存屏障避免 store-buffer 引起的乱序)

定义

Java编程语言允许线程访问共享变量,为了确保共享变量能被准确和一致地更新,线程应该确保通过排他锁单独获得这个变量。

volatile 某些情况下比锁要更加方便。如果一个字段被声明成 volatile,Java线程内存模型确保所有线程看到这个变量的值是一致的

Demo

public class main {
    volatile static int vi = 1;
    static void showVolatile() {
        vi++;
    }
    public static void main(String[] args) {
        for (int i = 0; i < 300; i++) {
            showVolatile();
        }
    }
}

VM options 添加如下三条

  • -XX:+UnlockDiagnosticVMOptions
  • -XX:CompileCommand=print,main.showVolatile
  • -XX:-UseCounterDecay

下载 hsdis-amd64.dll 插件,放在 jdk1.8.0_291\jre\bin\server 路径下。

以上操作通过重复运行 showVolatile() 方法,让 JIT 认定其为热点方法,就可以将方法的汇编信息打印出来(如果没有打印出结果,可以增加调用次数)。

第四行,也就是 vi++ 对应的汇编信息:

0x000001e3d07bb8e8: mov    0x68(%rsi),%edi    ;*getstatic vi
                                              ; - main::showVolatile@0 (line 4)

0x000001e3d07bb8eb: inc    %edi
0x000001e3d07bb8ed: mov    %edi,0x68(%rsi)
0x000001e3d07bb8f0: lock addl $0x0,(%rsp)     ;*putstatic vi
                                              ; - main::showVolatile@5 (line 4)

如果去掉 volatile,则为如下:

0x000001dab3e2ae28: mov    0x68(%rsi),%edi    ;*getstatic vi
                                              ; - main::showVolatile@0 (line 4)

0x000001dab3e2ae2b: inc    %edi
0x000001dab3e2ae2d: mov    %edi,0x68(%rsi)    ;*putstatic vi
                                              ; - main::showVolatile@5 (line 4)

也就是对带 volatile 的值进行写操作时会增加一行 Lock 前缀的指令。

Lock前缀指令会引起处理器缓存回写到内存

LOCK 指令前缀会设置处理器的 LOCK# 信号(译注:这个信号会使总线锁定,阻止其他处理器接管总线访问内存),直到使用 LOCK 前缀的指令执行结束,这会使这条指令的执行变为原子操作。在多处理器环境下,设置LOCK# 信号能保证某个处理器对共享内存的独占使用。

从 P6 处理器家族开始,如果使用了 LOCK 指令前缀的指令要访问的目的地址的内容已经缓存在了 cache 中,那么LOCK# 信号一般就不会被设置,但是当前处理器的 cache 会被锁定(并回写到内存),然后缓存一致性(cache coherency)机制会自动确保操作的原子性。缓存一致性机制会阻止同时修改被两个以上处理器缓存的内存区域的数据。

一个处理器的缓存回写到内存会导致其他处理器的缓存无效

在多核处理器系统中进行操作的时候,IA-32 和 Intel 64 处理器能嗅探其他处理器访问系统内存和它们的内部缓存。处理器使用嗅探技术保证它的内部缓存、系统内存和其他处理器的缓存的数据在总线上保持一致。例如,在Pentium 和 P6 家族处理器中,如果一个处理器通过嗅探检测到其他处理器打算写内存地址,而这个地址当前处于共享状态,那么正在嗅探的处理器将使它(自己)的缓存行无效,在下次访问相同内存地址时,强制执行缓存行填充。

到这里还是一些比较模糊的概念,没关系,可以先了解缓存一致性协议。

缓存一致性协议(仅介绍 MESI)

缓存一致性协议分为两种,第一种是基于窥探的协议(snoop-based),第二种是基于目录的协议(directory-based),基于窥探的协议在共享总线上广播一致信息,基于目录的协议能更好地扩展。MESI 协议是基于窥探的协议。

  • M:modified (被修改) —— 该缓存行中的内容被修改了,并且该缓存行只被缓存在该 CPU 中。这个状态,缓存行中的数据和内存中的不一样,在未来的某个时刻它会被写入到内存中。
  • E:exclusive (独占) —— 该缓存行对应内存中的内容只被该 CPU 缓存,其他 CPU 没有缓存该缓存对应内存行中的内容。这个状态的缓存行中的内容和内存中的内容一致。该缓存行可以在任何其他 CPU 读取该缓存对应内存中的内容时变成 S 状态。或者本地处理器写该缓存就会变成 M 状态。
  • S:shared (共享) —— 数据不止存在本地 CPU 缓存中,还存在别的 CPU 的缓存中。该状态下数据和内存中的数据是一致的。但是,当有一个CPU修改该缓存行对应的内存的内容时会使该缓存行变成 I 状态。
  • I:invalid (失效) —— 该缓存行中的内容是无效的。
MESI 的状态转移
当前状态操作情况
InvalidLocal Read1. 其他 core 的 cache 上没有这个 cache block 副本,从主存中加载,变为 Exclusive
2. 其他 core 的 cache 上有这个 cache block 副本,如果为 M 则写写入主存,如果为 E 和 S则直接获取主存的 cache block,变为 Shared
InvalidLocal Write 当前 core 修改 cache block 副本,副本的内容与内存不一致(dirty),并且只有当前 core 拥有这个最新的副本,变为 Modified
InvalidRemote Read,
Remote Write
无影响。
ExclusiveLocal Read无影响。
ExclusiveLocal Write独占时修改 cache block 副本内容,变为 Modified
ExclusiveRemote Read其他 core 读取这个 cache block,变为 Shared
ExclusiveRemote Write其他 core 修改 cache block 的内容,当前 core 的 cache block 副本的内容过时,变为 Invalid
SharedLocal Read,
Remote Read
无影响。
SharedLocal Write当前 core 的 cache block 副本与内存中的 cache block 不一致,变为 Modified
SharedRemote Write 其他 core 修改 cache block 的内容,当前 core 的 cache block 副本的内容过时,变为 Invalid
ModifiedLocal Read
Local Write
无影响。
ModifiedRemote Read先将 cache block 副本内容写入主存,其他 core 读取 cache block,不再是独占,变为 Shared
Modified Remote Write 先将 cache block 副本内容写入主存 ,其他 core 修改 cache block,当前 core 内容过时,本地 cache 变为 Invalid

基于目录的缓存一致性可以参考 CMU 的课程讲义《Directory-Based Cache Coherence》

内存顺序模型:完全存储顺序(Total Store Ordering)

一些定义:

  • 程序顺序:程序给出的指令执行顺序(可以从代码中看出的顺序)
  • 内存(读写)顺序:内存写入数据的真实顺序
  • 观察(生效)顺序:各 CPU 看到的在内存中发生的顺序(不是真实写入顺序,而是 CPU 观察到写入后发生的更变)

内存顺序模型就是规定,内存生效的顺序应该是怎么样的。

完全存储顺序是一种靠向程序顺序的顺序模型。

Total 说明内存(在写操作上)有一个全局的顺序(所有人看到都一样的顺序),这个顺序和你的程序顺序直接相关。

TSO 规定了所有 CPU 的 store 操作保持单一全局顺序(所有 CPU 的 store 访问内存时在一个队列中排队访问内存,不能并发访问内存),而 load 操作可以并发访问

在 SC 顺序模型中,load 和 store 操作都必须按照单一全局的内存顺序,这样在多处理器环境下,store 操作还要同步所有 CPU 的缓存,就会阻塞 load 操作。因此在 CPU 中添加了 store buffer:

store 操作可以先不执行,放入 store buffer 中缓存,load 操作只要在 store 放入 store buffer 后即可执行。

(仅增加 store buffer 会存在问题,之后又增加 store forwarding、写屏障和 invalid queue

store buffer 导致 store-load 操作会被打乱(看起来像重排序),因此在多线程编程时,程序的结果会变得不可预测。

volatile 具体影响了什么?

首先,指令的重排序在多个阶段都有出现,包括:

  • JIT 编译优化
  • CPU 自身优化
  • store buffer 导致的乱序(看起来像重排序)

volatile 禁止指令重排序,首先避免了在编译时期和 CPU 优化的重排。

还记得 volatile 反汇编后的 Lock 前缀吗?Lock 前缀的指令在功能上等价于内存屏障,可以让 store buffer 内容立即写入缓存行,清空其他共享核心的 invalid queue,触发 MESI,这样就保证了这部分指令执行时 store-load 顺序不会被打乱。

PS:注意 MESI 能保证多处理器对一个变量读写的一致性,但不能保证多处理器对多变量读写的一致性(因为上面提到的 store-load),所以在依赖 MESI 的基础上,volatile 还增加了 Lock前缀(或是内存屏障,具体看是采用了强顺序模型还是弱顺序模型)。