关于java:Java-并发编程之-JMM-volatile-详解

10次阅读

共计 10420 个字符,预计需要花费 27 分钟才能阅读完成。

本文从计算机模型开始,以及 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 = new Singleton(); 办法其实是拆分成多个指令,咱们用 javap -c 查看字节码,能够发现这段对象初始化操作是分成了三步:

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

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

(3)aload_0:存入部分办法变量表

以上三步如果程序执行的话是没问题的,然而如果 2、3 步产生指令重排,则极其并发状况下可能呈现上面这种状况:

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

五、总结

随着计算机和 CPU 的逐渐降级,CPU 缓存帮咱们大大提高了数据读写的性能,在高并发的场景下,CPU 通过 MESI 缓存一致性协定针对缓存行的生效进行解决。基于 JMM 模型,将用户态和内核态进行了划分,通过 java 提供的关键字和办法能够帮忙咱们解决原子性、可见性、有序性的问题。其中 volatile 关键字的应用最为宽泛,通过增加内存屏障、lock 汇编指令的形式保障了可见性和有序性,在咱们开发高并发零碎的过程中也要留神 volatile 关键字的应用,然而不能滥用,否则会导致总线风暴。

参考:《2020 最新 Java 根底精讲视频教程和学习路线!》

链接:https://juejin.cn/post/691633…

正文完
 0