写点什么

Java 并发编程之 JMM & volatile 详解

发布于: 2021 年 01 月 11 日

本文从计算机模型开始,以及 CPU 与内存、IO 总线之间的交互关系到 CPU 缓存一致性协议的逻辑进行了阐述,并对 JMM 的思想与作用进行了详细的说明。针对 volatile 关键字从字节码以及汇编指令层面解释了它是如何保证可见性与有序性的,最后对 volatile 进行了拓展,从实战的角度更了解关键字的运用。

一、现代计算机理论模型与工作原理

1.1 冯诺依曼计算机模型


让我们来一起回顾一下大学计算机基础,现代计算机模型——冯诺依曼计算机模型,是一种将程序指令存储器和数据存储器合并在一起的计算机设计概念结构。依据冯·诺伊曼结构设计出的计算机称做冯.诺依曼计算机,又称存储程序计算机。


计算机在运行指令时,会从存储器中一条条指令取出,通过译码(控制器),从存储器中取出数据,然后进行指定的运算和逻辑等操作,然后再按地址把运算结果返回内存中去。


接下来,再取出下一条指令,在控制器模块中按照规定操作。依此进行下去。直至遇到停止指令。


程序与数据一样存贮,按程序编排的顺序,一步一步地取出指令,自动地完成指令规定的操作是计算机最基本的工作模型。这一原理最初是由美籍匈牙利数学家冯.诺依曼于 1945 年提出来的,故称为冯.诺依曼计算机模型。



  • 五大核心组成部分:

  1. 运算器:顾名思义,主要进行计算,算术运算、逻辑运算等都由它来完成。

  2. 存储器:这里存储器只是内存,不包括内存,用于存储数据、指令信息。实际就是我们计算机中内存(RAM)

  3. 控制器:控制器是是所有设备的调度中心,系统的正常运行都是有它来调配。CPU 包含控制器和运算器。

  4. 输入设备:负责向计算机中输入数据,如鼠标、键盘等。

  5. 输出设备:负责输出计算机指令执行后的数据,如显示器、打印机等。


  • 现代计算机硬件结构:



图中结构可以关注两个重点:


I/O 总线:所有的输入输出设备都与 I/O 总线对接,保存我们的内存条、USB、显卡等等,就好比一条公路,所有的车都在上面行驶,但是毕竟容量有限,IO 频繁或者数据较大时就会引起“堵车”


CPU:当 CPU 运行时最直接也最快的获取存储的是寄存器,然后会通过 CPU 缓存从 L1->L2->L3 寻找,如果缓存都没有则通过 I/O 总线到内存中获取,内存中获取到之后会依次刷入 L3->L2->L1->寄存器中。现代计算机上我们 CPU 一般都是 1.xG、2.xG 的赫兹,而我们内存的速度只有每秒几百 M,所以为了为了不让内存拖后腿也为了尽量减少 I/O 总线的交互,才有了 CPU 缓存的存在,CPU 型号的不同有的是两级缓存,有的是三级缓存,运行速度对比:寄存器 > L1 > L2 > L3 > 内存条

1.2 CPU 多级缓存和内存


CPU 缓存即高速缓冲存储器,是位于 CPU 与主内存之间容量很小但速度很高的存储器。CPU 直接从内存中存取数据后会保存到缓存中,当 CPU 再次使用时可以直接从缓存中调取。如果有数据修改,也是先修改缓存中的数据,然后经过一段时间之后才会重新写回主内存中。


CPU 缓存最小单元是缓存行(cache line),目前主流计算机的缓存行大小为 64Byte,CPU 缓存也会有 LRU、Random 等缓存淘汰策略。CPU 的三级缓存为多个 CPU 共享的。



  • CPU 读取数据时的流程:

(1)先读取寄存器的值,如果存在则直接读取

(2)再读取 L1,如果存在则先把 cache 行锁住,把数据读取出来,然后解锁

(3)如果 L1 没有则读取 L2,如果存在则先将 L2 中的 cache 行加锁,然后将数据拷贝到 L1,再执行读 L1 的过程,最后解锁

(4)如果 L2 没有则读取 L3,同上先加锁,再往上层依次拷贝、加锁,读取到之后依次解锁

(5)如果 L3 也没有数据则通知内存控制器占用总线带宽,通知内存加锁,发起内存读请求,等待回应,回应数据保存到 L3(如果没有就到 L2),再从 L3/2 到 L1,再从 L1 到 CPU,之后解除总线锁定。


  • 缓存一致性问题:

在多处理器系统中,每个处理器都有自己的缓存,于是也引入了新的问题:缓存一致性。当多个处理器的运算任务都涉及同一块主内存区域时,将可能导致各自的缓存数据不一致的情况。为了解决一致性的问题,需要各个处理器访问缓存时都遵循一些协议,在读写时要根据协议来进行操作,这类协议有 MSI、MESI、MOSI 等等。



1.3 MESI 缓存一致性协议

缓存一致性协议中应用最广泛的就是 MESI 协议。主要原理是 CPU 通过总线嗅探机制(监听)可以感知数据的变化从而将自己的缓存里的数据失效,缓存行中具体的几种状态如下:




以上图为例,假设主内存中有一个变量 x=1,CPU1 和 CPU2 中都会读写,MESI 的工作流程为:


(1)假设 CPU1 需要读取 x 的值,此时 CPU1 从主内存中读取到缓存行后的状态为 E,代表只有当前缓存中独占数据,并利用 CPU 嗅探机制监听总线中是否有其他缓存读取 x 的操作。

(2)此时如果 CPU2 也需要读取 x 的值到缓存行,则在 CPU2 中缓存行的状态为 S,表示多个缓存中共享,同时 CPU1 由于嗅探到 CPU2 也缓存了 x 所以状态也变成了 S。并且 CPU1 和 CPU2 会同时嗅探是否有另缓存失效获取独占缓存的操作。

(3)当 CPU1 有写入操作需要修改 x 的值时,CPU1 中缓存行的状态变成了 M。

(4)CPU2 由于嗅探到了 CPU1 的修改操作,则会将 CPU2 中缓存的状态变成 I 无效状态。

(5)此时 CPU1 中缓存行的状态重新变回独占 E 的状态,CPU2 要想读取 x 的值的话需要重新从主内存中读取。

二、JMM 模型

2.1  Java 线程与系统内核的关系


Java 线程在 JDK1.2 之前,是基于称为“绿色线程”(Green Threads)的用户线程实现的,而在 JDK1.2 中,线程模型替换为基于操作系统原生线程模型来实现。因此,在目前的 JDK 版本中,操作系统支持怎样的线程模型,在很大程度上决定了 Java 虚拟机的线程是怎样映射的,这点在不同的平台上没有办法达成一致,虚拟机规范中也并未限定 Java 线程需要使用哪种线程模型来实现。



用户线程:指不需要内核支持而在用户程序中实现的线程,其不依赖于操作系统核心,应用进程利用线程库提供创建、同步、调度和管理线程的函数来控制用户线程。另外,用户线程是由应用进程利用线程库创建和管理,不依赖于操作系统核心。不需要用户态/核心态切换,速度快。操作系统内核不知道多线程的存在,因此一个线程阻塞将使得整个进程(包括它的所有线程)阻塞。由于这里的处理器时间片分配是以进程为基本单位,所以每个线程执行的时间相对减少。


内核线程: 线程的所有管理操作都是由操作系统内核完成的。内核保存线程的状态和上下文信息,当一个线程执行了引起阻塞的系统调用时,内核可以调度该进程的其他线程执行。在多处理器系统上,内核可以分派属于同一进程的多个线程在多个处理器上运行,提高进程执行的并行度。由于需要内核完成线程的创建、调度和管理,所以和用户级线程相比这些操作要慢得多,但是仍然比进程的创建和管理操作要快。


基于线程的区别,我们可以引出 java 内存模型的结构。

2.2  什么是 JMM 模型


Java 内存模型(Java Memory Model 简称 JMM)是一种抽象的概念,并不真实存在,它描述的是一组规则或规范,通过这组规范定义了程序中各个变量(包括实例字段,静态字段和构成数组对象的元素)的访问方式。


为了屏蔽掉各种硬件和操作系统的内存访问差异,以实现让 Java 程序在各种平台下都能达到一致的并发效果,JMM 规范了 Java 虚拟机与计算机内存是如何协同工作的:JVM 运行程序的实体是线程,而每个线程创建时 JVM 都会为其创建一个工作内存(有些地方称为栈空间),用于存储线程私有的数据,而 Java 内存模型中规定所有变量都存储在主内存,主内存是共享内存区域,所有线程都可以访问,但线程对变量的操作(读取赋值等)必须在工作内存中进行,首先要将变量从主内存拷贝的自己的工作内存空间,然后对变量进行操作,操作完成后再将变量写回主内存,不能直接操作主内存中的变量,工作内存中存储着主内存中的变量副本拷贝。工作内存是每个线程的私有数据区域,因此不同的线程间无法访问对方的工作内存,线程间的通信(传值)必须通过主内存来完成。



主内存

主要存储的是 Java 实例对象,所有线程创建的实例对象都存放在主内存中,不管该实例对象是成员变量还是方法中的本地变量(也称局部变量),当然也包括了共享的类信息、常量、静态变量。由于是共享数据区域,从某个程度上讲应该包括了 JVM 中的堆和方法区。多条线程对同一个变量进行访问可能会发生线程安全问题。


工作内存

主要存储当前方法的所有本地变量信息(工作内存中存储着主内存中的变量副本拷贝),每个线程只能访问自己的工作内存,即线程中的本地变量对其它线程是不可见的,就算是两个线程执行的是同一段代码,它们也会各自在自己的工作内存中创建属于当前线程的本地变量,当然也包括了字节码行号指示器、相关 Native 方法的信息。所以则应该包括 JVM 中的程序计数器、虚拟机栈以及本地方法栈。注意由于工作内存是每个线程的私有数据,线程间无法相互访问工作内存,因此存储在工作内存的数据不存在线程安全问题。

2.3 JMM 详解


需要注意的是 JMM 只是一种抽象的概念,一组规范,并不实际存在。对于真正的计算机硬件来说,计算机内存只有寄存器、缓存内存、主内存的概念。不管是工作内存的数据还是主内存的数据,对于计算机硬件来说都会存储在计算机主内存中,当然也有可能存储到 CPU 缓存或者寄存器中,因此总体上来说,Java 内存模型和计算机硬件内存架构是一个相互交叉的关系,是一种抽象概念划分与真实物理硬件的交叉。


工作内存同步到主内存之间的实现细节,JMM 定义了以下八种操作:



如果要把一个变量从主内存中复制到工作内存中,就需要按顺序地执行 read 和 load 操作,如果把变量从工作内存中同步到主内存中,就需要按顺序地执行 store 和 write 操作。但 Java 内存模型只要求上述操作必须按顺序执行,而没有保证必须是连续执行。



  • 同步规则分析

(1)不允许一个线程无原因地(没有发生过任何 assign 操作)把数据从工作内存同步回主内存中。

(2)一个新的变量只能在主内存中诞生,不允许在工作内存中直接使用一个未被初始化(load 或者 assign)的变量。即就是对一个变量实施 use 和 store 操作之前,必须先自行 assign 和 load 操作。

(3)一个变量在同一时刻只允许一条线程对其进行 lock 操作,但 lock 操作可以被同一线程重复执行多次,多次执行 lock 后,只有执行相同次数的 unlock 操作,变量才会被解锁。lock 和 unlock 必须成对出现。

(4)如果对一个变量执行 lock 操作,将会清空工作内存中此变量的值,在执行引擎使用这个变量之前需要重新执行 load 或 assign 操作初始化变量的值。

(5)如果一个变量事先没有被 lock 操作锁定,则不允许对它执行 unlock 操作;也不允许去 unlock 一个被其他线程锁定的变量。

(6)对一个变量执行 unlock 操作之前,必须先把此变量同步到主内存中(执行 store 和 write 操作)。

2.4 JMM 如何解决多线程并发引起的问题


多线程并发下存在:原子性、可见性、有序性三种问题。


  • 原子性:

问题:原子性指的是一个操作是不可中断的,即使是在多线程环境下,一个操作一旦开始就不会被其他线程影响。但是当线程运行的过程中,由于 CPU 上下文的切换,则线程内的多个操作并不能保证是保持原子执行。

解决:除了 JVM 自身提供的对基本数据类型读写操作的原子性外,可以通过 synchronized 和 Lock 实现原子性。因为 synchronized 和 Lock 能够保证任一时刻只有一个线程访问该代码块。


  • 可见性

问题:之前我们分析过,程序运行的过程中是分工作内存和主内存,工作内存将主内存中的变量拷贝到副本中缓存,假如两个线程同时拷贝一个变量,但是当其中一个线程修改该值,另一个线程是不可见的,这种工作内存和主内存之间的数据同步延迟就会造成可见性问题。另外由于指令重排也会造成可见性的问题。

解决:volatile 关键字保证可见性。当一个共享变量被 volatile 修饰时,它会保证修改的值立即被其他的线程看到,即修改的值立即更新到主存中,当其他线程需要读取时,它会去内存中读取新值。synchronized 和 Lock 也可以保证可见性,因为它们可以保证任一时刻只有一个线程能访问共享资源,并在其释放锁之前将修改的变量刷新到内存中。


有序性

问题:在单线程下我们认为程序是顺序执行的,但是多线程环境下程序被编译成机器码的后可能会出现指令重排的现象,重排后的指令与原指令未必一致,则可能会造成程序结果与预期的不同。

解决:在 Java 里面,可以通过 volatile 关键字来保证一定的有序性。另外可以通过 synchronized 和 Lock 来保证有序性,很显然,synchronized 和 Lock 保证每个时刻是有一个线程执行同步代码,相当于是让线程顺序执行同步代码,自然就保证了有序性。

三、volatile 关键字

3.1 volatile 的作用

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

  • 保证被 volatile 修饰的共享变量对所有线程总数可见,也就是当一个线程修改了一个被 volatile 修饰共享变量的值,新值总是可以被其他线程立即得知

  • 禁止指令重排序优化


3.2 volatile 保证可见性


以下是一段多线程场景下存在可见性问题的程序。


public class VolatileTest extends Thread { private int index = 0; private boolean flag = false; @Override public void run() { while (!flag) { index++; } } public static void main(String[] args) throws Exception { VolatileTest volatileTest = new VolatileTest(); volatileTest.start(); Thread.sleep(1000); // 模拟多次写入,并触发JIT for (int i = 0; i < 10000000; i++) { volatileTest.flag = true; } System.out.println(volatileTest.index); }}
复制代码


运行可以发现,当 volatileTest.index 输出打印之后程序仍然未停止,表示线程依然处于运行状态,子线程读取到的 flag 的值仍为 false。



private volatile boolean flag = false;
复制代码


尝试给 flag 增加 volatile 关键字后程序可以正常结束, 则表示子线程读取到的 flag 值为更新后的 true。


那么为什么 volatile 可以保证可见性呢?


可以尝试在 JDK 中下载 hsdis-amd64.dll 后使用参数-XX:+UnlockDiagnosticVMOptions -XX:+PrintAssembly 运行程序,可以看到程序被翻译后的汇编指令,发现增加 volatile 关键字后给 flag 赋值时汇编指令多了一段 "lock addl $0x0,(%rsp)"



说明 volatile 保证了可见性正是这段 lock 指令起到的作用,查阅 IA-32 手册,可以得知该指令的主要作用:


  • 锁总线,其它 CPU 对内存的读写请求都会被阻塞,直到锁释放,不过实际后来的处理器都采用锁缓存替代锁总线,因为锁总线的开销比较大,锁总线期间其他 CPU 没法访问内存。

  • lock 后的写操作会回写已修改的数据,同时让其它 CPU 相关缓存行失效,从而重新从主存中加载最新的数据。

  • 不是内存屏障却能完成类似内存屏障的功能,阻止屏障两遍的指令重排序。

3.3 volatile 禁止指令重排


Java 语言规范规定 JVM 线程内部维持顺序化语义。即只要程序的最终结果与它顺序化情况的结果相等,那么指令的执行顺序可以与代码顺序不一致,此过程叫指令的重排序。指令重排序的意义是什么?


JVM 能根据处理器特性(CPU 多级缓存系统、多核处理器等)适当的对机器指令进行重排序,使机器指令能更符合 CPU 的执行特性,最大限度的发挥机器性能。


以下是源代码到最终执行的指令集的示例图:



as-if-serial 原则:不管怎么重排序,单线程程序下编译器和处理器不能对存在数据依赖关系的操作做重排序。但是,如果操作之间不存在数据依赖关系,这些操作就可能被编译器和处理器重排序。


下面是一段经典的发生指令重排导致结果预期不符的例子:

public class VolatileTest {     int a, b, x, y;     public boolean test() throws InterruptedException {        a = b = 0;        x = y = 0;        Thread t1 = new Thread(() -> {            a = 1;            x = b;        });        Thread t2 = new Thread(() -> {            b = 1;            y = a;        });        t1.start();        t2.start();        t1.join();        t2.join();         if (x == 0 && y == 0) {            return true;        } else {            return false;        }    }     public static void main(String[] args) throws InterruptedException {        for (int i = 0; ; i++) {            VolatileTest volatileTest = new VolatileTest();            if (volatileTest.test()) {                System.out.println(i);                break;            }        }    }}
复制代码


按照我们正常的逻辑理解,在不出现指令重排的情况下,x、y 永远只会有下面三种情况,不会出现都为 0,即循环永远不会退出。


  1. x = 1、y = 1

  2. x = 1、y = 0

  3. x = 0、y = 1


但是当我们运行的时候会发现一段时间之后循环就会退出,即出现了 x、y 都为 0 的情况,则是因为出现了指令重排,时线程内的对象赋值顺序发生了变化。


而这个问题给参数增加 volatile 关键字即可以解决,此处是因为 JMM 针对重排序问题限制了规则表。



为了实现 volatile 的内存语义,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。一个读的操作为 load,写的操作为 store。


对于编译器来说,发现一个最优布置来最小化插入屏障的总数几乎不可能。为此,JMM 采取保守策略。下面是基于保守策略的 JMM 内存屏障插入策略。


  • 在每个 volatile 写操作的前面插入一个 StoreStore 屏障。

  • 在每个 volatile 写操作的后面插入一个 StoreLoad 屏障。

  • 在每个 volatile 读操作的后面插入一个 LoadLoad 屏障。

  • 在每个 volatile 读操作的后面插入一个 LoadStore 屏障。



以上图为例,普通写与 volatile 写之间会插入一个 StoreStore 屏障,另外有一点需要注意的是,volatile 写后面可能有的 volatile 读/写操作重排序,因为编译器常常无法准确判断是否需要插入 StoreLoad 屏障。


则 JMM 采用了比较保守的策略:在每个 volatile 写的后面插入一个 StoreLoad 屏障。


那么存汇编指令的角度,CPU 是怎么识别到不同的内存屏障的呢:


(1)sfence:实现 Store Barrior 会将 store buffer 中缓存的修改刷入 L1 cache 中,使得其他 cpu 核可以观察到这些修改,而且之后的写操作不会被调度到之前,即 sfence 之前的写操作一定在 sfence 完成且全局可见。


(2)lfence:实现 Load Barrior 会将 invalidate queue 失效,强制读取入 L1 cache 中,而且 lfence 之后的读操作不会被调度到之前,即 lfence 之前的读操作一定在 lfence 完成(并未规定全局可见性)。


(3)mfence:实现 Full Barrior 同时刷新 store buffer 和 invalidate queue,保证了 mfence 前后的读写操作的顺序,同时要求 mfence 之后写操作结果全局可见之前,mfence 之前写操作结果全局可见。


(4)lock:用来修饰当前指令操作的内存只能由当前 CPU 使用,若指令不操作内存仍然由用,因为这个修饰会让指令操作本身原子化,而且自带 Full Barrior 效果。


所以可以发现我们上述分析到的"lock addl"指令也是可以实现内存屏障效果的。


四、volatile 拓展

4.1 滥用 volatile 的危害


经过上述的总结我们可以知道 volatile 的实现是根据 MESI 缓存一致性协议实现的,而这里会用到 CPU 的嗅探机制,需要不断对总线进行内存嗅探,大量的交互会导致总线带宽达到峰值。因此滥用 volatile 可能会引起总线风暴,除了 volatile 之外大量的 CAS 操作也可能会引发这个问题。所以我们使用过程中要视情况而定,适当的场景下可以加锁来保证线程安全。

4.2 如何不用 volatile 不加锁禁止指令重排?


指令重排的示例中我们既然已经知道了插入内存屏障可以解决重排问题,那么用什么方式可以手动插入内存屏障呢?


JDK1.8 之后可以在 Unsafe 魔术类中发现新增了插入屏障的方法。


/** * Ensures lack of reordering of loads before the fence * with loads or stores after the fence. * @since 1.8 */public native void loadFence(); /** * Ensures lack of reordering of stores before the fence * with loads or stores after the fence. * @since 1.8 */public native void storeFence(); /** * Ensures lack of reordering of loads or stores before the fence * with loads or stores after the fence. * @since 1.8 */public native void fullFence();
复制代码


(1)loadFence()表示该方法之前的所有 load 操作在内存屏障之前完成。

(2)storeFence()表示该方法之前的所有 store 操作在内存屏障之前完成。

(3)fullFence()表示该方法之前的所有 load、store 操作在内存屏障之前完成。


可以看到这三个方法正式对应了 CPU 插入内存屏障的三个指令 lfence、sfence、mfence。


因此我们如果想手动添加内存屏障的话,可以用 Unsafe 的这三个 native 方法完成,另外由于 Unsafe 必须由 bootstrap 类加载器加载,所以我们想使用的话需要用反射的方式拿到实例对象。


/** * 反射获取到unsafe */private Unsafe reflectGetUnsafe() throws NoSuchFieldException, IllegalAccessException { Field field = Unsafe.class.getDeclaredField("theUnsafe"); field.setAccessible(true); return (Unsafe) field.get(null);} // 上述示例中手动插入内存屏障Thread t1 = new Thread(() -> { a = 1; // 插入LoadStore()屏障 reflectGetUnsafe().storeFence(); x = b;});Thread t2 = new Thread(() -> { b = 1; // 插入LoadStore()屏障 reflectGetUnsafe().storeFence(); y = a;});
复制代码

4.3 单例模式的双重检查锁为什么需要用 volatile


以下是单例模式双重检查锁的初始化方式:


private volatile static Singleton instance = null; public static Singleton getInstance() { if (instance == null) { synchronized (Singleton.class) { if (instance == null) { instance = new Singleton(); } } } return instance;}
复制代码


那么为什么初始化对象加了双重检查和 synchronized 加锁来保证原子性,可是实例对象 instance 还要用 volatile 修饰呢?


因为 synchronized 虽然加了锁,但是代码块内的程序是无法保证指令重排的,其中 instance = new Singleton(); 方法其实是拆分成多个指令,我们用 javap -c 查看字节码,可以发现这段对象初始化操作是分成了三步:


(1)new :创建对象实例,分配内存空间

(2)invokespecial :调用构造器方法,初始化对象

(3)aload_0 :存入局部方法变量表


以上三步如果顺序执行的话是没问题的,但是如果 2、3 步发生指令重排,则极端并发情况下可能出现下面这种情况:



所以,为了保证单例对象顺利的初始化完成,应该给对象加上 volatile 关键字禁止指令重排。

五、总结


随着计算机和 CPU 的逐步升级,CPU 缓存帮我们大大提高了数据读写的性能,在高并发的场景下,CPU 通过 MESI 缓存一致性协议针对缓存行的失效进行处理。基于 JMM 模型,将用户态和内核态进行了划分,通过 java 提供的关键字和方法可以帮助我们解决原子性、可见性、有序性的问题。其中 volatile 关键字的使用最为广泛,通过添加内存屏障、lock 汇编指令的方式保证了可见性和有序性,在我们开发高并发系统的过程中也要注意 volatile 关键字的使用,但是不能滥用,否则会导致总线风暴。

参考资料

  1. 书籍:《java 并发编程实战》

  2.  IA-32手册

  3. 双重检查锁为什么要使用volatile?

  4.  java内存模型总结

  5. Java 8 Unsafe: xxxFence() instructions


作者:push

发布于: 2021 年 01 月 11 日阅读数: 67
用户头像

官方公众号:vivo互联网技术,ID:vivoVMIC 2020.07.10 加入

分享 vivo 互联网技术干货与沙龙活动,推荐最新行业动态与热门会议。

评论

发布
暂无评论
Java 并发编程之 JMM & volatile 详解