想当皇帝小妾,先搞懂java内存模型 ☞— JMM(笔记)


JMM

\color{#34a853}{JMM}(Java Memory Model——Java内存模型)。什么是JMM呢?JMM是一个抽象概念,它并不存在。Java虚拟机规范中试图定义一种Java内存模型(JMM)来屏蔽掉各类硬件和操做系统的内存访问差别,以实现让Java程序在各类平台下都能达到一致的内存访问效果。在此以前,主流程序语言(如C/C++等)直接使用物理硬件和操做系统的内存模型,所以,会因为不一样平台的内存模型的差别,有可能致使程序在一套平台上并发彻底正常,而在另外一套平台上并发访问却常常出错,所以在某些场景就必须针对不一样的平台来编写程序。html

\color{#34a853}{Java线程}之间的通讯由JMM来控制,JMM决定一个线程共享变量的写入什么时候对另外一个线程可见。JMM保证若是程序是正确同步的,那么程序的执行将具备顺序一致性。从抽象的角度看,JMM定义了线程和主内存之间的抽象关系:线程之间的共享变量(实例域、静态域和数据元素)存储在主内存(Main Memory)中,每一个线程都有一个私有的本地内存(Local Memory),本地内存中存储了该线程以读/写共享变量的副本(局部变量、方法定义参数和异常处理参数是不会在线程之间共享,它们存储在线程的本地内存中)。从物理角度上看,主内存仅仅是虚拟机内存的一部分,与物理硬件的主内存名字同样,二者能够互相类比;而本地内存,可与处理器高速缓存类比。Java内存模型的抽象示意图如图所示: 程序员

这里介绍七个基础概念: \color{#4285f4}{8种操做指令、}\color{#ea4335}{内存屏障、}\color{#fbbc05}{顺序一致性模型、}\color{#4285f4}{as-if-serial、}\color{#34a853}{happens-before、}\color{#ea4335}{数据依赖性、}\color{purple}{重排序。}

8种操做指令

关于主内存与本地内存之间具体的交互协议,即一个变量如何从主内存拷贝到本地内存、如何从本地内存同步回主内存之类的实现细节,JMM中定义了如下8种操做来完成,虚拟机实现时必须保证下面说起的每种操做都是原子的、不可再分的(对于double和long类型的遍从来说,load、store、read和write操做在某些平台上容许有例外):面试

  • \color{#34a853}{lock}(锁定):做用于主内存的变量,它把一个变量标识为一条线程独立的状态。
  • \color{#34a853}{unlock}(解锁):做用于主内存的变量,它把一个处于锁定状态的变量释放出来,释放后的变量才能够被其余线程锁定。
  • \color{#34a853}{read}(读取):做用于主内存的变量,它把一个变量的值从主内存传输到线程的本地内存中,以便随后的load动做使用。
  • \color{#34a853}{load}(载入):做用于本地内存的变量,它把read操做从主内存中获得变量值放入本地内存的变量副本中。
  • \color{#34a853}{use}(使用):做用于本地内存的变量,它把本地内存中一个变量的值传递给执行引擎,每当虚拟机遇到一个须要使用到变量的值的字节码指令时将会执行这个操做。
  • \color{#34a853}{assign}(赋值):做用于本地内存的变量,它把一个从执行引擎接收到的值赋给本地内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操做。
  • \color{#34a853}{store}(存储):做用于本地内存的变量,它把本地内存中的一个变量的值传送到主内存中,以便随后的write操做使用。
  • \color{#34a853}{write}(写入):做用于主内存的变量,它把store操做从本地内存中提到的变量的值放入到主内存的变量中。

\color{#34a853}{☞}若是要把一个变量从主内存模型复制到本地内存,那就要顺序的执行read和load操做,若是要把变量从本地内存同步回主内存,就要顺序的执行store和write操做。注意,Java内存模型只要求上述两个操做必须按顺序执行,而没有保证是连续执行。也就是说read与load之间、store与write之间是可插入其余指令的,如对主内存中的变量a、b进行访问时,一种可能出现的顺序是read a read b、load b、load a。编程

内存屏障

内存屏障是一组处理器指令(前面的8个操做指令),用于实现对内存操做的顺序限制。包括LoadLoad, LoadStore, StoreLoad, StoreStore共4种内存屏障。内存屏障存在的意义是什么呢?它是在Java编译器生成指令序列的适当位置插入内存屏障指令来禁止特定类型的处理器重排序,从而让程序按咱们预想的流程去执行,内存屏障是与相应的内存重排序相对应的。JMM把内存屏障指令分为4类:缓存

StoreLoad Barriers是一个“全能型”的屏障,它同时具备其余3个屏障的效果。如今的多数处理器大多支持该屏障(其余类型的屏障不必定被全部处理器支持)。执行该屏障开销会很昂贵,由于当前处理器一般要把写缓冲区中的数据所有刷新到内存中。

数据依赖性

若是两个操做访问同一个变量,且这两个操做中有一个为写操做,此时这两个操做之间就存在数据依赖性。数据依赖性分3种类型:写后读、写后写、读后写。这3种状况,只要重排序两个操做的执行顺序,程序的执行结果就会被改变。编译器和处理器可能对操做进行重排序。而它们进行重排序时,会遵照数据依赖性,不会改变数据依赖关系的两个操做的执行顺序。bash

名称 代码 示例说明
写后读 a = 1;b = a; 写一个变量以后,再读这个位置。
写后写 a = 1;a = 2; 写一个变量以后,再写这个变量。
读后写 a = b;b = 1; 读一个变量以后,再写这个变量。

这里所说的数据依赖性仅针对单个处理器中执行的指令序列和单个线程中执行的操做,不一样处理器之间和不一样线程之间的数据依赖性不被编译器和处理器考虑。多线程

顺序一致性内存模型

顺序一致性内存模型是一个理论参考模型,在设计的时候,处理器的内存模型和编程语言的内存模型都会以顺序一致性内存模型做为参照。它有两个特性:并发

  • 一个线程中的全部操做必须按照程序的顺序来执行
  • (无论程序是否同步)全部线程都只能看到一个单一的操做执行顺序。在顺序一致性的内存模型中,每一个操做必须原子执行而且马上对全部线程可见。

从顺序一致性模型中,咱们能够知道程序全部操做彻底按照程序的顺序串行执行。而在JMM中,临界区内的代码能够重排序(但JMM不容许临界区内的代码“逸出”到临界区外,那样就破坏监视器的语义)。app

假设这两个线程使用监视器锁来正确同步:A线程的3个操做执行后释放监视器锁,随后B线程获取同一个监视器锁。 编程语言

假设这两个线程没有作同步:

JMM会在退出临界区和进入临界区这两个关键时间点作一些特别处理,使得线程在这两个时间点具备与顺序一致性模型相同的内存视图。虽然线程A在临界区内作了重排序,但因为监视器互斥执行的特性,这里的线程B根本没法“观察”到线程A在临界区内的重排序。这种重排序既提升了执行效率,又没有改变程序的执行结果。像单例模型[静态内部类模型]的类初始化解决方案就是采用了这个思想。

as-if-serial

as-if-serial的意思是无论怎么重排序,(单线程)程序的执行结果不能改变。编译器、runtime和处理器都必须遵照as-if-serial语义。为了遵照as-if-serial语义,编译器和处理器不会对存在数据依赖关系的操做作重排序。

as-if-serial语义把单线程程序保护了起来,遵照as-if-serial语义的编译器、runtime和处理器共同为编写单线程程序的程序员建立了一个幻觉:单线程程序是按程序的顺序来执行的。as-if-serial语义使单线程程序员无需担忧重排序会干扰他们,也无需担忧内存可见性问题。

happens-before

happens-before是JMM最核心的概念。从JDK5开始,Java使用新的JSR-133内存模型,JSR-133 使用happens-before的概念阐述操做之间的内存可见性,若是一个操做执行的结果须要对另外一个操做可见,那么这两个操做之间必须存在happens-before关系。

happens-before规则以下:

  • 程序次序法则:线程中的每一个动做 A 都 happens-before 于该线程中的每个动做 B,其中,在程序中,全部的动做 B 都出如今动做 A 以后。(注:此法则只是要求遵循 as-if-serial语义)
  • 监视器锁法则:对一个监视器锁的解锁 happens-before 于每个后续对同一监视器锁的加锁。(显式锁的加锁和解锁有着与内置锁,即监视器锁相同的存储语意。)
  • volatile变量法则:对 volatile 域的写入操做 happens-before 于每个后续对同一域的读操做。(原子变量的读写操做有着与 volatile 变量相同的语意。)(volatile变量具备可见性和读写原子性。)
  • 线程启动法则:在一个线程里,对 Thread.start 的调用会 happens-before 于每个启动线程中的动做。
  • 线程终止法则:线程中的任何动做都 happens-before 于其余线程检测到这个线程已终结,或者从 Thread.join 方法调用中成功返回,或者 Thread.isAlive 方法返回false。
  • 中断法则法则:一个线程调用另外一个线程的 interrupt 方法 happens-before 于被中断线程发现中断(经过抛出InterruptedException, 或者调用 isInterrupted 方法和 interrupted 方法)。
  • 终结法则:一个对象的构造函数的结束 happens-before 于这个对象 finalizer 开始。
  • 传递性:若是 A happens-before 于 B,且 B happens-before 于 C,则 A happens-before 于 C。 happens-before与JMM的关系以下图所示:

as-if-serial语义和happens-before本质上同样,参考顺序一致性内存模型的理论,在不改变程序执行结果的前提下,给编译器和处理器以最大的自由度,提升并行度。

重排序

终于谈到咱们反复说起的重排序了,重排序是指编译器和处理器为了优化程序性能而对指令序列进行从新排序的一种手段。重排序分3种类型。

  • 编译器优化的重排序。编译器在不改变单线程程序语义(as-if-serial )的前提下,能够从新安排语句的执行顺序。
  • 指令级并行的重排序。现代处理器采用了指令级并行技术(Instruction Level Parallelism,ILP)来将多条指令重叠执行。若是不存在数据依赖性,处理器能够改变语句对机器指令的执行顺序。
  • 内存系统的重排序。因为处理器使用缓存和读/写缓冲区,这使得加载和存储操做看上去多是在乱序执行。 从Java源代码到最终实际执行的指令序列,会分别经历下面3种重排序

上述的1属于编译器重排序,2和3属于处理器重排序。这些重排序可能会致使多线程程序出现内存可见性问题。对于编译器,JMM的编译器重排序规则会禁止特定类型的编译器重排序(不是全部的编译器重排序都要禁止)。对于处理器重排序,JMM的处理器重排序规则会要求Java编译器在生成指令序列时,插入特定类型的内存屏障指令,经过内存屏障指令来禁止特定类型的处理器重排序。

JMM属于语言级的内存模型,它确保在不一样的编译器和不一样的处理器平台之上,经过禁止特定类型的编译器重排序和处理器重排序,为程序员提供一致的内存可见性保证。

从JMM设计者的角度来讲,在设计JMM时,须要考虑两个关键因素:

  • 程序员对内存模型的使用。程序员但愿内存模型易于理解,易于编程。程序员但愿基于一个强内存模型(程序尽量的顺序执行)来编写代码。
  • 编译器和处理器对内存模型的实现。编译器和处理器但愿内存模型对它们的束缚越少越好,这样它们就能够作尽量多的优化(对程序重排序,作尽量多的并发)来提升性能。编译器和处理器但愿实现一个弱内存模型。

JMM设计就须要在这二者之间做出协调。JMM对程序采起了不一样的策略:

  • 对于会改变程序执行结果的重排序,JMM要求编译器和处理器必须禁止这种重排序。
  • 对于不会改变程序执行结果的重排序,JMM对编译器和处理器不做要求(JMM容许这种重排序)。

介绍完了这几个基本概念,咱们不难推断出JMM是围绕着在并发过程当中如何处理原子性、可见性和有序性这三个特征来创建的。

  • 原子性:由Java内存模型来直接保证的原子性操做就是咱们前面介绍的8个原子操做指令,其中lock(lock指令实际在处理器上原子操做体现对总线加锁或对缓存加锁)和unlock指令操做JVM并未直接开放给用户使用,可是却提供了更高层次的字节码指令monitorenter和monitorexit来隐式使用这两个操做,这两个字节码指令反映到Java代码中就是同步块——synchronize关键字,所以在synchronized块之间的操做也具有原子性。除了synchronize,在Java中另外一个实现原子操做的重要方式是自旋CAS,它是利用处理器提供的cmpxchg指令实现的。至于自旋CAS后面J.U.C中会详细介绍,它和volatile是整个J.U.C底层实现的核心。
  • 可见性:可见性是指一个线程修改了共享变量的值,其余线程可以当即得知这个修改。而咱们上文谈的happens-before原则禁止某些处理器和编译器的重排序,来保证了JMM的可见性。而体如今程序上,实现可见性的关键字包含了volatile、synchronize和final。
  • 有序性:谈到有序性就涉及到前面说的重排序和顺序一致性内存模型。咱们也都知道了as-if-serial是针对单线程程序有序的,即便存在重排序,可是最终程序结果仍是不变的,而多线程程序的有序性则体如今JMM经过插入内存屏障指令,禁止了特定类型处理器的重排序。

经过前面8个操做指令和happens-before原则介绍,也不难推断出,volatile和synchronized两个关键字来保证线程之间的有序性,volatile自己就包含了禁止指令重排序的语义,而synchronized则是由监视器法则得到。

JUC

也许你对volatile和CAS的底层实现原理不是很了解,这里简单介绍下它们的底层实现:

volatile

Java语言规范第三版对volatile的定义为:Java编程语言容许线程访问共享变量,为了确保共享变量能被准确和一致性的更新,线程应该确保经过排他锁单独得到这个变量。若是一个字段被声明为volatile,Java内存模型确保这个全部线程看到这个值的变量是一致的。

而volatile是如何来保证可见性的呢?若是对声明了volatile的变量进行写操做,JVM就会向处理器发送一条Lock前缀的指令,将这个变量所在缓存行的数据写回到系统内存(Lock指令会在声言该信号期间锁总线/缓存,这样就独占了系统内存)。

可是,就算是写回到内存,若是其余处理器缓存的值仍是旧的,再执行计算操做就会有问题。因此,在多处理器下,为了保证各个处理器的缓存是一致的,就会实现缓存一致性协议,每一个处理器经过嗅探在总线(注意处理器不直接跟系统内存交互,而是经过总线)上传播的数据来检查本身缓存的值是否是过时了,当处理器发现直接缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置成无效状态,当处理器对这个数据进行修改操做的时候,会从新从系统内存中把数据读处处理器缓存里。

CAS

CAS其实应用挺普遍的,咱们经常听到的悲观锁乐观锁的概念,乐观锁(无锁)指的就是CAS。

这里只是简单说下在并发的应用,所谓的乐观并发策略,通俗的说,就是先进性操做,若是没有其余线程争用共享数据,那操做就成功了,若是共享数据有争用,产生了冲突,那就采起其余的补偿措施(最多见的补偿措施就是不断重试,治到成功为止,这里其实也就是自旋CAS的概念),这种乐观的并发策略的许多实现都不须要把线程挂起,所以这种操做也被称为非阻塞同步。而CAS这种乐观并发策略操做和冲突检测这两个步骤具有的原子性,是靠什么保证的呢?硬件,硬件保证了一个从语义上看起来须要屡次操做的行为只经过一条处理器指令就能完成。

也许你会存在疑问,为何这种无锁的方案通常会比直接加锁效率更高呢?这里其实涉及到线程的实现和线程的状态转换。实现线程主要有三种方式:使用内核线程实现、使用用户线程实现和使用用户线程加轻量级进程混合实现。而Java的线程实现则依赖于平台使用的线程模型。至于状态转换,Java定义了6种线程状态,在任意一个时间点,一个线程只能有且只有其中的一种状态,这6种状态分别是:新建、运行、无限期等待、限期等待、阻塞、结束

Java的线程是映射到操做系统的原生线程之上的,若是要阻塞或唤醒一个线程,都须要操做系统来帮忙完成,这就须要从用户态转换到核心态中,所以状态转换须要耗费不少的处理器时间。对于简单的同步块(被synchronized修饰的方法),状态转换消耗的时间可能比用户代码执行的时间还要长。因此出现了这种优化方案,在操做系统阻塞线程之间引入一段自旋过程或一直自旋直到成功为止。避免频繁的切入到核心态之中。 可是这种方案其实也并不完美,在这里就说下CAS实现原子操做的三大问题:

  • ABA问题。由于CAS须要在操做值的时候,检查值有没有变化,若是没有发生变化则更新,可是若是一个值原来是A,变成了B,又变成了A,那么使用CAS进行检查时会发现它的值没有变化,可是实际上发生变化了。ABA解决的思路是使用版本号。在变量前面追加上版本号,每次变量更新的时候把版本号加1。JDK的Atomic包里提供了一个类AtomicStampedReference来解决ABA问题。不过目前来讲这个类比较“鸡肋”,大部分状况下ABA问题不会影响程序并发的正确性,若是须要解决ABA问题,改用原来的互斥同步可能会比原子类更高效。
  • 循环时间长开销大。自旋CAS若是长时间不成功,会给CPU带来很是大的执行开销。因此说若是是长时间占用锁执行的程序,这种方案并不适用于此。
  • 只能保证一个共享变量的原子操做。当对一个共享变量执行操做时,咱们可使用自旋CAS来保证原子性,可是对多个共享变量的操做时,自旋CAS就没法保证操做的原子性,这个时候能够用锁。

final

  • final的内存语义 编译器和处理器要遵照两个重排序规则:

在构造函数内对一个final域的写入,与随后把这个被构造对象的引用赋值给一个引用变量,这两个操做之间不能重排序。 初次读一个包含final域的对象的引用,与随后初次读这个final域,这两个操做之间不能重排序。

  • final域为引用类型:

增长了以下规则:在构造函数内对一个final引用的对象的成员域的写入,与随后在构造函数外把这个被构造对象的引用赋值给一个引用变量,这两个操做之间不能重排序。

  • final语义在处理器中的实现: 会要求编译器在final域的写以后,构造函数return以前插入一个StoreStore障屏。 读final域的重排序规则要求编译器在读final域的操做前面插入一个LoadLoad屏障

一道面试题: [不使用volatile怎么打破循环?]

public class TestThread implements Serializable {

    public static void main(String[] args) throws InterruptedException {

        Data data = new Data();
        new Thread(() -> {
            try {
                TimeUnit.SECONDS.sleep(1);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            data.add();
        }).start();

        while (data.num == 0) {
            //怎么打破 死循环
        }

        /**-----------------------无责任分割线1-----------------------------------------------*/
        int i = 1;
        while (data.num == 0) {
            i = i++; //未触发致使死循环 
            i = ++i;
        }
        /**-----------------------无责任分割线2-----------------------------------------------*/
        while (data.num == 0) {
            synchronized (TestThread.class) {
                //同步锁触发线程切换 跳出循环
            }
        }
        /**-----------------------无责任分割线3-----------------------------------------------*/
        while (data.num == 0) {
            Thread.yield();//线程让步 跳出循环
        }
        /**-----------------------无责任分割线4-----------------------------------------------*/
        while (data.num == 0) {
            try {
                Thread.sleep(0);//线程休眠让出CPU 跳出循环
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
        /**-----------------------无责任分割线5-----------------------------------------------*/
        while (data.num == 0) {
            System.out.println("");//println 有同步锁 跳出循环
        }
        /**-----------------------无责任分割线6-----------------------------------------------*/
        LongAdder longAdder = new LongAdder();
        while (data.num == 0) {
            longAdder.decrement();//cas自旋锁 跳出循环
        }
        /**-----------------------无责任分割线7-----------------------------------------------*/

        System.out.println("哈哈2");
    }

    static class Data {
      volatile   int num = 0;
        public void add() {
            this.num = 60;
        }
    }
}
复制代码

本文摘(jie)抄(jian)自 鸣谢原文:从一个简单的Java单例示例谈谈并发 JMM JUC

相关文章
相关标签/搜索