Java内存模型(JMM)总结

  Seves

    一、前言

      了解和掌握Java内存模型,是java并发编程的基础,本文是笔者对《java并发编程的艺术》一书中Java内存模型(简称JMM)相关的重点内容的总结和分析。
      
    二、Java内存模型的抽象结构

      在Java中,所有实例域、静态域、和数组元素都存储在堆内存中,堆内存在线程之间共享。局部变量,方法定义参数和异常处理器参数不会在线程之间共享,它们不会有内存可见性问题,也不受内存模型的影响。
      Java线程之间的通信由JMM(Java内存模型)控制,JMM决定一个线程对共享变量的写入何时对另外一个线程可见。从抽象的角度来看,JMM定义了线程和主内存之间的抽象关系:线程之间的共享变量存储在主内存中,每个线程都有一个私有的本地内存,本地内存中存储了该线程以读/写共享变量的副本。本地内存是一个抽象概念,并不是真实存在。它涵盖了缓存、写缓冲区、寄存器以及其他的硬件和编译器优化。
      
    三、重排序

      在执行程序时,为了提高性能,编译器和处理器常常会对指令做重排序,重排序是编译器和处理器为了优化程序性能而对指令序列进行重新排序的一种手段,重排序分3种类型:
      1.编译器优化的重排序。编译器在不改变单线程程序语义的前提下,可以重新语句的执行顺序。
      2.指令级并行的重排序。现代处理器采用了指令级并行技术来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
      3.内存系统的重排序。由于处理器采用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是乱序执行。
      从Java源代码到最终实际执行的指令序列,会经历如下过程。源代码->编译器优化的重排序->指令级并行重排序->内存系统的重排序->最终执行的指令序列。、
      由于要进行这些重排序,所以可能会导致多线程程序出现内存可见性问题。对于编译器,JMM的编译器重排序规则会禁止特定类型的编译器重排序。对于处理器的重排序,JMM的处理器重排序规则会要求Java编译器在生成指令序列时,插入特定类型的内存屏障,通过内存屏障指令来禁止特定类型的处理器重排序。

    四、内存屏障

      内存屏障可以分为以下几个类型:

    • LoadLoad屏障:对于这样的语句Load1:LoadLoad;Load2,在Load2及后续读取操作要读取的数据访问之前,保证Load1读取的数据被读取完毕。
    • LoadStore屏障,对于这样的语句Load1:LoadStore;Store2,在Store2及后续的写操作被刷出之前,保证Load1的要读取的数据被读取完毕。
    • StoreStore屏障,对于这样的语句Store1:StoreStore;Store2在Store2及之后的写操作被刷出之前,保证Store1的写入操作对其他处理器可见。
    • StoreLoad屏障,对于这样的语句Store1:StoreLoad;Load2在Load2及之后的读操作要读取的数据访问之前,保证Store1的写入操作对其他处理器可见。他的开销是四种屏障中最大的。

    五、happen-before

      在JMM中,如果一个操作的执行结果需要对另外一操作可见,那么这两个操作之间必须要存在happen-before关系。这里提到的两个操作可以在一个线程之内,也可以在不同线程之间。它有如下规则:

    • 程序次序规则:一个线程的每个操作,happens-before于该线程的任意后续操作。
    • 监视器锁规则:对于一个锁的解锁,happens-before于随后对这个锁的加锁。
    • volatile变量规则:对于一个volatile域的写,happens-before于任意后续对这个volatile变量的读操作。
    • 传递性:如果Ahappens-beforeB,Bhappens-beforeC,那么Ahappens-beforeC 。
    • 线程启动规则:Thread对象的Start()方法happens-before对此线程的每一个操作。
    • 线程中断规则:对线程的interrupt()方法happens-before被中断代码监测到中断时间的发生。
    • 线程终结规则:线程中的所有方法happens-before于线程的终止监测(Thread.join结束和Threan.isAlive的返回值监测)。
    • 对象终结规则:一个对象的初始化happens-before对这个对象的finallize()方法的开始。

    注意:
      1.如果一个操作happens-before另一个操作,那么第一个操作的执行结果将对第二个操作可见,而且第一个操作的执行顺序排在第二个操作之前。
      2.两个操作之间存在happens-before关系,并不意味着Java平台的具体实现必须要按照happens-before关系指定的顺序来执行。如果重排序之后的执行结果,与按happens-before关系来执行的结果一致,那么这种重排序并不非法。
      上面1是JMM对程序员的承诺。从程序员的角度来说,可以这样理解happens-before关系:如果A happens-before B,那么Java内存模型将向程序员保证——A操作的结果将对B可见,且A的执行顺序排在B之前。注意,这只是Java内存模型向程序员做出的保证!
      上面2是JMM对编译器和处理器冲排序的约束。JMM其实是在遵循一个基本原则:只要不改变程序的执行结果,编译器和处理器怎么优化都行。happens-before这么做的目的,都是为了在不改变程序执行结果的前提下,尽可能地提高程序执行的并行度。
      
    六、数据依赖性

      如果有两个操作访问同一个变量,且这两个操作中有一个为写操作,此时两个操作之间就存在数据依赖性。分别为写后读、写后写、读后写三个类型。
      由于编译器和处理器可能会对操作做重排序,而存在数据依赖性的三种操作只要重排序就会改变程序执行的结果,所以编译器和处理器在重排序时,会遵守数据的依赖性,编译器和处理器不会改变存在数据依赖性的两个操作的执行顺序。这里所说的数据依赖性仅针对单个处理器中指令序列和单个线程中执行的操作,不同处理器之间和不同线程之间的数据依赖性不被编译器和处理器考虑。
      
    七、as-if-serial

      as-if-serial的语义是:不管怎么重排序(编译器和处理器为了提高并行度),单线程的程序执行结果不能被改变。编译器、runtime、和处理器都必须遵守as-if-serial语义。
      as-if-serial语义把单线程程序保护了起来,遵守as-if-serial的编译器、runtime、和处理器共同为编程单线程的程序员创建了一个幻觉:单线程程序是按程序的顺序来执行的。as-if-serial语义使单线程程序员无需担心重排序会干扰他们,也无需担心内存可见性问题。
      
    八、volatile

    Volatile的特性:
      A.禁止指令的重排序
      B.可见性
      C.原子性(对任意单个volatile变量的读/写具有原子性,但类似于volatile++这种复合操作不具有原子性)
    内存定义:

    • 当写一个volatile内存变量时,JMM会把线程对应的本地内存中的共享变量值刷新到主内存。
    • 当读一个volatile内存变量时,JMM会把线程对应的本地内存中置为无效,线程接下来将从主内存中读取共享变量(写的时候线程之间会互相通信)。

    语义总结:

    1. 线程A写一个volatile变量,实质上是线程A向接下来将要读这个volatile变量的某个线程发出了(其对共享变量所修改的)消息。
    2. 线程B读一个volatile变量,实质上是线程B接收了之前某个线程发出的(在写这个volatile变量之前对共享变量所做修改的)消息。
    3. 线程A写一个volatile变量,随后线程B读这个volatile变量,这个过程实质上是线程A通过主内存向线程B发送消息。

    volatile的重排序:

    1. 当第二个操作为volatile写操作时,不管第一个操作是什么(普通读写或者volatile读写),都不能重排序。这个操作确保了volatile写之前的操作不会被重排序到volatile之后。
    2. 当第一个操作为volatile读操作时,不管第二个操作是什么,都不能进行重排序。这个规则确保了volatile读之后的所有操作不会重排序到volatile之前。
    3. 当第一个操作是volatile的写操作,第二个操作是volatile的读操作,不能进行重排序。这个规则和前面的两个规则构成了2个volatile变量操作不能进行重排序。

    除以上3中情况可以重排序,例如:

    1. 第一个变量是普通的读写操作,第二个是volatile变量的读。
    2. 第一个操作是volatile变量写操作,第二个是普通变量的读。

    JMM实现volatile语义:

      为了实现volatile的内存定义,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。对于编译器来说,发现一个最优布置来最小化插入屏障的总数几乎不可能。为此,JMM采用保守策略,下面是基于volatile的JMM内存屏障插入策略:

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

    JMM优化:
      在实际执行时,只要不改变volatile的内存语义,编译器可以根据具体情况省略不必要的屏障。volatile后面的StoreLoad屏障是为了避免在volatile写后面可能有的volatile的读写操作的重排序。因为编译成常常无法判断一个volatile写后是否需要插入一个StoreLoad屏障(例如一个volatile写后面立即return),为了保证volatile的语义,JMM采取了保守策略,在每个volatile写后面或者在某个volatile读前面加入StoreLoad屏障。从整体效率来说,JMM最终选择了在volatile后面的加入了StoreLoad内存屏障(因为一般是写线程少读线程多)。
      
    九、锁

    锁的内存定义:
      当线程释放锁时,JMM该线程会把本地内存中的共享变量刷新到主内存中去。
      当线程获取锁时,JMM会把该线程对应的本地内存置为无效。从而使得被监视器保护的临界区代码必须从主内存中读取共享变量。
      锁的释放和获取内存语义和volatile变量的读写内存语义相同。
      线程A释放一个锁,实质上是线程A向接下来将要获取这个锁的某个线程发出了(线程A对共享变量所做修改的)消息。
      线程B获取一个锁,实质上是线程B接收了之前某个线程发出的(在释放这个锁之前对共享变量修改的)消息。
      线程A释放一个锁,随后线程B获取这个锁,这个过程实在上是下线程A通过主内存向线程B发送消息。
      在ReentrantLock中很明显可以看到其中同步包括两种,分别是公平的FairSync和非公平的NonfairSync。公平锁的作用就是严格按照线程启动的顺序来执行的,不允许其他线程插队执行的;而非公平锁是允许插队的。
      默认情况下ReentrantLock是通过非公平锁来进行同步的,包括Synchronized关键字都是如此,因为这样性能会更好。因为从线程进入了RUNNABLE状态,可以执行开始,到实际线程执行是要比较久的时间的。而且,在一个锁释放之后,其他的线程会需要重新来获取锁。其中经历了持有锁的线程释放锁,其他线程从挂起恢复到RUNNABLE状态,其他线程请求锁,获得锁,线程执行,这一系列步骤。如果这个时候,存在一个线程直接请求锁,可能就避开挂起到恢复RUNNABLE状态的这段消耗,所以性能更优化。
      
    公平锁和非公平锁的内存定义:

    1. 公平锁和非公平锁释放时,最后都要写一个volatile变量state。
    2. 公平锁获取锁时,首先回去读volatile变量。
    3. 非公平锁获取锁时,首先会用CAS更新volatile变量,这个操作同时具有volatile读和volatile写的内存定义。

    锁释放-获取的内存语义的实现至少有下面两种方式:

    1. 利用volatile变量的写-读所具有的内存语义。
    2. 利用CAS附带的volatile读和volatile写的内存语义。

    concurrent包的实现:

      由于java的CAS同时具有volatile读和写的内存语义,因此java线程之间的通信方式有了下面的4种方式。
      (1)A线程写volatile变量,随后B线程读这个volatile变量。
      (2)A线程写volatile变量,然后B线程用CAS更新这个volatile变量
      (3)A线程用CAS更新一个volatile变量,然后B线程用CAS更新这个volatile变量。
      (4)A线程用CAS更新一个volatile变量,然后B线程读这个volatile变量。
      concurrent包的源码的实现有一个基本的通用模式,首先声明共享变量为volatile,然后使用CAS的原子条件更新来实现线程之间的同步,同时配合以volatile的读写和CAS所具有的volatile读写的内存语义来实现线程之间的通信。
      AQS,非阻塞数据结构和原子变量类,这些concurrent包中的基础了都是使用这种模式来实现的,而concurrent的高层类又是依赖于这些基础类来实现的。
      
    十、final

    final域的重排序规则:

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

    写final域的重排序规则禁止把final域的写重排序到构造函数之外。这个规则的实现包含两个方面:
      (1)JMM禁止编译器把final域的写重排序到构造器之外
      (2)编译器会在final域的写之后,构造函数return之前,插入一个StoreStore屏障。这个屏障禁止处理器把final域的写重排序到构造函数之外。
      读final域的重排序规则是在一个线程中,初次都这个对象的引用和初次读这个对象包含的final域,JMM禁止处理器重排序这两个操作。JMM会在读final域之插入一个LoadLoad屏障。
    final语义在处理器中的语义实现:
      写final域的重排序规则会要求编译器在final域的写之后,构造函数return之前插入一个StoreStore屏障。读final域的重排序规则要求编译器在读final域的操作前加入一个LoadLoad屏障。由于在X86处理器中不会对写写操作做重排序和不会对间接依赖关系做重排序,所以说在X86处理中,final的读写所插入的内存屏障会被省略,也就是不会插入内存屏障。
      
    十一、双重检查锁定与懒加载(延迟加载)

      双重检查锁定与懒加载是常见的懒加载技术,但是它是错误的。
    双重检查锁定
      由于在new Instance()的时候实际上有三个过程。1.分配对象的内存空间。2.初始化对象。3.设置instance指向刚才分配的内存。而由于在单线程的环境下,2和3如果进行重排序是不会影响程序运行结果的,所以JMM是可以允许重排序的。但在多环境下,如果2和3进行了重排序,那么必然会出问题,因为在判断对象是否为null时,虽然可以通过判断,但是对象并没有实例化完成。  
    解决方案:

    方案一:
      声明instance为volatile类型,在声明volatile后多线程环境将会禁止2和3之间的重排序。
    方案二:
    类初始化
      这个方案的实质是:采用类初始化方案允许2和3的重排序,但不允许非构造线程看到这个重排序。
      提示一下在首次发生如下情况时,一个类或者接口T将会被立即初始化(初始化一个类,包括执行这个类的静态初始化和初始化这个类的静态字段):
      (1)T是一个类,而且一个类的T类型的实例被创建。
      (2)T是一个类,且T中声明的一个静态方法被调用。
      (3)T中声明的一个静态字段被赋值。
      (4)T中声明的一个静态字段被使用,而且这个字段不是一个常量字段。
      (5)T是一个顶级类,而且一个断言语句嵌套在T内部被执行。
      对比两个方案,类初始化方案的实现代码会更加简洁。但基于第一个volatile方案有一个额外的优势:除了可以对静态字段进行懒加载初始化,还可以对实例字段实现懒加载。

    十二、JMM内存可见性的保证

      单线程程序:单线程程序不会出现内存可见性的问题。编译器、runtime和处理器会共同确保单线程程序的执行结果与该程序在顺序一致性模型中执行的结果相同。
      正确同步的多线程程序。正确同步的多线程程序的执行将具有顺序一致性(程序的执行结果与该程序在顺序一致性的模型中的执行结果相同)。这是JMM关注的重点,JMM通过限制编译器和处理器的重排序来为程序员提供顺序一致性的保证。
      未同步或者未正确同步的多线程程序。JMM为它们提供了最小的安全性保障:线程执行时读到的值,要么是之前某个线程写入的值或者是默认值。

    CSDN地址:https://blog.csdn.net/qq_36236890

    微信订阅号:
    微信订阅号

    手在键盘敲很轻,我写的代码很小心。
    410