Java并发编程的艺术-Java内存模型

第三章 Java内存模型

3.1 Java内存模型的基础

3.1.1 并发编程模型的两个关键问题

​ 在并发编程中,需要处理的两个关键问题:线程之间如何通信及线程之间如何同步。通信是指线程之间以何种机制来交换信息。在命令式编程中,线程之间的通信机制有两种:共享内存和消息传递。
​ 在共享内存的并发模型里,线程之间共享程序的公共状态,通过写-读内存中的公共状态进行隐式通信。在消息传递的并发模型里,线程之间没有公共状态,线程之间必须通过发送消息来显示进行通信。

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

​ Java的并发采用的是共享内存模型,Java线程之间的通信总是隐式进行。

3.1.2 Java内存模型的抽象结构

​ 在Java中,所有实例域、静态域和数组元素都存储在堆内存中,堆内存在线程之间共享(“共享变量”这个术语代指这三个东西)。局部变量,方法定义参数和异常处理器参数不会在线程之间共享,它们不会有内存可见性问题,也不受内存模型的影响。

​ Java线程之间的通信由Java内存模型(JMM)控制。JMM定义了线程和主内存之间的抽象关系:线程之间的共享变量存储在主内存中,每个线程都有一个私有的本地内存,本地内存中存储了共享变量的副本。注意:本地内存是JMM的一个抽象概念,并不真实存在。

Java内存模型的抽象结构示意图

​ JMM通过控制主内存与每个线程的本地内存之间的交互,来为Java程序员提供内存可见性保证。

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

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

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

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

​ 3)内存系统的重排序。

​ JMM属于语言级的内存模型,它确保在不同的编译器和不同的处理器平台之上,通过禁止特定类型的编译器重排序和处理器重排序(通过内存屏障指令),提供一致的内存可见性保证。

3.1.4 并发编程模型的分类

3.1.5 happens-before简介

​ 从JDK5开始,Java使用心得JSR-133内存模型(无特别说明,本人针对的都是此内存模型)。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-before C。

注意:两个操作之间具有happens-before关系,并不意味着前一个操作必须要在后一个操作之前执行!happens-before仅仅要求前一个操作(执行的结果)对后一个操作可见,且前一个操作按顺序排在第二个操作之前(the first is visible to and ordered before the second)。

​ 一个happens-before规则对应于一个或多个编译器和处理器重排序规则。对于Java程序员来说,happens-before规则简单易懂,它避免程序员为了理解JMM提供的内存可见性保证而去学习复杂的重排序规则及这些规则的具体实现方法。

3.2 重排序

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

3.2.1 数据依赖性

​ 如果两个操作访问同一个变量,且这两个操作中有一个为写操作,此时这两个操作之间就存在数据依赖性。数据依赖分为三种:写后读、写后写、读后写。

​ 编译器和处理器在重排序时,会遵守数据依赖性,编译器和处理器不会改变存在数据依赖关系的两个操作的执行顺序(仅针对单个处理器和单个线程),不同处理器之间和不同线程之间的数据依赖性,编译器和处理器不考虑。

3.2.2 as-if-serial语义

​ as-if-serial语义的意思:不管怎么重排序,(单线程)程序的执行结果不能被改变。编译器、runtime和处理器都必须遵守。

​ 遵守as-if-serial语义,编译器和处理器不会对存在数据依赖关系的操作做重排序。弄个代码示例:

double pi = 3.14; //A
double r = 1.0; //B
double area = pi * r * r; //C

​ 上面三代码的数据依赖关系:C操作依赖于A操作和B操作,而A操作和B操作之间无数据依赖关系。因此,C操作不能重排序到A和B的前面。但是A和B之间无数据依赖关系,编译器和处理器可以重排序A和B之间的执行顺序。

​ as-if-serial语义把单线程程序保护了起来,给程序员创建了一个幻觉:单线程程序是按程序的顺序来执行的。as-if-serial语义使单线程无须担心重排序会干扰,也无需担心内存可见性问题。

3.2.3 程序顺序规则

​ 根据happens-before的程序顺序规则,上面的示例代码存在3个happens-before关系。

​ 1)A happens-before B。

​ 2)B happens-before C。

​ 3)A happens-before C。

​ 如果A happens-before B,JMM并不要求A一定要在B之前执行。JMM仅仅要求前一个操作(执行的结果)对后一个操作可见,且前一个操作按顺序排在第二个操作之前。但是这里的A操作结果不需要对B操作可见,而且重排序A操作和B操作后的执行结果,与俩操作按happens-before顺序执行结果一致,这时候JMM认为这种重排序并不非法,允许这种重排序。

在计算机中,软件技术和硬件技术有一个共同目标:在不改变程序执行结果的而前提下,尽可能提高并行度。

3.2.4 重排序对多线程的影响

class ReorderExample{
    int a = 0;
    boolean flag = false;
    public void writer(){
        a = 1; // 1
        flag = true; // 2
    }
    public void reader(){
        if(flag){ // 3
            int i = a * a; // 4
        }
    }
}

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

答案:是不一定看到。

​ 由于操作1和操作2没有数据依赖关系,编译器和处理器可以对两个操作重排序。同样,操作3和操作4也没有数据依赖关系,编译器和处理器也可以对着两个操作重排序。

​ 1.假设操作1和操作2做了重排序。假如线程A首先写标记变量flag,随后线程B读这个变量。由于条件判断为真,线程B读取变量a的值,此时,变量a却还没被线程A写入,那么这重排序就破环了多线程程序的语义了。

​ 2.假设操作3和操作4做了重排序。在程序中,操作3和操作4存在控制依赖关系。带代码中存在控制依赖关系时,会影响指令序列执行的并行度。为此,编译器和处理器会采用猜测执行来克服控制相关性对并行度的影响。以处理器的猜测执行为例,执行线程B的处理器可以提前读取并计算a*a,然后把计算结果临时保存到一个名为重排序缓冲(Reorder Buffer,ROB)的硬件缓存中,当操作3的条件判断为真时,就把该计算结果写入变量i中。可以看出,猜测执行实质上对操作3和操作4做了重排序。重排序在这里破环了多线程程序的语义!

​ 在单线程程序中,对存在控制依赖的操作重排序不会改变执行结果(这也是as-if-serial语义允许对存在控制依赖的操作做重排序的原因);但在多线程程序中,则可能会改变程序的执行结果。

3.3 顺序一致性

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

3.3.1 数据竞争与顺序一致性

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

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

​ 当代码中包含数据竞争时,程序的执行往往产生违反直觉的结果。如果一个多线程程序能正确同步,这个程序将是一个没有数据竞争的程序。

​ JMM对正确同步的多线程程序的内存一致性做了如下保证:如果程序是正确同步的,程序的执行将具有顺序一致性———即程序的执行结果与该程序在顺序一致性内存模型中的而执行结果相同。这里的同步是指广义上的同步,包括对常用同步原语(synchronized、volatile和final)的正确使用。

3.3.2 顺序一致性内存模型

​ 顺序一致性内存模型的两大特性:

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

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

​ 在概念上,顺序一致性模型有一个单一的全局内存,这个内存通过一个左右摆动的开关可以连接到任意一个线程,同时每一个线程必须按照程序的顺序来执行内存读/写操作。可以想象,在任意时间点最多只能有一个线程可以连接到内存。当多个线程并发执行时,开关装置能把所有的线程的所有内存读/写操作串行化(即在顺序一致性模型中,所有操作之间具有全序关系)。

​ 例子:假设有两个线程A和B并发执行。其中A线程有3个操作,它们在程序中的顺序是:A1->A2->A3。B线程也有3个操作,它们在程序中的顺序是:B1->B2->B3。

​ 假设两个线程使用监视器锁来正确同步:A线程的3个操作执行后释放监视器锁,随后B线程获取同一个监视器锁。最后的执行效果:操作的执行整体上有序,且两个线程都只能看到这个执行顺序。执行效果:A1->A2->A3->B1->B2->B3。

​ 假设两个线程没有做同步。未同步程序在顺序一致性模型中虽然整体执行顺序是无序的,但所有线程都只能看到一个一致的整体执行顺序。例如线程A和B看到的执行效果都是:B1->A1->A2->B2->A3->B3。之所以能得到这个保证是因为顺序一致性内存模型中的每个操作必须立即对任意线程可见。

​ 但是,JMM中没有这个保证。未同步程序在JMM中不但整体的执行顺序是无序的,而且所有线程看到的操作执行顺序也可能不一致。

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

​ 下面对前面的示例程序ReorderExample用锁来同步。

public class SynchronizedExample{
    int a = 0;
    boolean flag = false;
    public synchronized void writer(){  // 获取锁
        a = 1;
        flag = true;
    }  // 释放锁
    public synchronized void read(){  // 获取锁
        if(flag){
            int i = a * a;
        }
    }  // 释放锁
}

​ 这是一个正确同步的多线程程序。根据JMM规范,该程序的执行结果将与该程序在顺序一致性模型中的执行结果相同。根据JMM规范,该程序的执行结果将与该程序在顺序一致性模型中的执行结果相同。下面是该程序在两个内存模型中的执行时序对比图。

​ 顺序一致性模型中,所有操作完全按程序的顺序串行执行。而在JMM中,临界区内的代码可以重排序(但JMM不允许临界区的代码“逸出”到临界区之外,那样会破坏监视器的语义)。JMM会在退出临界区和进入临界区这两个关键时间点做一些特别处理,使得线程在这两个时间点具有与顺序一致性模型相同的内存视图。虽然线程A在临界区内做了重排序,但由于监视器互斥执行的特性,这里的线程B根本无法“观察”到线程A在临界区内的重排序。这种重排序既提高了执行效率,又没有改变程序的执行结果。

JMM与顺序一致性内存模型的执行时序对比图

​ 从这里可以看到,JMM在具体实现上的基本方案为:在不改变(正确同步的)程序执行结果的前提下,尽可能地为编译器和处理器的优化打开方便之门。

3.3.4 未同步程序的执行特性

​ 对于未同步或未正确同步的多线程程序,JMM只提供最小安全性:线程执行时读取到的值,要么是之前某个线程写入的值,要么是默认值(0,Null,False),JMM保证线程读操作读取的值不会是无中生有的冒出来。

​ 实现最小安全性,JVM在堆上分配对象时,首先会对内存空间进行清零,然后才会在上面分配对象(JVM内部会同步这两个操作)。

​ JMM不保证未同步程序的执行结果与该程序在顺序一致性模型中的执行结果一致。因为保证一致JMM需要禁止大量的处理器和编译器的优化,这会降低程序的执行性能。而且未同步程序在顺序执行一致性模型中,整体也是无序的,其执行结果往往也是无法预知的,所以没啥意义。

​ 未同步程序在两个模型的执行特性有如下几个差异:

​ 1)顺序一致性模型保证单线程内的操作会按程序的顺序执行,而JMM不保证。

​ 2)顺序一致性模型保证所有线程只能看到一致的操作执行顺序,而JMM不保证。

​ 3)JMM不保证对64位的long型和double型变量的写操作具有原子性,而顺序一致性模型保证对所有的内存读/写操作具有原子性。

​ 第三个差异与处理器总线的工作机制密切相关。在计算机中,数据通过总线在处理器和内存之间传递。每次处理器和内存之间的数据传递都是通过一系列步骤来完成,这一系列步骤称之为总线事务。总线事务包括读事务和写事务。读事务从内存传送数据到处理器,写事务从处理器传送数据到内存,每个事务会读/写内存中一个或对个物理上连续的字。这里关键的是,总线会同步试图并发使用总线的事务。在一个处理器执行总线事务期间,总线会禁止其他的处理器和I/O设备执行内存的读/写。

总线的工作机制:假设处理器A,B和C同时向总线发起总线事务,这时总线仲裁会对竞争做出裁决。在仲裁后被判定胜利的处理器会继续它的总线事务(总线仲裁会确保所有的处理器都能公平地访问内存),其他处理器则需等待胜利的处理器的总线事务完成后才能再次执行内存访问。

​ 在一些32位的处理器上,如果要求对64为数据的写操作具有原子性,会有比较大的开销。为了照顾这种处理器,Java语言规范鼓励但不强求JVM对64位的long型变量和double型变量的写操作具有原子性。当JVM在这种处理器上运行时,可能会把一个64位long/double型变量的写操作拆分为两个32位的写操作来执行。这两个32位的写操作可能会被分配到不同的总线事务中执行,此时对这个64位变量的写操作将不具备原子性。

​ 当单个内存操作不具备原子性时,可能会产生意想不到的后果,请看示意图:

总线事务执行的时序图

​ 如上图所示,假设处理器A写一个long型变量,同时处理器B要读这个long型变量。处理器A中64位的写操作被拆分为两个32位的写操作,且这两个32位的写操作被分配到不同的写事务中执行。同时,处理器B中64位的读操作被分配到单个的读事务中执行。当处理器A和B按上图的时序来执行时,处理器B将看到仅仅被处理器A“写了一半”的无效值。

​ **注意:**在JSR-133之前的旧内存模型中,一个64位long/double型变量的读/写操作可以被拆分为两个32位的读/写操作来执行。从JSR-133内存模型开始(即从JDK5开始),仅仅只允许把一个64位long/double型变量的写操作可以被拆分为两个32位的写操作来执行,任意的读操作在JSR-133中都必须具备原子性(即任意读操作必须要在单个读事务中执行)

3.4 volatile的内存语义

​ 当声明共享变量为volatile后,对这个变量的读/写将会很特别。

3.4.1 volatile的特性

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

class VolatileFeaturesExample{
    volatile long v = 0L;
    public void set(long l){
        v = l;
    }
    public long get(){
        return v;
    }
    public void getAndIncrement(){
        v++;
    }
}

​ 在多线程并发下,上面程序的语义和下面的等价。

class VolatileFeaturesExample{
    long v = 0L; // 64位的long型普通变量
    public synchronized void set(long l)}{ // 加锁
    	v = l
	}
	public synchronized void get(){ // 加锁
        return v;
    }
	public void getAndIncrement(){
        long temp = get(); // 调用已同步的读方法
        temp += 1L; // 普通写操作
        set(temp); // 调用已同步的写方法
    }
}

​ 如上所示,一个volatile变量的单个读/写操作,与一个普通变量的读/写操作都使用同一个锁来同步,它们之间的执行效果相同。

​ 锁的happens-before规则保证释放锁和获取锁的两个线程之间的内存可见性,这意味着对一个volatile变量的读,总是能看到(任意线程)对这个volatile变量最后的写入。
​ 锁的语义决定了临界区代码的执行具有原子性。这意味着,即使是64位的long型和double型变量,只要它是volatile变量,对该变量的读/写就具有原子性。如果是多个volatile操作或类似于volatile++这种复合操作,这些操作整体上不具有原子性。
​ 简而言之,volatile变量自身具有下列特性。

  • 可见性。对一个volatile变量的读,总是能看到(任意线程)对这个volatile变量最后的写入。
  • 原子性:对任意单个volatile变量的读/写具有原子性,但类似于volatile++这种复合操作不具有原子性。

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

​ 从JSR-133开始(即从JDK5开始),volatile变量的写-读可以实现线程之间的通信。
​ 从内存语义的角度来说,volatile的写-读与锁的释放-获取有相同的内存效果:volatile写和锁的释放有相同的内存语义;volatile读与锁的获取有相同的内存语义。

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

class VolatileExample{
    int a = 0;
    volatile boolean flag = false;
    public void write(){
        a = 1;  // 1
        flag = true;  // 2
    }
    public void read(){
        if(flag){  // 3
            int i = a * a;  // 4
        }
    }
}

​ 假设线程A执行writer()方法之后,线程B执行reader()方法。根据happens-before规则,这个立的happens-before关系可以分为3类:
​ 1)根据程序次序规则,1 happens-before 2;3 happens-before 4。
​ 2)根据volatile规则,2 happens-before 3。
​ 3)根据happens-before的传递性规则,1 happens-before 4。

​ 因为1 happens-before4,所以保证了flag变量的内存可见性。

3.4.3 volatile写-读的内存语义

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

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

​ 下面对volatile写和volatile读的内存语义做总结(线程A和B还是上面的例子,A先执行write(),B执行reader())。

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

3.4.4 volatile内存语义的实现

​ 为了实现volatile内存语义,JMM会限制编译器重排序和处理器重排序。下面为JMM针对编译器制定的volatile重排序规则表。

是否能重排序 第二个操作
第一个操作 普通读/写 volatile读 volatile写
普通读/写 NO
volatile读 NO NO NO
volatile写 NO NO

​ 规则:

  • 当第二个操作是volatile写时,不管第一个操作是什么,都不能重排序。
  • 当第一个操作是volatile读时,不管第二个操作是什么,都不能重排序。
  • 当第一个操作是volatile写,第二个操作是volatile读时,不能重排序。

​ 为了实现volatile的内存语义,编译器在生成字节码时,会在指令序列中插入内存屏障来进制特定类型的处理器重排序。下面是基于保守策略的JMM内存屏障插入策略。

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

​ 上述内存屏障插入策略非常保守,但它可以保证在任意处理器平台,任意的程序中都能得到正确的volatile内存语义。下面是保守策略下,volatile写插入内存屏障后生成的指令序列示意图:

指令序列示意图

​ 图3-19中的StoreStore屏障可以保证在volatile写之前,其前面的所有普通写操作已经对任意处理器可见了。这是因为StoreStore屏障将保障上面所有的普通写在volatil写之前刷新到主内存。
​ 这里比较有意思的是,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屏障。
​ 上面的优化针对任意处理器平台,由于不同的处理器有不同“松紧度”的处理器内存模
型,内存屏障的插入还可以根据具体的处理器内存模型继续优化。

3.4.5 JSR-133为什么要增强volatile的内存语义

​ 在JSR-133之前的旧Java内存模型中,虽然不允许volatile变量之间重排序,但旧的Java内存模型允许volatile变量与普通变量重排序。如果可重排序,上面的VolatileExample示例中,读线程B执行4时,不一定能看到写线程A在执行1时对共享变量的修改。

​ 因此,在旧的内存模型中,volatile的写-读没有锁的释放-获所具有的内存语义。为了提供一种比锁更轻量级的线程之间通信的机制,JSR-133专家组决定增强volatile的内存语义:严格限制编译器和处理器对volatile变量与普通变量的重排序,确保volatile的写-读和锁的释放-获取具有相同的内存语义。从编译器重排序规则和处理器内存屏障插入策略来看,只要volatile变量与普通变量之间的重排序可能会破坏volatile的内存语义,这种重排序就会被编译器重排序规则和处理器内存屏障插入策略禁止。

由于volatile仅仅保证对单个volatile变量的读/写具有原子性,而锁的互斥执行的特性可以确保对整个临界区代码的执行具有原子性。在功能上,锁比volatile更强大;在可伸缩性和执行性能上,volatile更有优势。如果读者想在程序中用volatile代替锁,请一定谨慎,具体详情请参阅Brian Goetz的文章《Java理论与实践:正确使用Volatile变量。

3.5 锁的内存语义

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

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

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

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

class MonitorExample{
    int a = 0;
    public synchronized void write(){ // 1
        a++; // 2
    } // 3
    public synchronized void read(){ // 4
        int i = a; // 5
        ....
    } // 6
}

​ 假设线程A执行write()方法,随后线程B执行read()方法。根据happens-before规则,这个过程包含的happens-before关系分为3类。

​ 1)根据程序次序规则:1 happens-before 2,2happens-before 3;4 happens-before 5,5 happens-before 6。

​ 2)根据监视器锁规则:3 happens-before 4。

​ 3)根据happens-before的传递性:2 happens-before 5;

​ 因为2 happens-before 5,因此,线程A在释放锁之前所有的可见的共享变量,在线程B同一个锁后,将立刻变得对线程B可见。

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

​ 当线程释放锁时,JMM会把该线程对应的本地内存中的共享变量刷新到主内存中。

​ 当线程获取锁时,JMM会把该线程对应的本地内存置为无效。从而使得被监视器保护的临界区代码必须从主内存中读取共享变量。

​ 到这里,我们可以发现:锁释放与volatile写有相同的内存语义,锁获取与volatile读有相同的内存语义。

​ 下面对锁释放和锁获取的内存语义做个总结。

  • 线程A释放一个锁,实质上是线程A向接下来将要获取这个锁的某个线程发出了(线程A对共享变量所做修改的)消息。
  • 线程B获取一个锁,实质上是线程B接收了之前某个线程发出的(在释放这个锁之前对共享变量所做修改的)消息。
  • 线程A释放锁,随后线程B获取这个锁,这个过程实质上是线程A通过主内存向线程B发
    送消息。

3.5.3 锁内存语义的实现

​ 借助ReentrantLock的源码来分析锁内存语义的具体实现机制。在ReentrantLock中,调用lock()方法获取锁;调用unlock()方法释放锁。

​ ReentrantLock的实现依赖于Java同步器框架AbstractQueuedSynchronizer(本人简称知为AQS)。AQS使用一个整型的volatile变量(命名为state)来维护同步状态。

​ 下面是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变量state。
​ 公平锁在释放锁的最后写volatile变量state,在获取锁时首先读这个volatile变量。根据volatile的happens-before规则,释放锁的线程在写volatile变量之前可见的共享变量,在获取锁的线程读取同一个volatile变量后将立即变得对获取锁的线程可见。

​ 现在我们来分析非公平锁的内存语义的实现。非公平锁的释放和公平锁完全一样,所以这里仅仅分析非公平锁的获取。使用非公平锁时,加锁方法lock()调用轨迹如下。
​ 1)ReentrantLock:lock()。
​ 2)NonfairSync:lock()。
​ 3)AbstractQueuedSynchronizer:compareAndSetState(int expect,int update)。
​ 在第3步真正开始加锁,下面是该方法的源代码。

protected final boolean compareAndSetState(int expect, int update) {
	return unsafe.compareAndSwapInt(this, stateOffset, expect, update);
}

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

​ 下面我们来分析在常见的intel X86处理器中,CAS是如何同时具有volatile读和volatile写的内存语义的。

​ compareAndSwapInt是一个本地方法调用,在openjdk中依次调用c++代码:unsafe.cpp,atomic.app和atomic_windows_x86.inline.hpp。我们看一下atomic_windows_x86.inline.hpp的源码片段。

inline jint Atomic::cmpxchg (jint exchange_value,volatile jint* dest,jintcompare_value) {
	// alternative for InterlockedCompareExchange
	int mp = os::is_MP();
	__asm {
		mov edx, dest
		mov ecx, exchange_value
		mov eax, compare_value
		LOCK_IF_MP(mp)
		cmpxchg dword ptr [edx], ecx
	}
}

​ 如上面源代码所示,程序会根据当前处理器的类型来决定是否为cmpxchg指令添加lock前缀。如果程序是在多处理器上运行,就为cmpxchg指令加上lock前缀(Lock Cmpxchg)。反之,如果程序是在单处理器上运行,就省略lock前缀(单处理器自身会维护单处理器内的顺序一致性,不需要lock前缀提供的内存屏障效果)。

intel的手册对lock前缀的说明如下:
1)确保对内存的读-改-写操作原子执行。在Pentium及Pentium之前的处理器中,带有lock前缀的指令在执行期间会锁住总线,使得其他处理器暂时无法通过总线访问内存。很显然,这会带来昂贵的开销。从Pentium 4、Intel Xeon及P6处理器开始,Intel使用缓存锁定(Cache Locking)来保证指令执行的原子性。缓存锁定将大大降低lock前缀指令的执行开销。
2)禁止该指令,与之前和之后的读和写指令重排序。
3)把写缓冲区中的所有数据刷新到内存中。

​ 上面的第2点和第3点所具有的内存屏障效果,足以同时实现volatile读和volatile写的内存语义。
​ 经过上面的分析,现在我们终于能明白为什么JDK文档说CAS同时具有volatile读和volatile写的内存语义了。

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

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

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

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

从本文对ReentrantLock的分析可以看出,锁释放-获取的内存语义的实现至少有下面两种方式。
​ 1)利用volatile变量的写-读所具有的内存语义。
​ 2)利用CAS所附带的volatile读和volatile写的内存语义。

3.5.4 concurrent包的实现

​ 由于Java的CAS同时具有volatile读和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变量。

​ Java的CAS会使用现代处理器上提供的高效机器级别的原子指令,这些原子指令以原子方式对内存执行读-改-写操作,这是在多处理器中实现同步的关键。同时,volatile变量的读/写和CAS可以实现线程之间的通信。把这些特性整合在一起,就形成了整个concurrent包得以实现的基石。

​ 如果我们仔细分析concurrent包的源代码实现,会发现一个通用化的实现模式。

​ 首先,声明共享变量为volatile。

​ 然后,使用CAS的原子条件更新来实现线程之间的同步。

​ 同时,配合以volatile的读/写和CAS所具有的volatile读和写的内存语义来实现线程之间的通信。

AQS,非阻塞数据结构和原子变量类(java.util.concurrent.atomic包中的类),这些concurrent
包中的基础类都是使用这种模式来实现的,而concurrent包中的高层类又是依赖于这些基础类
来实现的。从整体来看,concurrent包的实现示意图如下所示。

concurrent包的实现示意图

3.6 final域的内存语义

​ 与前面介绍的锁和volatile相比,对final域的读和写更像是普通的变量访问。

3.6.1 final域的重排序规则

​ final变量需在新建时初始化或者在构造函数中初始化(static final变量一定要在新建时初始化)。

​ 对于final域,编译器和处理器要遵守两个重排序规则。
​ 1)在构造函数内对一个final域的写入,与随后把这个被构造对象的引用赋值给一个引用变量,这两个操作之间不能重排序。
​ 2)初次读一个包含final域的对象的引用,与随后初次读这个final域,这两个操作之间不能重排序。

public class FinalExample {
	int i;           // 普通变量
	final int j;         // final变量
	static FinalExample obj;
	public FinalExample () {   // 构造函数
		i = 1;         // 写普通域
		j = 2;         // 写final域
	}
	public static void writer () {  // 写线程A执行
		obj = new FinalExample ();
	}
	public static void reader () {  // 读线程B执行
		FinalExample object = obj; // 读对象引用
		int a = object.i;      // 读普通域
		int b = object.j;      // 读final域
	}
}

3.6.2 写final域的重排序规则

​ 写final域的重排序规则禁止把final域的写重排序到构造函数之外。这个规则的实现包含下面2个方面。

​ 1)JMM禁止编译器把final域的写重排序到构造函数之外。

​ 2)编译器会在final域的写之后,构造函数return之前,插入一个StoreStore屏障。这个屏障禁止处理器把final域的写重排序到构造函数之外。

写final域的重排序规则可以确保:在对象引用为任意线程可见之前,对象的final域已经被正确初始化过了,而普通域不具有这个保障。

3.6.3 读final域的重排序规则

​ 读final域的重排序规则是,在一个线程中,初次读对象引用与初次读该对象包含的final域,JMM禁止处理器重排序这两个操作(注意,这个规则仅仅针对处理器)。编译器会在读final域操作的前面插入一个LoadLoad屏障。
​ 初次读对象引用与初次读该对象包含的final域,这两个操作之间存在间接依赖关系。由于编译器遵守间接依赖关系,因此编译器不会重排序这两个操作。大多数处理器也会遵守间接依赖,也不会重排序这两个操作。但有少数处理器允许对存在间接依赖关系的操作做重排序(比如alpha处理器),这个规则就是专门用来针对这种处理器的。

读final域的重排序规则可以确保:在读一个对象的final域之前,一定会先读包含这个final域的对象的引用。

3.6.4 final域为引用类型

public class FinalReferenceExample {
	final int[] intArray; // final是引用类型
	static FinalReferenceExample obj;
	public FinalReferenceExample () { // 构造函数
		intArray = new int[1]; // 1
		intArray[0] = 1; // 2
	}
	public static void writerOne () { // 写线程A执行
		obj = new FinalReferenceExample (); // 3
	}
	public static void writerTwo () { // 写线程B执行
		obj.intArray[0] = 2; // 4
	}
	public static void reader () { // 读线程C执行
		if (obj != null) { // 5
			int temp1 = obj.intArray[0]; // 6
		}
	}
}

​ 对于引用类型,写final域的重排序规则对编译器和处理器增加如下约束:在构造函数内对一个final引用的对象的成员域的写入,与随后在构造函数外把这个被构造函数对象的引用赋值給一个引用变量,这两个操作之间不能重排序。

​ JMM可以确保读线程C至少能看到写线程A在构造函数中对final引用对象的成员域的写入。即C至少能看到数组下标0的值为1。而写线程B对数组元素的写入,读线程C可能看得到,也可能看不到。JMM不保证线程B的写入对读线程C可见,因为写线程B和读线程C之间存在数据竞争,此时的执行结果不可预知。
​ 如果想要确保读线程C看到写线程B对数组元素的写入,写线程B和读线程C之间需要使用同步原语(lock或volatile)来确保内存可见性。

3.6.5 为什么final引用不能从构造函数内“溢出”

​ 前面我们提到过,写final域的重排序规则可以确保:在引用变量为任意线程可见之前,该引用变量指向的对象的final域已经在构造函数中被正确初始化过了。其实,要得到这个效果,还需要一个保证:**在构造函数内部,不能让这个被构造对象的引用为其他线程所见,也就是对象引用不能在构造函数中“逸出”。**为了说明问题,让我们来看下面的示例代码。

public class FinalReferenceEscapeExample {
	final int i;
	static FinalReferenceEscapeExample obj;
	public FinalReferenceEscapeExample () {
		i = 1; // 1写final域
		obj = this; // 2 this引用在此"逸出"
	}
	public static void writer() {
		new FinalReferenceEscapeExample ();
	}
	public static void reader() {
		if (obj != null) { // 3
			int temp = obj.i; // 4
		}
	}
}

3.6.6 final语义在处理器中的实现

​ 现在我们以X86处理器为例,说明final语义在处理器中的具体实现。
​ 上面我们提到,写final域的重排序规则会要求编译器在final域的写之后,构造函数return之前插入一个StoreStore障屏。读final域的重排序规则要求编译器在读final域的操作前面插入一个LoadLoad屏障。
由于X86处理器不会对写-写操作做重排序,所以在X86处理器中,写final域需要的StoreStore障屏会被省略掉。同样,由于X86处理器不会对存在间接依赖关系的操作做重排序,所以在X86处理器中,读final域需要的LoadLoad屏障也会被省略掉。也就是说,在X86处理器中,final域的读/写不会插入任何内存屏障!

3.6.7 JSR-133为什么要增强final的语义

​ 在旧的Java内存模型中,一个最严重的缺陷就是线程可能看到final域的值会改变。比如,一个线程当前看到一个整型final域的值为0(还未初始化之前的默认值),过一段时间之后这个线程再去读这个final域的值时,却发现值变为1(被某个线程初始化之后的值)。最常见的例子就是在旧的Java内存模型中,String的值可能会改变。
​ 为了修补这个漏洞,JSR-133专家组增强了final的语义。通过为final域增加写和读重排序规则,可以为Java程序员提供初始化安全保证:只要对象是正确构造的(被构造对象的引用在构造函数中没有“逸出”),那么不需要使用同步(指lock和volatile的使用)就可以保证任意线程都能看到这个final域在构造函数中被初始化之后的值。

3.7 happens-before

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

3.7.1 JMM的设计

​ 首先,让我们来看JMM的设计意图。从JMM设计者的角度,在设计JMM时,需要考虑两个关键因素。

  • 程序员对内存模型的使用。程序员希望内存模型易于理解、易于编程。程序员希望基于
    一个强内存模型来编写代码。
  • 编译器和处理器对内存模型的实现。编译器和处理器希望内存模型对它们的束缚越少越
    好,这样它们就可以做尽可能多的优化来提高性能。编译器和处理器希望实现一个弱内存模
    型。

​ 由于这两个因素互相矛盾,所以JSR-133专家组在设计JMM时的核心目标就是找到一个好的平衡点:一方面,要为程序员提供足够强的内存可见性保证;另一方面,对编译器和处理器的限制要尽可能地放松。下面让我们来看JSR-133是如何实现这一目标的。

double pi = 3.14;   // A
double r = 1.0;     // B
double area = pi * r * r; // C

上面计算圆的面积的示例代码存在3个happens-before关系,如下。

- A happens-before B。
- B happens-before C。
- A happens-before C。

​ 在3个happens-before关系中,2和3是必需的,但1是不必要的。因此,JMM把happens-before要求禁止的重排序分为了下面两类。

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

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

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

JMM的设计示意图

​ 从上图可看出两点:

​ 1)JMM向程序员提供的happens-before规则能满足程序员的需求。JMM的happens-before规则不但简单易懂,而且也向程序员提供了足够强的内存可见性保证(有些内存可见性保证其实并不一定真实存在,比如上面的A happens-before B)。

​ 2)JMM对编译器和处理器的束缚已经尽可能少。从上面的分析可以看出,JMM其实是在遵循一个基本原则:**只要不改变程序的执行结果(指的是单线程程序和正确同步的多线程程序),编译器和处理器怎么优化都行。**例如,如果编译器经过细致的分析后,认定一个锁只会被单个线程访问,那么这个锁可以被消除。再如,如果编译器经过细致的分析后,认定一个volatile变量只会被单个线程访问,那么编译器可以把这个volatile变量当作一个普通变量来对待。这些优化既不会改变程序的执行结果,又能提高程序的执行效率。

3.7.2 happens-before的定义

​ happens-before的概念最初由Leslie Lamport在其一篇影响深远的论文(《Time,Clocks and the Ordering of Events in a Distributed System》)中提出。Leslie Lamport使用happens-before来定义分布式系统中事件之间的偏序关系(partial ordering)。Leslie Lamport在这篇论文中给出了一个分布式算法,该算法可以将该偏序关系扩展为某种全序关系。
​ JSR-133使用happens-before的概念来指定两个操作之间的执行顺序。由于这两个操作可以在一个线程之内,也可以是在不同线程之间。因此,JMM可以通过happens-before关系向程序员提供跨线程的内存可见性保证(如果A线程的写操作a与B线程的读操作b之间存在happensbefore关系,尽管a操作和b操作在不同的线程中执行,但JMM向程序员保证a操作将对b操作可见)。
​ 《JSR-133:Java Memory Model and Thread Specification》对happens-before关系的定义如下。
​ 1)如果一个操作happens-before另一个操作,那么第一个操作的执行结果将对第二个操作可见,而且第一个操作的执行顺序排在第二个操作之前。
​ 2)两个操作之间存在happens-before关系,并不意味着Java平台的具体实现必须要按照happens-before关系指定的顺序来执行。如果重排序之后的执行结果,与按happens-before关系来执行的结果一致,那么这种重排序并不非法(也就是说,JMM允许这种重排序)。
​ 上面的1)是JMM对程序员的承诺。从程序员的角度来说,可以这样理解happens-before关系:如果A happens-before B,那么Java内存模型将向程序员保证——A操作的结果将对B可见,且A的执行顺序排在B之前。注意,这只是Java内存模型向程序员做出的保证!
​ 上面的2)是JMM对编译器和处理器重排序的约束原则。正如前面所言,JMM其实是在遵循一个基本原则:只要不改变程序的执行结果(指的是单线程程序和正确同步的多线程程序),编译器和处理器怎么优化都行。JMM这么做的原因是:程序员对于这两个操作是否真的被重排序并不关心,程序员关心的是程序执行时的语义不能被改变(即执行结果不能被改变)。因此,happens-before关系本质上和as-if-serial语义是一回事。

  • as-if-serial语义保证单线程内程序的执行结果不被改变,happens-before关系保证正确同
    步的多线程程序的执行结果不被改变。
  • as-if-serial语义给编写单线程程序的程序员创造了一个幻境:单线程程序是按程序的顺
    序来执行的。happens-before关系给编写正确同步的多线程程序的程序员创造了一个幻境:正
    确同步的多线程程序是按happens-before指定的顺序来执行的。

​ as-if-serial语义和happens-before这么做的目的,都是为了在不改变程序执行结果的前提
下,尽可能地提高程序执行的并行度。

3.7.3 happens-before规则

《JSR-133:Java Memory Model and Thread Specification》定义了如下happens-before规则。
1)程序顺序规则:一个线程中的每个操作,happens-before于该线程中的任意后续操作。
2)监视器锁规则:对一个锁的解锁,happens-before于随后对这个锁的加锁。
3)volatile变量规则:对一个volatile域的写,happens-before于任意后续对这个volatile域的
读。
4)传递性:如果A happens-before B,且B happens-before C,那么A happens-before C。
5)start()规则:如果线程A执行操作ThreadB.start()(启动线程B),那么A线程的ThreadB.start()操作happens-before于线程B中的任意操作。
6)join()规则:如果线程A执行操作ThreadB.join()并成功返回,那么线程B中的任意操作happens-before于线程A从ThreadB.join()操作成功返回。

​ 这里的规则1)、2)、3)和4)前面都讲到过,这里再做个总结。由于2)和3)情况类似,这里

只以1)、3)和4)为例来说明。图3-34是volatile写-读建立的happens-before关系图。

happens-before关系的示意图

​ 结合图,我们做以下分析。
​ ·1 happens-before 2和3 happens-before 4由程序顺序规则产生。由于编译器和处理器都要遵守as-if-serial语义,也就是说,as-if-serial语义保证了程序顺序规则。因此,可以把程序顺序规则看成是对as-if-serial语义的“封装”。
​ ·2 happens-before 3是由volatile规则产生。前面提到过,对一个volatile变量的读,总是能看到(任意线程)之前对这个volatile变量最后的写入。因此,volatile的这个特性可以保证实现volatile规则。
​ ·1 happens-before 4是由传递性规则产生的。这里的传递性是由volatile的内存屏障插入策略和volatile的编译器重排序规则共同来保证的。

​ 下面我们来看start()规则。假设线程A在执行的过程中,通过执行ThreadB.start()来启动线
程B;同时,假设线程A在执行ThreadB.start()之前修改了一些共享变量,线程B在开始执行后会
读这些共享变量。图3-35是该程序对应的happens-before关系图。

happens-before关系的示意图

​ 在图中,1 happens-before 2由程序顺序规则产生。2 happens-before 4由start()规则产生。根据传递性,将有1 happens-before 4。这实意味着,线程A在执行ThreadB.start()之前对共享变量所做的修改,接下来在线程B开始执行后都将确保对线程B可见。
​ 下面我们来看join()规则。假设线程A在执行的过程中,通过执行ThreadB.join()来等待线
程B终止;同时,假设线程B在终止之前修改了一些共享变量,线程A从ThreadB.join()返回后会
读这些共享变量。图3-36是该程序对应的happens-before关系图。

happens-before关系的示意图

​ 在图中,2 happens-before 4由join()规则产生;4 happens-before 5由程序顺序规则产生。根据传递性规则,将有2 happens-before 5。这意味着,线程A执行操作ThreadB.join()并成功返回后,线程B中的任意操作都将对线程A可见。

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

​ 在Java多线程程序中,有时候需要采用延迟初始化来降低初始化类和创建对象的开销(这里我们可以想到(使用双重检查锁的)单例模式)。双重检查锁定是常见的延迟初始化技术,但它是一个错误的用法。本文将分析双重检查锁定的错误根源,以及两种线程安全的延迟初始化方案。

3.8.1 双重检查锁定的由来

​ 在Java程序中,有时候可能需要推迟一些高开销的对象初始化操作,并且只有在使用这些对象时才进行初始化。此时,程序员可能会采用延迟初始化。但要正确实现线程安全的延迟初始化需要一些技巧,否则很容易出现问题。比如,下面是非线程安全的延迟初始化对象的示例代码。

public class UnsafeLazyInitialization {
	private static Instance instance;
	public static Instance getInstance() {
		if (instance == null) // 1:A线程执行
			instance = new Instance(); // 2:B线程执行
		return instance;
	}
}

​ 在UnsafeLazyInitialization类中,假设A线程执行代码1的同时,B线程执行代码2。此时,线程A可能会看到instance引用的对象还没有完成初始化(出现这种情况的原因见3.8.2节)。
​ 对于UnsafeLazyInitialization类,我们可以对getInstance()方法做同步处理来实现线程安全的延迟初始化。示例代码如下。

public class SafeLazyInitialization {
	private static Instance instance;
	public synchronized static Instance getInstance() { // 加锁
		if (instance == null)
			instance = new Instance();
		return instance;
	}
}

​ 由于对getInstance()方法做了同步处理,synchronized将导致性能开销。如果getInstance()方法被多个线程频繁的调用,将会导致程序执行性能的下降。反之,如果getInstance()方法不会被多个线程频繁的调用,那么这个延迟初始化方案将能提供令人满意的性能。

​ 在早期的JVM中,synchronized(甚至是无竞争的synchronized)存在巨大的性能开销。因此,人们想出了一个“聪明”的技巧:双重检查锁定(Double-Checked Locking)。人们想通过双重检查锁定来降低同步的开销。下面是使用双重检查锁定来实现延迟初始化的示例代码。

public class DoubleCheckedLocking { // 1
	private static Instance instance; // 2
	public static Instance getInstance() { // 3
		if (instance == null) { // 4:第一次检查
			synchronized (DoubleCheckedLocking.class) { // 5:加锁
				if (instance == null) // 6:第二次检查
					instance = new Instance(); // 7:问题的根源出在这里
			} // 8
		} // 9
		return instance; // 10
	} // 11
}

​ 如上面代码所示,**如果第一次检查instance不为null,那么就不需要执行下面的加锁和初始化操作。因此,可以大幅降低synchronized带来的性能开销。**上面代码表面上看起来,似乎两全其美。

​ ·多个线程试图在同一时间创建对象时,会通过加锁来保证只有一个线程能创建对象。
​ ·在对象创建好之后,执行getInstance()方法将不需要获取锁,直接返回已创建好的对象。

双重检查锁定看起来似乎很完美,但这是一个错误的优化!在线程执行到第4行,代码读取到instance不为null时,instance引用的对象有可能还没有完成初始化(猜测:问题根源可能在重排序)。

3.8.2 问题的根源

​ 前面的双重检查锁定示例代码的第7行(instance=new Singleton();)创建了一个对象。这一行代码可以分解为如下的3行伪代码。

memory = allocate(); // 1:分配对象的内存空间
ctorInstance(memory);  // 2:初始化对象
instance = memory;   // 3:设置instance指向刚分配的内存地址

​ 上面的3行伪代码中的2和3之间,可能会被重排序2和3重排序之后的执行时序如下:

memory = allocate(); // 1:分配对象的内存空间
instance = memory;   // 3:设置instance指向刚分配的内存地址
						// 注意:此时对象还没被初始化
ctorInstance(memory);  // 2:初始化对象

​ 根据《The Java Language Specification,Java SE 7 Edition》(后文简称为Java语言规范),所有线程在执行Java程序时必须要遵守intra-thread semantics。**intra-thread semantics保证重排序不会改变单线程内的程序执行结果。换句话说,intra-thread semantics允许那些在单线程内,不会改变单线程程序执行结果的重排序。**上面3行伪代码的2和3之间虽然被重排序了,但这个重排序并不会违反intra-thread semantics。这个重排序在没有改变单线程程序执行结果的前提下,可以提高程序的执行性能。

​ 为了更好地理解intra-thread semantics,假设一个线程A在构造对象后,立即访问这个对象。
​ 如下图所示,只要保证2排在4的前面,即使2和3之间重排序了,也不会违反intra-threadsemantics。

单线程执行时序图

​ 下面是多线程并发执行的情况。

多线程执行时序图

​ 由于单线程内要遵守intra-thread semantics,从而能保证A线程的执行结果不会被改变。但是,当线程A和B按多线程并发的时序执行时,B线程将看到一个还没有被初始化的对象。
​ 回到本文的主题,DoubleCheckedLocking示例代码的第7行(instance=new Singleton();)如果发生重排序,另一个并发执行的线程B就有可能在第4行判断instance不为null。线程B接下来将访问instance所引用的对象,但此时这个对象可能还没有被A线程初始化!下表是这个场景的具体执行时序。

时间 线程A 线程B
t1 A1:分配对象的内存空间
t2 A3:设置instance指向内存空间
t3 B1:判断instance是否为空
t4 B2:由于instance不为null,线程B将访问instance引用的对象
t5 A2:初始化对象
t6 A4:访问instance引用的对象

​ 这里A2和A3虽然重排序了,但Java内存模型的intra-thread semantics将确保A2一定会排在A4前面执行。因此,线程A的intra-thread semantics没有改变,但A2和A3的重排序,将导致线程B在B1处判断出instance不为空,线程B接下来将访问instance引用的对象。此时,线程B将会访问到一个还未初始化的对象。
​ 在知晓了问题发生的根源之后,我们可以想出两个办法来实现线程安全的延迟初始化。
​ 1)不允许2和3重排序。
​ 2)允许2和3重排序,但不允许其他线程“看到”这个重排序。

3.8.3 基于volatile的解决方案

​ 对于前面的基于双重检查锁定来实现延迟初始化方案(DoubleCheckedLockin示例代码),只需要做一点小的修改,把instance声明为volatile型,就可以实现线程安全的延迟初始化。

public class SafeDoubleCheckedLocking{
    private volatile static SafeDoubleCheckedLocking instance;
    public static SafeDoubleCheckedLocking getInstance(){
        if(instance == null){
            synchronized(SafeDoubleCheckedLocking.class){
                if(instance == null){
                    // instance为volatile类型,volatile保证instance的读和写都具有原子性
                    instance = new SafeDoubleCheckedLocking(); 
                }
            }
        }
    }
}

注意 这个解决方案需要JDK 5或更高版本(因为从JDK 5开始使用新的JSR-133内存模型规范,这个规范增强了volatile的语义)。当声明对象的引用为volatile后,3.8.2节中的3行伪代码中的2和3之间的重排序,在多线程环境中将会被禁止。

3.8.4 基于类初始化的解决方案

JVM在类的初始化阶段(即在Class被加载后,且被线程使用之前),会执行类的初始化。在执行类的初始化期间,JVM会去获取一个锁。这个锁可以同步多个线程对同一个类的初始化。

​ 根据上面,其实单例模式中的静态内部类,饿汉模式就是使用此解决方案。利用类初始化的线程安全来保证只会实例化一个instance对象。

// 静态内部类
public class Singleton{
    private Singleton(){}
    private static class Inner{
    	public static Singleton instance = new Singleton();    
    }
    public static Singleton getInstance(){
        return Inner.instance; // 这里将导致Inner类被初始化
    }
}
// 饿汉模式
public class Singleton{
    private Sintleton(){}
    private static Singleton instance = new Singleton(); // Singleton类初始化是线程安全的且只初始化一次,保证instance只有一个
    public static Singleton getInstance(){
        return instance;
    }
}

两个线程并发执行的示意图

​ 此方案的实质是:允许伪代码中2和3的重排序,但不允许非构造线程(这里指线程B)看到这个重排序。

​ 初始化一个类,包括执行这个类的静态初始化和初始化这个类中声明的静态字段。根据Java语言规范,在首次发生下列任意一种情况时,一个类或接口类型T将立即初始化。

1)T是一个类,而且一个T类型的实例被创建。

2)T是一个类,且T中声明的一个静态方法被调用。(饿汉模式是这个

3)T中声明的一个静态字段被赋值。

4)T中生命的一个静态字段被使用,而且这个字段不是一个常量字段。(静态内部类是这个

5)T是一个顶级类(Top Level Class,见Java语言规范的§7.6),而且一个断言语句嵌套在T内部被执行。

​ **Java语言规范规定,对于每一个类或接口C,都有一个唯一的初始化锁LC与之对应。**从C到LC的映射,由JVM的具体实现去自由实现。JVM在类初始化期间会获取这个初始化锁,并且每个线程至少获取一次锁来确保这个类已经被初始化过了(事实上,Java语言规范允许JVM的具体实现在这里做一些优化,见后文的说明)。
​ 对于类或接口的初始化,Java语言规范制定了精巧而复杂的类初始化处理过程。Java初始化一个类或接口的处理过程如下(这里对类初始化处理过程的说明,省略了与本文无关的部分;同时为了更好的说明类初始化过程中的同步处理机制,笔者人为的把类初始化的处理过程分为了5个阶段)。

第1阶段:通过在Class对象上同步(即获取Class对象的初始化锁),来控制类或接口的初始化。这个获取锁的线程会一直等待,直到当前线程能够获取到这个初始化锁。

​ 假设Class对象当前还没有被初始化(初始化状态state,此时被标记为state=noInitialization),且有两个线程A和B试图同时初始化这个Class对象。

​ 假设Class对象当前还没有被初始化(初始化状态state,此时被标记为state=noInitialization),且有两个线程A和B试图同时初始化这个Class对象。

时间 线程A 线程B
t1 A1:尝试获取Class对象的初始化锁。这里假设线程A获取到了初始化锁 B1:尝试获取Class对象的初始化锁,由于线程A获取到了锁,线程B将一直等待获取初始化锁
t2 A2:线程A看到对象还未被初始化(因为读到statenoInitialization),初始化锁设置stateinitializing
t3 A3:线程A释放初始化锁

第2阶段:线程A执行类的初始化,同时线程B在初始化锁对应的conditon上等待。

时间 线程A 线程B
t1 A1:执行类的静态初始化和初始化类中声明的静态字段 B1:获取到初始化锁
t2 B2:读到state=initializing
t3 B3:释放初始化锁
t4 B4:在初始化锁的condition中等待

第3阶段:线程A设置state=initialized,然后唤醒在condition中等待的所有线程。

时间 线程A
t1 A1:获取初始化锁
t2 A2:完成类的初始化,将state设置为initialized
t3 A3:唤醒在初始化锁condition中的所有线程
t4 A4:释放初始化锁

第4阶段:线程B结束类的初始化处理。

时间 线程B
t1 B1:获取到初始化锁
t2 B2:读到state=initialized
t3 B3:释放初始化锁
t4 B4:线程B的类初始化处理过程完成

​ 线程A在第2阶段的A1执行类的初始化,并在第3阶段的A4释放初始化锁;线程B在第4阶段的B1获取同一个初始化锁,并在第4阶段的B4之后才开始访问这个类。根据Java内存模型规范的锁规则,这里将存在如下的happens-before关系。

​ 1)根据程序顺序规则:2阶段-A1 happens-before 3阶段-A4;4阶段-B1 happens-before 4阶段-B4

​ 2)根据监视器锁规则:3阶段-A4 happens-before 4阶段-B1

​ 3)根据happens-before的传递性:2阶段-A1 happens-before 4阶段-B4

​ 所以这个happens-before关系将保证:线程A执行类的初始化时的写入操作(执行类的静态初始化和初始化类中声明的静态字段),线程B一定能看到。

第5阶段:线程C执行类的初始化的处理

时间 线程C
t1 C1:获取到初始化锁
t2 C2:读到state=initialized
t3 C3:释放初始化锁
t4 C4:线程C的类初始化处理过程完成

​ 因为在第3阶段后,类已经完成了初始化。因此线程C在第5阶段的类初始化过程相对简单一下,相当于第四阶段的B线程的类初始化过程一样,线程C只需要经历一次一次锁获取-锁释放。而线程A和线程B都经历过两次锁获取-锁释放。

​ 第5阶段的happens-before和上面的一样。

注意:这里的condition和state标记是本文虚构出来的。Java语言规范并没有硬性规定一定要使用condition和state标记。JVM的具体实现只要实现类似功能即可。
注意:Java语言规范允许Java的具体实现,优化类的初始化处理过程(对这里的第5阶段做优化),具体细节参见Java语言规范的12.4.2节。

​ 通过对比基于volatile的双重检查锁定的方案和基于类初始化的方案,我们会发现基于类初始化的方案的实现代码更简洁。但基于volatile的双重检查锁定的方案有一个额外的优势:除了可以对静态字段实现延迟初始化外,还可以对实例字段实现延迟初始化。
​ 字段延迟初始化降低了初始化类或创建实例的开销,但增加了访问被延迟初始化的字段的开销。在大多数时候,正常的初始化要优于延迟初始化。如果确实需要对实例字段使用线程安全的延迟初始化,请使用上面介绍的基于volatile的延迟初始化的方案;如果确实需要对静态字段使用线程安全的延迟初始化,请使用上面介绍的基于类初始化的方案。

3.9 Java内存模型综述

3.9.1 处理器的内存模型

​ 顺序一致性内存模型是一个理论参考模型,JMM和处理器内存模型在设计时通常会以顺序一致性内存模型为参照。在设计时,JMM和处理器内存模型会对顺序一致性模型做一些放松,因为如果完全按照顺序一致性模型来实现处理器和JMM,那么很多的处理器和编译器优化都要被禁止,这对执行性能将会有很大的影响。

注意,因为处理器要遵守as-if-serial语义,处理器不会对存在数据依赖性的两个内存操作做重排序。

​ 常见的处理器内存模型比JMM要弱,Java编译器在生成字节码时,会在执行指令序列的适当位置插入内存屏障来限制处理器的重排序。同时,由于各种处理器内存模型的强弱不同,为了在不同的处理器平台向程序员展示一个一致的内存模型,JMM在不同的处理器中需要插入的内存屏障的数量和种类也不相同。下图展示了JMM在不同处理器内存模型中需要插入的内存屏障的示意图。
​ JMM屏蔽了不同处理器内存模型的差异,它在不同的处理器平台之上为Java程序员呈现了一个一致的内存模型。

内存屏障的示意图

3.9.2 各种内存模型之间的关系

​ JMM是一个语言级的内存模型,处理器内存模型是硬件级的内存模型,顺序一致性内存模型是一个理论参考模型。

​ 处理器内存模型比语言内存模型要弱,处理器内存模型和语言内存模型都比顺序一致性内存模型要弱。同处理器内存模型一样,越是追求执行性能的语言,内存模型设计得会越弱。

3.9.3 JMM的内存可见性保证

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

各CPU内存模型的强弱对比示意图

​ 注意,最小安全性保障与64位数据的非原子性写并不矛盾。它们是两个不同的概念,它们“发生”的时间点也不同。最小安全性保证对象默认初始化之后(设置成员域为0、null或false),才会被任意线程使用。最小安全性“发生”在对象被任意线程使用之前。64位数据的非原子性写“发生”在对象被多个线程使用的过程中(写共享变量)。当发生问题时(处理器B看到仅仅被处理器A“写了一半”的无效值),这里虽然处理器B读取到一个被写了一半的无效值,但这个值仍然是处理器A写入的,只不过是处理器A还没有写完而已。最小安全性保证线程读取到的值,要么是之前某个线程写入的值,要么是默认值(0、null、false)。但最小安全性并不保证线程读取到的值,一定是某个线程写完后的值。最小安全性保证线程读取到的值不会无中生有的冒出来,但并不保证线程读取到的值一定是正确的。

3.9.4 JSR-133对旧内存模型的修补

​ JS-133对JDK5之前的旧内存模型的修补主要有两个。

​ 1)增强volatile的内存语义。旧内存模型允许volatile变量与普通变量重排序。JSR-133严格限制volatile变量与普通变量的重排序,使volatile的写-读和锁的释放-获取具有相同的内存语义。

​ 2)增强final的内存语义。在旧内存模型中,多次读取同一个final变量的值可能会不相同。为此,JSR-133为final增加了两个重排序规则。在保证final引用不会从构造函数内逸出的情况下,final具有了初始化安全性。

发布了156 篇原创文章 · 获赞 76 · 访问量 11万+

猜你喜欢

转载自blog.csdn.net/Howinfun/article/details/100037789
今日推荐