第3章 Java内存模型

  Java线程之间的通信对程序员完全透明,内存可见性问题很容易困扰Java程序员,本章将解开Java内存模型的面纱。大致分为4个部分:

      Java内存模型的基础主要介绍内存模型相关的基本概念

      Java内存模型的顺序一致性主要介绍重排序与顺序一致性内存模型

      同步原语,主要介绍3个同步原语(synchronized、volatile 和 final)的内存语义及重排序规则在处理器中的实现

      Java内存模型的设计原理,及其与处理器内存模型和顺序一致性内存模型的关系

  3.1 Java内存模型的基础

    在并发编程中,需要处理的两个关键的问题线程之间如何通信线程之间如何同步(这里的线程是指执行并发执行的活动实体)。通信是指线程之间以何种机制来交换消息。在命令式编程中,线程之间的通信机制有两种:共享内存和消息传递

    在共享内存的并发模型中,线程之间共享程序的公共状态,通过写-读内存中的公共状态进行隐式通信。在消息传递的模型里,线程之间没有公共状态,线程之间必须通过发送消息来显式的进行通信

    同步是指程序中用于控制不同线程间操作发生相对顺序的机制。在共享内存并发模型里,同步是显示进行的。程序员必须显式的指定某个方法或某段代码需要在线程之间互斥执行在消息传递的并发模型里,由于消息的发送必须在消息的接收之前,因此同步是隐式进行的

    Java的并发采用的是共享内存模型,Java线程之间的通信总是隐式的进行,整个通信工程对程序员来说完全透明。如果编写多线程的Java程序员不能理解隐式的线程之间通信的工作机制,可能会遇到各种奇怪的内存的可见性问题

    3.1.2 Java内存模型的抽象结构

      在Java中,所有实例域、静态域和数组元素都存储在堆内存中,堆内存在线程之间共享(本章中用“共享变量”这个术语代指实例域、静态域和数组元素)。局部变量(Loal variables),方法定义参数(Formal Method Parameters)和异常处理器此参数(Exception Handler Paarameters)不会在线程之间共享,他们不会有内存可见性问题,也不受内存模型影响

    Java线程之间的通信由Java内存模型(JMM)来控制,JMM决定一个线程对共享变量的写入何时对另一个线程可见。从抽象的角度来看,JMM定义了线程和主内存之间的抽象关系:线程之间的共享变量存储在主内存(Main Memory)中,每个线程都有一个私有的本地内存(Local Memory),本地内存中存储了该线程以读/写共享变量的副本。本地内存是JMM的一个抽象概念,并不真实存在,它涵盖了缓存、写缓冲区、寄存器以及其它硬件和编译器优化。Java内存模型的抽象示意图如图3-1所示。

          

 

    从上图来看,如果两个线程之间要通信,需要以下两个步骤

      1)线程A把本地内存中更新过的共享变量刷新到主内存中去。

      2)线程B把主内存中的A放入的共享变量读取出来。

  3.1.3 从源代码到指令排序的重排序

    在执行程序时,为了提高性能,编译器和处理器通常会对指令做重排序。重排序分为3种类型。

    1)编译器优化的重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。

    2)指令级并行的重排序。现代处理器采用了指令级并行技术来将多条指令重贴执行。如果不存在数据依赖型,处理器可以改变语句对应机器指令的执行顺序。

    3)内存系统的重排序。由于处理器使用缓冲和读/写缓冲区,这使得加载和存储操作看上去可能在乱序执行。

    从Java源代码到最终实际执行的指令序列,会分别经理下面的3种重排序,如图3-3所示。

    

 

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

  3.1.4 并发编程模型的分类

    现代处理器使用写缓冲区临时保存像内存中写入的数据。可以通过批处理的方式刷新写缓冲区,以及合并写缓冲区中对同一内存地址的多次写,减少内存总线的占用。虽然写缓冲区有这么多好处,但是每个处理器上的写缓冲区仅对她所在的处理器可见。这个回对内存操作的执行顺序产生重要的影响:处理器对内存的读/写操作的执行书讯,不一定与内存中实际发生的读写顺序操作一致!为了具体说明,请观察下面的例子。

        

 

      

 

 

 

    这里处理器A和处理器B可同时把共享变量写入自己的缓冲区(A1,B1),然后从内存中读取另一个共享变量(A2,B2),最后才能把自己写缓存区中的脏数据刷新到(A3,B3)中。当以这种时序执行时,会得到x=y=0的结果。

    从内存操作实际发生的实际顺序来看,只有处理器A执行A3来刷新自己的写缓存区,写操作A1才算真正执行了。虽然处理器A的执行内存操作的顺序为A1->A2,但是内存操作实际的顺序确实A2->A1.此时处理器A的内存操作顺序被重排序了。(处理器B同理)

  3.1.5 happens-before简介

    从JDK1.5开始,Java使用新的JSR-133内存模型。它使用了happens-before的概念阐述操作之间的内存可见性。在JMM中,如果一个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须要存在happens-before关系。这里提到的两个操作既可以在一个线程之内,也可以在不同的线程之间

    与程序员密切相关的happens-before规则如下。

      程序顺序规则:一个线程中的每个操作,happens-before于 该线程中的任意后续操作

      监视器锁规则:对于一个锁的解锁,happens-before于随后对这个锁的加锁

      volatile变量规则:对一个volatile域的写,happens-before于任意后续对这个volatile域的读

      传递性:如果 A happens-before B,且B happens-before C,那么A happens-beforeC.

    注意两个操作之间有happens-before关系,并不意味着前一个操作必须在后一个操作之前执行!happens-before仅仅要求前一个操作的执行结果对后一个操作可见,且前一个操作按顺序排在第二个操作之前

    happens-before与JMM的关系如下图所示

    

 

  happens-before原则简单易懂,它避免Java程序员为了理解jmm提供的内存可见性保证而去学习复杂的重排序i则以及这些规则的具体实现方法。

  3.2 重排序

    重排序时编译器和处理器为了优化程序的性能而对指令进行重新排序的一种手段。

    3.2.1数据依赖性

    如果两个操作访问同一个变量,且这两个操作有一个为写操作,此时这两个操作之间就存在数据依赖型。数据依赖分为下列三种类型,如表3-4所示。

    

 

 

     上面的情况,只要重排序两个操作执行顺序,程序的执行结果就会被改变。编译器和处理器不会改变存在数据一栏关系的两个操作的执行顺序。这里所说的数据依赖是针对单个处理器和单个线程中执行的操作,不同处理器之间和不同线程之间的数据依赖性不被其考虑。

    3.2.2  as-if-serial语义

    意思是不管怎么重排序,单线程程序的执行结果不能被改变。as-if-serial语义把单线程程序保护了起来,遵循as-if-serial语义的编译器、处理器、runtime共同为编写单线程的程序员创造了一个幻觉,就是单线程的程序是按照程序的顺序来执行的。as-if-serial语义使单线程程序员无需但相信重排序会干扰他们,也无需担心内存可见性问题。

     3.2.3 重排序对多线程的影响

    现在让我们来看看,重排序是否会改变多线程程序的执行结果。请看下面的示例代码。

  

class ReorderExample { 
    int a = 0; 
    boolean flag = false; 

    public void writer() {
         a = 1; // 1 
        flag = true; // 2 
    }
    Public void reader() { 
        if (fllag) {// 3 
            int i = a * a; // 4
         …… 
        } 
    } 
}            

  这里假设有两个线程A和B,A首先执行writer()方法,然后线程B执行reader()方法。线程B在执行操作4时,能否看到线程A在操作1对共享变量的写入呢?

    答案是:不一定

  由于操作1和操作2之间没有数据依赖关系,编译器和处理器可以对这两个操作进行重排序,同样操作3和操作4没有数据依赖关系,也可以重排序(猜测提前读取),都会导致破坏了多线程程序的语义。  

   3.3 顺序一致性

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

   3.3.1数据竞争与顺序一致性

  当程序未正确同步时,就可能存在数据竞争。Java内存模型规范对数据竞争的定义如下。

    在一个线程中写一个变量,在另一个线程中读同一个变量,而且写和读没有通过同步来排序。

  当代码中包含数据竞争时,程序运行结果违反直觉结果(前一张的示例就是如此)。若能正确同步,则是一个没有数据竞争的程序。JMM对正确同步的多线程程序内存一致性做了如下保证。

  如果同步正确,则程序执行具有顺序一致性,执行结果和在顺序一致性模型中的执行结果相同。这里的同步,包括对常用同步原语(synchronized、volatile和final)的正确使用。

  3.3.2 顺序一致性内存模型

    他是一个被科学家理想化了的参考模型,他为程序员提供了极强的内存可见性保证。有两大特性。

    1)一个线程中的所有操作必须按照程序的顺序来执行。

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

            

 

 

     但是在JMM中并没有这个保证,如果没有正确同步就会导致所有线程观察到的执行顺序不一致。

  3.3.3同步程序的顺序一致性效果

    我们对前面的示例程序用锁来同步,来看看正确同步的程序如何具有顺序一致性。   

class SynchronizedExample{
  int a;
  boolean flag = false;

  public synchronized  void writer(){
        a = 1;
        flag = true;

  }  

    public synchronized void reader(){
        if(flag){
            int  i=a;
        }

    }
  
}

  A执行writer()方法,B执行reader()方法。这是一个正确同步的代码,更具JMM规范,执行结果与在顺序一致性模型中的执行结果相同。对比如下:

        

 

 

   3.3.4 未同步程序的执行特性

    对于未同步或未正确同步的多线程程序,JMM只提供最小安全性:线程执行时读取到的值,要么是之前某个线程写入得知,要么是默认值,不可能是凭空冒出来的。

 

3.4 volatile的特性

    3.4.1 volatile特性

    理解volatile特性的一个好方法是把对volatile变量的单个读/写,看成是使用同一个锁对这些单个读写做了同步。下面通过具体的示例来说明

  

 

 

  锁的happens-before原则保证释放锁和获取锁之间线程的内存可见性,意味着对一个volatile变量的读,总能看到其最后的写。

  锁的语义决定了临界区代码的执行具有原子性。

  简而言之,volatile具有以下特征

  可见性:对一个volatile变量的读,总能看到任意线程对这个变量最后的写入

  原子性:对单个读写具有原子性,对volatie++这种符合操作不具有原子性

  3.4.2 volatile写-读建立的happens-before关系

    上面讲的是volatile变量自身的特性,对程序员来说,volatile对线程的内存可见性的影响比volatile自身的特性更为重要,也更需要我们去关注、

   从JSR-133开始(即JDK1.5开始),volatile变量的写-读可以实现线程之间的通信。

  从内存语义角度来说,volatile的写-读和锁的释放-获取有相同的内存效果:volatile写和锁的释放有相同的内存语义;volatile读和锁的获取有相同的内存语义

  请看下面使用volatile变量的示例代码

  

 

  假设线程A执行writer()方法之后,线程B执行reader()方法。根据happens-before规则,这个过程建立的happens-before关系可以分为3类:

    1)根据程序次序规则,1happens-before2;3happens-before4

    2)根据volatile规则,2 happens-before 3

    3)根据volatile传递性规则,1happens-before4

  注意:这里A线程写一个volatile变量后,B线程读取同一个volatile变量。A线程在写volatile变量之前所有可见的共享变量,在B线程读取同一个volatile变量后,将立即变得对B线程可见

  3.4.3 写-读的内存语义

  volatile写的内存语义如下:

    当写一个volatile变量时,JMM会把该线程对应的本地内存中的共享变量值刷新到主内存。

  volatile读的内存语义如下:

    当读一个volatile变量时,JMM会把该线程对应的本地内存置为无效。将从主内存中读取共享变量。

 

   3.4.4 volatile内存语义的实现

    下面来看看JMM如何实现volatile 写/读的内存语义

  

 

 

   从表3-5我们可以看出

  当第二个操作是volatile写时,不管第一个操作是什么,都不能重排序。这个规则确保volatile写之前的操作不会被编译器重排序到volatile写之后

  当第一个操作是volatile读操作时,不管第二个操作是什么,都不能重排序。这个规则确保volatile读之后的操作不会被编译器重排序到volatile读之前

  当第一个操作是volatile写,第二个操作是volatile读时,不能重排序。

   3.5 锁的内存语义

    众所周知,锁可以让临界区互斥执行。这里将介绍锁的另一个同样重要,但常常被忽视的功能:所得内存语义

    3.5.1 锁的释放-获取建立的happens-before关系

      锁时Java并发编程中最重要的同步机制。锁除了让临界区互斥执行,还可以让释放锁的线程想获取同一个锁的线程发送消息。

    下面是锁释放-获取的示例代码

    

 

 

   

  

    

 

     图3-24表示线程A释放了锁之后,随后线程B获取同一个锁。在上图中,2happens-before5.因此,线程A释放锁之前所有可见的共享变量,在线程B获取同一个锁之后将立刻变得对线程B可见。

   3.5.2 锁的释放和获取的内存语义

    释放锁时将本地内存刷新到主内存中,获取锁时将本地内存变量设置为无效,可以看出和volatile内存语义一致。

    总结:

    线程A释放一个锁,实质上是线程A向接下来将要获取这个锁的线程发送了(线程A对共享变量所进行的修改的)消息

    线程B获取一个锁,实质上是线程B接收了之前某个线程发出的(在释放这个锁之前对共享变量所做修改的)消息

    线程A释放锁,随后线程B获取这个锁,这个过程实质上是线程A通过主内存向线程B发送消息。

  3.5.3锁内存语义的实现

    本文将借助ReentrantLock的源代码,来分析锁内存语义的具体实现机制。

    请看下面示例代码

class ReentrantLockExample{
    int a = 0;
    ReentrantLock lock = new ReentrantLock();

    public void writer(){
        lock.lock(); //获取锁
        try{
            a++;
        }finally{
            lock.unlock(); //释放锁
        }
    }


    public void reader(){
           lock.lock(); //获取锁
            try{
              int i = a;
               ....
           }finally{
              lock.unlock(); // 释放锁
           }
    }
}                

  在ReentrantLock中,lock()方法获取锁,unlock()方法释放锁。其实现依赖于Java同步器框架AbstractQueuedSynchronizer(本文简称AQS)。AQS使用一个整形的volatile变量(命名为state)来维护同步状态,马上我们可以看到,这个volatile变量时ReentrantLock内存语义实现的关键。

  下面来看ReentrantLock的类图(仅画出与本文相关的部分)  

    

 

    ReentrantLock分为公平锁和非公平锁,我们先来看公平锁。

    使用公平锁时,加锁方法lock()的调用轨迹如下:

      1)ReentrantLock:lock().

      2)  FairSync: lock().

      3) AbstractQueuedSynchronizer: acquire(int arg)

      4) ReentrantLock:tryAcquire(int acquires)

    在第4步开始真正的加锁,下面是该方法的源代码

      

 protected final boolean tryAcquire(int acquires) {
            final Thread current = Thread.currentThread();
            int c = getState();  //获取锁的开始,首先读volatile变量state
            if (c == 0) {
                if (!hasQueuedPredecessors() &&
                    compareAndSetState(0, acquires)) {
                    setExclusiveOwnerThread(current);
                    return true;
                }
            }
            else if (current == getExclusiveOwnerThread()) {
                int nextc = c + acquires;
                if (nextc < 0)
                    throw new Error("Maximum lock count exceeded");
                setState(nextc);
                return true;
            }
            return false;
}

  从上面的源代码中我们可以看出,加锁方法首先读取volatile变量state

  在使用公平锁时,解锁方法unlock()调用轨迹如下:

    1)ReentrantLock:unlock()

    2) AbstractQueuedSynchronizer:release(int arg)

    3) Sync:tryRelease(int releases)

  在第3步开始真正释放锁,下面是方法源代码

protected final boolean tryRelease(int releases) {
            int c = getState() - releases;
            if (Thread.currentThread() != getExclusiveOwnerThread())
                throw new IllegalMonitorStateException();
            boolean free = false;
            if (c == 0) {
                free = true;
                setExclusiveOwnerThread(null);
            }
            setState(c);  //释放锁的最后,写volatile变量state
            return free;
}

  公平锁在释放锁的最后写volatile变量,在获取锁时首先读取这个volatile变量。根据volatile的happens-before规则,释放锁的线程在写volatile变量之前可见的共享变量,在获取锁的线程读取同一个volatile变量后将立即变得对获取锁的线程可见。

   现在我们来分析一下非公平锁的内存语义的实现。非公平锁的释放和公平锁完全一样,所以这里仅仅分析非公平锁的获取,使用非公平锁时,加锁方法lock()调用轨迹如下。

  1)ReentrantLock:lock()

  2)  NonfairSync:lock()

  3) AbstractQueuedSynchronizer:compareAndSetState(int expect,int update)

  第三步开始真正加锁,下面是该方法的源代码

protected final boolean compareAndSetState(int expect, int update) {
        // See below for intrinsics setup to support this
        return unsafe.compareAndSwapInt(this, stateOffset, expect, update);
}

  该方法以原子操作的方式更新state变量,本文把Java的compareAndSet()方法调用简称为CAS。JDK文档对该方法的说明如下:如果当前状态等于预期值,则以原子方式将同步状态设置为给定的更新值。此操作具有volatile读写的内存语义。

  现在对公平锁和非公平锁的内存语义做一个总结

    公平锁和非公平锁释放时,最后都要写一个volatile变量state

    公平锁获取时,首先会读取volatile变量

    非公平锁获取时,首先会用CAS跟新volatile变量,这个操作同时具有volatile写和volatile读的内存语义

  从本文对ReentrantLock的分析就可以看出,锁释放-获取的内存语义实现至少有以下两种方式

    1)利用volatile变量的写-读所具有的内存语义

    2)利用CAS所附带的volatile读和volatile写的内存语义

  3.5.4 concurrent包的实现

    由于Java的CAS同时具有volatile读和volatile写的内存语义,因此线程之间的通信现在有了下面4种方式。

    1)A写volatile变量,B读volatile变量

    2)A写volatile变量,B CAS更新变量

    3)A CAS更新便令,B CAS更新变量

    4)A CAS更新变量,B 读volatile变量

  

  如果我们仔细分析concurrent包的源代码实现,会发现一个通用化的实现模式
    首先,声明共享变量为volatile
    然后,使用CAS的原子条件更新来实现线程之间的同步  
    同时,配合以volatile的读/写和CAS所具有的volatile读和写的内存语义来实现线程之间的通信
    
    AQS非阻塞数据结构原子变量类(java.util.concurrent.atomic包中的类),这些concurrent
    包中的基础类都是使用这种模式来实现的,而concurrent包中的高层类又是依赖于这些基础类
    来实现的。从整体来看,concurrent包的实现示意图如3-28所示
        

 

  3.6 final域的内存语义

    与前面介绍的锁和volatile相比,对final域的读和写更像是普通的变量访问。下面将介绍final域的内存语义  

    对于final域,编译器和处理器要遵守两个重排序规则。
      1)在构造函数内对一个final域的写入,与随后把这个被构造对象的引用赋值给一个引用变量,这两个操作之间不能重排序
      2)初次读一个包含final域的对象的引用,与随后初次读这个final域,这两个操作之间不能重排序
      下面通过一些示例性的代码来分别说明这两个规则
    3.6.2写final域的重排序规则
      1)JMM禁止编译器把final域的写重写到构造函数之外
        

 

  3.6.3 读final域的重排序规则

        

 

 

 3.7 happens-before

  happens-before是JMM的核心。对Java程序员来说,理解happens-before是理解JMM的关键

  3.7.1 JMM设计

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

      程序员对内存模型的使用。程序员希望内存模型易于理解、易于编程,希望基于一个强内存模型来编写代码

      编译器和处理器对内存模型的实现。编译器以往束缚越少越好,希望实现一个弱内存模型

    因此JMM把happens-before要求禁止的重排序分为了下面两类

      会改变程序执行结果的重排序

      不会改变程序执行结果的重排序

    JMM对这两种不同性质的重排序,采取了不同的策略,如下。

      对于会改变程序执行结果的重排序,JMM要求编译器和处理器禁止重排序

      对于不会改变程序执行结果的重排序,JMM不做要求

    图3-33时JMM设计的示意图

        

 

   3.7.2 happens-before的定义

    

      JMM这样做的原因是

     

 

   3.7.3 happens-before规则

    1)程序顺序规则:一个线程中的每个操作,happens-before于该线程种的任意后续操作

    2)监视器锁规则:对一个锁的解锁,happens-before于随后对这个锁的加锁

    3)volatile变量规则:对一个volatile域的写,happens-before于任意后续对这个volatile域的读

    4)传递性: 如果A happens-before B,且Bhappens-before C,那么A happens-before CT

    5)   start()规则:如果线程A执行执行操作ThreadB.start()(启动线程B),那么A线程的ThreadB.start()操作happens-before于线程B中的任意操作

    6)join()规则:如果线程A执行操作ThreadB.join()并返回成功,那么线程B中的任意操作happens-before于线程A从ThreadB.join()操作成功返回

  

  1234前面都讲过,我们来看56
          

 

           

 

 

 

  3.8 双重检查锁定与延迟初始化

      详见单例模式(synchronized,双重判定,再加上volatile)

      基于volatile的解决方案

      

 

       基于类初始化的解决方案      

        JVM在类的初始化阶段(即在Class被加载后,且被线程使用之前),会执行类的初始化。在
        执行类的初始化期间,JVM会去获取一个锁。这个锁可以同步多个线程对同一个类的初始化。
        基于这个特性,可以实现另一种线程安全的延迟初始化方案(这个方案被称之为Initialization On Demand Holder idiom)。 
    

 

   3.9 内存模型总结

      

 

 

 
 
 
 
 

 

posted @ 2019-10-24 15:23  helloworldmybokeyuan  阅读(228)  评论(0编辑  收藏  举报