本文从计算机模型开始,以及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...