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

958 阅读20分钟


JMM

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

\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种情况,只要重排序两个操作的执行顺序,程序的执行结果就会被改变。编译器和处理器可能对操作进行重排序。而它们进行重排序时,会遵守数据依赖性,不会改变数据依赖关系的两个操作的执行顺序。

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

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

顺序一致性内存模型

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

  • 一个线程中的所有操作必须按照程序的顺序来执行
  • (不管程序是否同步)所有线程都只能看到一个单一的操作执行顺序。在顺序一致性的内存模型中,每个操作必须原子执行并且立刻对所有线程可见。

从顺序一致性模型中,我们可以知道程序所有操作完全按照程序的顺序串行执行。而在JMM中,临界区内的代码可以重排序(但JMM不允许临界区内的代码“逸出”到临界区外,那样就破坏监视器的语义)。

假设这两个线程使用监视器锁来正确同步: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