关于后端:一文读懂Java内存模型JMM及volatile关键字

86次阅读

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

什么是 JMM 模型?

Java 内存模型(Java Memory Model 简称 JMM)是一种形象的概念,并不实在存在,它形容的一组规定或标准,通过这组标准定义了程序中各个变量(包含实例字段、动态字段和形成数组对象的元素)的拜访形式。JVM 运行程序的实体是线程,而每个线程创立时 JVM 都会为其创立一个工作内存(有些中央称为栈空间),用于存储线程公有的数据,而 Java 内存模型中规定所有变量都存储在主内存,其主内存是共享内存区域,所有线程都能够拜访,但线程对变量的操作(读取赋值等)必须在工作内存中进行,首先要将变量从主内存考吧到减少的工作内存空间,而后对变量进行操作,操作实现后再将变量写回主内存,不能间接操作主内存中的变量,工作内存中存储这主内存中的变量正本拷贝,工作内存是每个线程的公有数据区域,因而不同的线程间无法访问对方的工作内存,线程间的通信(传值)必须通过主内存来实现。

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

JMM 不同于 JVM 内存区域模式

JMM 与 JVM 内存区域的划分是不同的概念档次,更失当说 JMM 形容的是一组规定,通过这组规定管制各个变量在共享数据区域内和公有数据区域的拜访形式,JMM 是围绕原子性、有序性、可见性开展 。JMM 与 Java 内存区域惟一类似点,都存在共享数据区域和公有数据区域,在 JMM 中主内存属于共享数据区域,从某个水平上讲应该包含了堆和办法区,而工作内存数据线程公有数据区域,从某个水平上讲则应该包含程序计数器、虚拟机栈以及本地办法栈。
线程、工作内存、主内存工作交互图(基于 JMM 标准),如下:

主内存

次要存储的是 Java 实例对象,所有线程创立的实例对象都寄存在主内存中,不论该 实例对象是成员变量还是办法中的本地变量(也称局部变量),当然也包含了共享的类信息、常量、动态变量。因为是共享数据区域,多个线程同一个变量进行拜访可能会发送线程平安问题。

工作内存

次要存储以后办法的所有本地变量信息(工作内存中存储着主内存中的变量正本拷贝),每个线程只能拜访本人的工作内存,即线程中的本地变量对其余线程是不可见的,就算是两个线程执行的是同一段代码,它们也会在各自的工作内存中创立属于以后线程的本地变量,当然也包含了字节码行号指示器、相干 Native 办法的信息。留神因为工作内存是每个线程的公有数据,线程间无奈互相拜访工作内存,因而存储在工作内存的数据不存在线程平安问题。

依据 JVM 虚拟机标准主内存与工作内存的数据存储类型以及操作形式,对于一个实例对象中的成员办法而言,如果办法中包含本地变量是根本数据类型(boolean、type、short、char、int、long、float、double),将间接存储在工作内存的帧栈中,而对象实例将存储在主内存(共享数据区域,堆)中。但对于实例对象的成员变量,不论它是根本数据类型或者包装类型(Integer、Double 等)还是援用类型,都会被存储到堆区。至于 static 变量以及类自身相干信息将会存储在主内存中。
须要留神的是,在主内存中的实例对象能够被多线程共享,假使两个线程同时调用类同一个对象的同一个办法,那么两个线程会将要操作的数据拷贝一份到间接的工作内存中,执行晚操作后才刷新到主内存。模型如下图所示:

Java 内存模型与硬件内存架构的关系

通过对后面的硬件内存架构、Java 内存模型以及 Java 多线程的实现原理的理解,咱们应该曾经意识到,多线程的执行最终都会映射到硬件处理器上进行执行,但 Java 内存模型和硬件内存架构并不完全一致。对于硬件内存来说只有寄存器、缓存内存、主内存的概念,并没有工作内存(线程公有数据区域)和主内存(堆内存)之分,也就是说 Java 内存模型对内存的划分对硬件内存并没有任何影响,因为 JMM 只是一种形象的概念,是一组规定,并不理论存在,不论是工作内存的数据还是主内存的数据,对于计算机硬件来说都会存储在计算机主内存中,当然也有可能存储到 CPU 缓存或者寄存器中,因而总体上来说,Java 内存模型和计算机硬件内存架构是一个互相穿插的关系,是一种抽象概念划分与实在物理硬件的穿插。(留神对于 Java 内存区域划分也是同样的情理)

JMM 存在的必要性

在明确了 Java 内存区域划分、硬件内存架构、Java 多线程的实现原理与 Java 内存模型的具体关系后,接着来谈谈 Java 内存模型存在的必要性。

因为 JVM 运行程序的实体是线程,而每个线程创立时 JVM 都会为其创立一个工作内存(有些中央称为栈空间),用于存储线程公有的数据,线程与主内存中的变量操作必须通过工作内存间接实现,次要过程是将变量从主内存拷贝的每个线程各自的工作内存空间,而后对变量进行操作,操作实现后再将变量写回主内存,如果存在两个线程同时对一个主内存中的实例对象的变量进行操作就有可能诱发线程平安问题。

假如主内存中存在一个共享变量 x,当初有 A 和 B 两个线程别离对该变量 x=1 进行操作,A/ B 线程各自的工作内存中存在共享变量正本 x。假如当初 A 线程想要批改 x 的值为 2,而 B 线程却想要读取 x 的值,那么 B 线程读取到的值是 A 线程更新后的值 2 还是更新钱的值 1 呢?

答案是:不确定。即 B 线程有可能读取到 A 线程更新钱的值 1,也有可能读取到 A 线程更新后的值 2,这是因为工作内存是每个线程公有的数据区域,而线程 A 操作变量 x 时,首先是将变量从主内存拷贝到 A 线程的工作内存中,而后对变量进行操作,操作实现后再将变量 x 写回主内存。而对于 B 线程的也是相似的,这样就有可能造成主内存与工作内存间数据存在一致性问题,假如间接的工作内存中,这样 B 线程读取到的值就是 x=1,然而如果 A 线程已将 x=2 写回主内存后,B 线程才开始读取的话,那么此时 B 线程读取到的就是 x=2,但达到是那种状况先发送呢?

如下图所示案例:

以上对于主内存与工作内存间接的具体交互协定,即一个变量如何从主内存拷贝到工作内存,如何从工作内存同步到主内存之间的实现细节,Java 内存模型定义来以下八种操作来实现。

数据同步八大原子操作

  1. lock(锁定):作用于主内存的变量,把一个变量标记为一个线程独占状态;
  2. unlock(解锁):作用于主内存的变量,把一个处于锁定状态的变量释放出来,开释后的变量才能够被其余线程锁定;
  3. read(读取):作用于主内存的变量,把一个变量值从主内存传输到线程的工作内存中,当前随后的 load 工作应用;
  4. load(载入):作用于工作内存的变量,它把 read 操作从主内存中失去的变量值放入工作内存的变量;
  5. use(应用):作用于工作内存的变量,把工作内存中的一个变量值传递给执行引擎;
  6. assign(赋值):作用于工作内存的变量,它把一个从执行引擎接管到的值赋给工作内存的变量;
  7. store(存储):作用于工作内存的变量,把工作内存中的一个变量的值传送到主内存中,以便随后的 write 的操作;
  8. wirte(写入):作用于工作内存的变量,它把 store 操作从工作内存中的一个变量值传送到主内存的变量中。
  • 如果要把一个变量从主内存中复制到工作内存中,就须要按程序地执行 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 操作)。

原子性

原子性 指的是一个操作不可中断,即便是在多线程环境下,一个操作一旦开始就不会被其余线程影响。
在 Java 中,对于根本数据类型的变量的读取和赋值操作是原子性操作须要留神的是:对于 32 位零碎来说,long 类型数据和 double 类型数据(对于根本类型数据:byte、short、int、float、boolean、char 读写是原子操作),它们的读写并非原子性的,也就是说如果存在两条线程同时对 long 类型或者 double 类型的数据进行读写是存在互相烦扰的,因为对于 32 位虚拟机来说,每次原子读写是 32 位,而 long 和 double 则是 64 位的存储单元,这样回导致一个线程在写时,操作实现前 32 位的原子操作后,轮到 B 线程读取时,恰好只读取来后 32 位的数据,这样可能回读取到一个即非原值又不是线程批改值的变量,它可能是“半个变量”的数值,即 64 位数据被两个线程分成了两次读取。但也不用太放心,因为读取到“半个变量”的状况比拟少,至多在目前的商用虚拟机中,简直都把 64 位的数据的读写操作作为原子操作来执行,因而对于这个问题不用太在意,晓得怎么回事即可。

X=10; // 原子性(简略的读取、将数字赋值给变量) Y = x; // 变量之间的互相赋值,不是原子操作 X++; // 对变量进行计算操作 X=x+1; 

可见性

了解了 指令重排 景象后,可见性容易了解了。可见性指的是当一个线程批改了某个共享变量的值,其余线程是否可能马上得悉这个批改的值。对于串行程序来说,可见性是不存在的,因为咱们在任何一个操作中批改了某个变量的值,后续的操作中都能读取到这个变量,并且是批改过的新值。

但在多线程环境中可就不肯定了,后面咱们剖析过,因为线程对共享变量的操作都是线程拷贝到各自的工作内存进行操作后才写回到主内存中的,这就可能存在一个线程 A 批改了共享变量 x 的值,还未写回主内存时,另外一个线程 B 又对主内存中同一个共享变量 x 进行操作,但此时 A 线程工作内存中共享变量 x 对线程 B 来说并不可见,这种工作内存与主内存同步提早景象就会造成可见性问题,另外指令重排以及编译器优化也可能回导致可见性问题,通过后面的剖析,咱们晓得无论是编译器优化还是处理器优化的重排景象,在多线程环境下,的确回导致程序乱序执行的问题,从而也就导致可见性问题。

有序性

有序性是指对于单线程的执行代码,咱们总是认为代码的执行是按程序顺次执行的,这样的了解并没有故障,比拟对于单线程而言的确如此,但对于多线程环境,则可能呈现乱序景象,因为程序编译称机器码指令后可能回呈现指令重排景象,重排后的指令与原指令的程序未必统一,要明确的是,在 Java 程序中,假使在本线程内,所有操作都视为有序行为,如果是多线程环境下,一个线程中察看另外一个线程,所有操作都是无序的,前半句指的是单线程内保障串行语义执行的一致性,后半句则指令重排景象和工作内存与主内存同步提早景象

JMM 如何解决原子性、可见性和有序性问题

原子性问题

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

可见性问题

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

有序性问题

在 Java 外面,能够通过 volatile 关键字来保障肯定的“有序性”。另外能够通过 synchronized 和 Lock 来保障有序性,很显然,synchronized 和 Lock 保障每个时刻是只有一个线程执行同步代码,相当于是让线程程序执行同步代码,天然就保障来有序性。

Java 内存模型

每个线程都有本人的工作内存,线程对变量的所有操作都必须在工作内存中进行,而不能间接对主内存进行操作。并且每个线程不能拜访其余线程的工作内存。Java 内存模型具备一些先天的“有序性”,即不须要通过任何伎俩就可能失去保障的有序性,这个通常也称为 happens-before 准则。如果两个操作的执行秩序无奈从 happens-before 准则推导进去,那么它们就不能保障它们的有序性,虚拟机能够随便地对它们进行重排序。

指令重排序

Java 语言标准规定 JVM 线程外部维持程序化语义。即只有程序的最终后果与它程序化状况的后果相等,那么指令的执行程序能够与代码程序不统一,此过程叫做指令的重排序。
指令重排序的意义是什么?JVM 能依据解决个性(CPU 多级缓存、多核处理器等)适当的对机器指令进行重排序,使机器指令更更合乎 CPU 的执行个性,最大限度的施展机器性能。

下图为从源码到最终执行的指令序列示意图:

as-if-serial 语义

as-if-serial 语义的意思是:不管怎么重排序(编译器和处理器为了进步并行度),(单线程)程序的执行后果不能被扭转。编译器、runtime 和处理器都必须恪守 as-if-serial 语义。
为了恪守 as-if-serial 语义,编译器和处理器不会对存在数据依赖关系的操作做重排序,因为这种重排序会扭转执行后果。然而,如果操作之间不存在数据依赖关系,这些操作就可能被编译器和处理器重排序。

happens-before 准则

只靠 synchronized 和 volatile 关键字来保障原子性、可见性以及有序性,那么编写并发程序可能会显得非常麻烦,侥幸的是,从 JDK 5 开始,Java 应用新的 JSR-133 内存模型,提供了 happens-before 准则 来辅助保障程序执行的原子性、可见性和有序性的问题,它是判断数据非常存在竞争、线程非常平安的一句。happens-before 准则内容如下:

  1. 程序程序准则,即在一个线程内必须保障语义串行,也就是说依照代码程序执行。
  2. 锁规定,解锁(unlock)操作必然产生在后续的同一个锁的加锁(lock)之前,也就是说,如果对于一个锁解锁后,再加锁,那么加锁的动作必须在解锁动作之后(同一个锁)。
  3. volatile 规定,volatile 变量的写,先产生于读,这保障了 volatile 变量的可见性,简略了解就是,volatile 变量在每次被线程拜访时,都强制从主内存中读该变量的值,而当该变量发生变化时,又会强制将最新的值刷新到主内存,任何时刻,不同的线程总是可能看到该变量的最新值。
  4. 线程启动规定,线程的 start() 办法先于它的每一个动作,即如果线程 A 在执行线程 B 的 start 办法之前批改了共享变量的值,那么当线程 B 执行 start 办法时,线程 A 对共享变量的批改对线程 B 可见。
  5. 传递性,A 先于 B,B 先于 C,那么 A 必然先于 C。
  6. 线程终止准则,线程的所有操作先于线程的终结,Thread.join() 办法的作用是期待以后执行的线程终止。假如在线程 B 终止之前,批改了共享变量,线程 A 从线程 B 的 join 办法胜利返回,线程 B 对共享变量的批改将对线程 A 可见。
  7. 线程中断规定,对线程 interrupt() 办法的调用后行产生于被中断线程的代码查看到中断事件的产生,能够通过 Thread.interrupted() 办法检测线程非常中断。
  8. 对象终结规定,对象的构造函数执行,完结先于 finalize() 办法。

finalize()是 Object 中的办法,当垃圾回收器将要回收对象所占内存之前被调用,即当一个对象被虚拟机宣告死亡时会先调用它 finalize()办法,让此对象解决它生前的最初事件(这个对象能够趁这个机会挣脱死亡的命运)。

volatile 内存语义

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

  1. 保障被 volatile 润饰的共享变量对所有线程总是可见的,也就是当一个线程批改了被 volatile 润饰共享变量的值,新值总是能够被其余线程立刻得悉。
  2. 缓和指令重排序优化。

volatile 的可见性

对于 volatile 的可见性作用,咱们必须意思到被 volatile 润饰的变量对所有线程总是立刻可见的,对于 volatile 变量的所有写操作总是能立即反馈到其余线程中。
案例:线程 A 扭转 initFlag 属性之后,线程 B 马上感知到

package com.niuh.jmm;

import lombok.extern.slf4j.Slf4j;

/**
 * @description: -server -Xcomp -XX:+UnlockDiagnosticVMOptions -XX:+PrintAssembly -XX:CompileCommand=compileonly,*Jmm03_CodeVisibility.refresh
 * -Djava.compiler=NONE
 **/
@Slf4j
public class Jmm03_CodeVisibility {

    private static boolean initFlag = false;

    private volatile static int counter = 0;

    public static void refresh() {log.info("refresh data.......");
        initFlag = true;
        log.info("refresh data success.......");
    }

    public static void main(String[] args) {
        // 线程 A
        Thread threadA = new Thread(() -> {while (!initFlag) {//System.out.println("runing");
                counter++;
            }
            log.info("线程:" + Thread.currentThread().getName()
                    + "以后线程嗅探到 initFlag 的状态的扭转");
        }, "threadA");
        threadA.start();

        // 两头休眠 500hs
        try {Thread.sleep(500);
        } catch (InterruptedException e) {e.printStackTrace();
        }

        // 线程 B
        Thread threadB = new Thread(() -> {refresh();
        }, "threadB");
        threadB.start();}
}

联合后面介绍的数据同步八大原子操作,咱们来剖析下:
线程 A 启动后

  • 第一步:执行 read 操作,作用于主内存 ,将变量initFlag 从主内存拷贝一份,这时候还没有放到工作内存中,而是放在了总线里。如下图
  • 第二步:执行 load 操作,作用于工作内存,将上一步拷贝的变量,放入工作内存中;
  • 第三步:执行 use(应用)操作,作用于工作内存 ,把工作内存中的变量传递给执行引擎,对于线程 A 来说,执行引擎会判断initFlag = true 吗?不等于,循环始终进行

执行过程如下图:

线程 B 启动后

  • 第一步:执行 read 操作,作用于主内存 ,从主内存拷贝initFlag 变量,这时候拷贝的变量还没有放到工作内存中,这一步是为了 load 做筹备;
  • 第二步:执行 load 操作,作用于工作内存,将拷贝的变量放入到工作内存中;
  • 第三步:执行 use 操作,作用于工作内存,将工作内存的变量传递给执行引擎,执行引擎判断while(!initFlag), 那么执行循环体;
  • 第四步:执行 assign 操作,作用于工作内存,把从执行引擎接管的值赋值给工作内存的变量,即设置 inifFlag = true ;
  • 第五步:执行 store 操作,作用于工作内存,将工作内存中的变量 initFlag = true 传递给主内存;
  • 第六步:执行 write 操作,作用于工作内存,将变量写入到主内存中。

volatile 无奈保障原子性

// 示例
public class VolatileVisibility {
    public static volatile int i =0;
    public static void increase(){i++;}
} 

在并发场景下,i 变量的任何扭转都会立马反馈到其余线程中,然而如此存在多线程同时调用 increase() 办法的化,就会呈现线程平安问题,毕竟 i++ 操作并不具备原子性,该操作是先读取值,而后写回一个新值,相当于原来的值加上 1,分两部实现。如果第二个线程在第一个线程读取旧值和写回新值期间读取 i 的值,那么第二个线程就会于第一个线程一起看到同一个值,并执行雷同值的加 1 操作,这也就造成了线程平安失败,因而对于 increase 办法必须应用 synchronized 润饰,以便保障线程平安,须要留神的是一旦应用 synchronized 润饰办法后,因为 sunchronized 自身也具备于 volatile 雷同的个性,即可见性,因而在这样的状况下就齐全能够省去 volatile 润饰变量。
案例:起了 10 个线程,每个线程加到 1000,10 个线程,一共是 10000

package com.niuh.jmm;

/**
 * volatile 能够保障可见性, 不能保障原子性
 */
public class Jmm04_CodeAtomic {

    private volatile static int counter = 0;
    static Object object = new Object();

    public static void main(String[] args) {for (int i = 0; i < 10; i++) {Thread thread = new Thread(() -> {for (int j = 0; j < 1000; j++) {synchronized (object) {counter++;// 分三步 - 读,自加,写回}
                }
            });
            thread.start();}

        try {Thread.sleep(3000);
        } catch (InterruptedException e) {e.printStackTrace();
        }

        System.out.println(counter);

    }
} 

而理论后果, 不到 10000, 起因是: 有并发操作. 这时候, 如果我在 counter 上加关键字 volatile, 能够保障原子性么?

private volatile static int counter = 0; 

咱们发现, 仍然不是 10000, 这阐明 volatile 不能保障原子性.

每个线程, 只有一个操作, counter++, 为什么不能保障原子性呢?

其实 counter++ 不是一步实现的. 他是分为多步实现的. 咱们用上面的图来解释

线程 A 通过 read, load 将变量加载到工作内存, 通过 user 将变量发送到执行引擎, 执行引擎执行 counter++,这时线程 B 启动了, 通过 read, load 将变量加载到工作内存, 通过 user 将变量发送到执行引擎, 而后执行复制操作 assign, stroe, write 操作. 咱们看到这是通过了 n 个步骤. 尽管看起来就是简略的一句话.
当线程 B 执行 store 将数据回传到主内存的时候, 同时会告诉线程 A, 抛弃 counter++, 而这时 counter 曾经自加了 1, 将自加后的 counter 丢掉, 就导致总数据少 1.

volatile 禁止重排优化

volatile 关键字另一个作用就是禁止指令重排优化,从而防止多线程环境下程序呈现乱序执行的景象,对于指令重排优化后面曾经剖析过,这里次要简略阐明一下 volatile 是如何实现禁止指令重排优化的。先理解一个概念,内存屏障(Memory Barrier)

硬件层的内存屏障

Intel 硬件提供了一系列的内存屏障,次要又:

  1. ifence,是一种 Load Barrier 读屏障;
  2. sfence,是一种 Store Barrier 写屏障;
  3. mfence,是一种全能型的屏障,具备 ifence 和 sfence 的能力;
  4. Lock 前缀,Lock 不是一种内存屏障,然而它能实现相似内存屏障的性能。Lock 会对 CPU 总线和高速缓存加锁,能够了解为 CPU 指令级的一种锁。它前面能够跟 ADD、ADC、AND、BTC、BTR、BTS、CMPXCHG、CMPXCH8B、DEC、INC、NEG、NOT、OR、SBB、SUB、XOR、XADD、and XCHG 等指令。

JVM 的内存屏障

不同硬件实现内存屏障的形式不同,Java 内存模型屏蔽了这些底层硬件平台的差别,由 JVM 来为不同平台生产相应的机器码。JVM 中提供了四类内存屏障指令:

内存屏障,又称 内存栅栏,是一个 CPU 指令,它的作用有两个:

  1. 一是保障特定操作的执行程序;
  2. 二是保障某些变量的内存可见性(利用该个性实现 volatile 的内存可见性)。

因为编译器和处理器都能执行指令重排优化。如果在指令间插入一条 Memory Barrier 则会高速编译器和 CPU,不论什么指令都不能和这条 Memory Barrier 指令重排序,也就是说通过插入内存屏障禁止在内存屏障前后的指令执行重排序优化。
Memory Barrier 的另外一个作用是强制刷出各种 CPU 的缓存数据,因而任何 CPU 上的线程都能读取到这些数据的最新版本。
总之,volatile 变量正是通过内存屏障实现其内存中的语义,即可见性和禁止重排优化。
上面看一个十分典型的禁止重排优化的例子 DCL,如下:

public class DoubleCheckLock {
    private volatile static DoubleCheckLock instance;
    private DoubleCheckLock(){}
    public static DoubleCheckLock getInstance(){
        // 第一次检测
        if (instance==null){
            // 同步
            synchronized (DoubleCheckLock.class){if (instance == null){
                    // 多线程环境下可能会呈现问题的中央
                    instance = new  DoubleCheckLock();}
            }
        }
        return instance;
    }
}

上述代码一个经典的单例的双重检测的代码,这段代码在单线程环境下并没什么问题,但如果在多线程环境下就可能会呈现线程平安的问题。因为在于某一线程执行到第一次检测,读取到 instance 不为 null 时,instance 的援用对象可能还没有实现初始化。

因为instance = new DoubleCheckLock(); 能够分为以下 3 步实现(伪代码)

memory = allocate(); // 1. 调配对象内存空间
instance(memory); // 2. 初始化对象
instance = memory; // 3. 设置 instance 指向刚调配的内存地址,此时 instance != null 

因为步骤 1 和步骤 2 间可能会重排序,如下:

memory=allocate();//1. 调配对象内存空间
instance=memory;//3. 设置 instance 指向刚调配的内存地址,此时 instance!=null,然而对象还没有初始化实现!instance(memory);//2. 初始化对象 

因为步骤 2 和步骤 3 不存在数据依赖关系,而且无论重排前还是重排后程序的指向后果在单线程中并没有扭转,因而这种重排优化是容许的。然而指令重排只会保障串行语义执行的一致性(单线程),但并不会关怀多线程间的语义一致性。所以当一条线程拜访 instance 不为 null 时,因为 instance 实例未必曾经初始化实现,也就造成来线程平安问题。那么该如何解决呢,很简略,咱们应用 volatile 禁止 instance 变量被执行指令重排优化即可。

// 禁止指令重排优化
private volatile static DoubleCheckLock instance; 

volatile 内存语义的实现

后面提到过重排序分为 编译器重排序 处理器重排序。为来实现 volatile 内存语义,JMM 会别离限度这两种类型的重排序类型。

上面是 JMM 针对编译器制订的 volatile 重排序规定表。

举例来说,第二行最初一个单元格的意思是:在程序中,当第一个操作为一般变量的读或者写时,如果第二个操作为 volatile 写,则编译器不能重排序这两个操作。
从上图能够看出:

  • 当第二个操作是 volatile 写时,不论第一个操作是什么,都不能重排序。这个规定确保了 volatile 写之前的操作不会被编译器重排序到 volatile 写之后。
  • 当第一个操作是 volatile 读时,不论第二个操作是什么,都不能重排序。这个规定确保了 volatile 读之后的操作不会被编译器重排序到 volatie 读之前。
  • 当第一个操作是 volatile 写,第二个操作是 volatile 读或写时,不能重排序。

为了实现 volatile 的内存语义,编译在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。对于编译器来说,发现一个最优安排来最小化插入屏障的总数简直不可能。为此,JMM 采取激进策略。上面是基于激进策略的 JMM 内存屏障插入策略。

  • 在每个 volatile 写操作的后面插入一个 StoreStore 屏障;
  • 在每个 volatile 写操作的前面插入一个 StoreLoad 屏障;
  • 在每个 volatile 读操作的前面插入一个 LoadLoad 屏障;
  • 在每个 volatile 读操作的前面插入一个 LoadStore 屏障;

上述内存屏障插入策略十分激进,但它能够保障在任一处理器平台,任意的程序中都能失去正确的 volatile 内存语义。

上面是激进策略下,volatile 写插入 内存屏障后生成的指令序列示意图

上图中 StoreStore 屏障能够保障在 volatile 写之前,其后面的所有一般写操作曾经对任意处理器可见来。这是因为 StoreStore 屏障将保障下面所有的一般写在 volatile 写之前刷新到主内存。
这里比拟有意思的是,volatile 写前面的 StoreLoad 屏障。此屏障的作用是防止 volatile 写与前面可能有的 volatile 读 / 写操作重排序。因为编译器经常无奈精确判断在一个 volatile 写的前面非常须要插入一个 StoreLoad 屏障(比方,一个 volatile 写之后办法立刻 return)。为来保障能正确实现 volatile 的内存语义,JMM 在采取了激进策略:在每个 volatile 写的前面,或者每个 volatile 读的后面插入一个 StoreLoad 屏障。从整体执行效率的角度思考,JMM 最终抉择了在每个 volatile 写的前面插入一个 StoreLoad 屏障,因为 volatile 写 - 读内存语义的常见应用模式是:一个写线程写 volatile 变量,多个线程读同一个 volatile 变量。当读线程的数量大大超过写线程时,抉择在 volatile 写之后插入 StoreLoad 屏障将带来可观的执行效率的晋升。从这里能够看到 JMM 在实现上的一个特点:首先确保正确性,而后再去谋求执行效率。

下图是在激进策略下,volatile 读插入 内存屏障后生成的指令序列示意图

上图中 LoadLoad 屏障用来禁止处理器把下面的 volatile 读 与上面的一般读重排序。LoadStore 屏障用来禁止处理器把下面的 volatile 读与上面的一般写重排序。
上述 volatile 写 和 volatile 读的内存屏障插入策略十分激进。在理论执行时,只有不扭转 volatile 写 - 读的内存语义,编译器能够依据具体情况省略不必要的屏障。
上面通过具体的示例代码进行阐明。

class VolatileBarrierExample {
       int a;
       volatile int v1 = 1;
       volatile int v2 = 2;
       void readAndWrite() {
           int i = v1;      // 第一个 volatile 读
           int j = v2;       // 第二个 volatile 读
           a = i + j;         // 一般写
           v1 = i + 1;       // 第一个 volatile 写
           v2 = j * 2;       // 第二个 volatile 写
       }
} 

针对readAndWrite() 办法,编译器在生成字节码时能够做如下的优化。

留神,最初的 StoreLoad 屏障不能省略。因为第二个 volatile 写之后,办法立刻 return。此时编译器可能无奈精确判断判定前面非常会有 volatile 读或写,为了平安起见,编译器通常会在这里插入一个 StoreLoad 屏障。
下面的优化针对任意处理器平台,因为不同的处理器有不同“松紧度”的处理器内存模型,内存屏障的插入还能够依据具体的处理器内存模型持续优化。以 X86 解决完为例,上图中除最初的 StoreLoad 屏障外,其余的屏障都会被省略。
后面激进策略下的 volatile 读和写,在 X86 处理器平台能够优化如下图所示。X86 处理器仅会对读 - 写操作做重排序。X86 不会对读 - 读、读 - 写 和 写 - 写 做重排序,因而在 X86 处理器中会省略掉这 3 种操作类型对应的内存屏障。在 X86 中,JMM 仅需在 volatile 写前面插入一个 StoreLoad 屏障即可正确实现 volatile 写 - 读的内存语义,这意味着在 X86 处理器中,volatile 写的开销比 volatile 读的开销会大很多(因为执行 StoreLoad 的屏障开销会比拟大)。

参考资料

  • 《并发编程的艺术》

正文完
 0