Java 并发编程之 JMM & volatile 详解

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

1、现代计算机理论模型与工做原理

1.1 冯诺依曼计算机模型

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

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

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

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

  • 五大核心组成部分:
  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的值的话须要从新从主内存中读取。

2、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保证每一个时刻是有一个线程执行同步代码,至关因而让线程顺序执行同步代码,天然就保证了有序性。

3、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 = 一、y = 1
  2. x = 一、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"指令也是能够实现内存屏障效果的。

4、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 :存入局部方法变量表

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

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

5、总结

随着计算机和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
相关文章
相关标签/搜索