JMM—详细总结

JMM

Java并发的通信机制是通过共享内存实现的。线程之间共享程序的公共状态,线程通过读写内存中的公共状态进行隐式通讯。这对程序员是透明的,我们需要理解其工作机制,以防止内存可见性问题,从而编写出正确同步的代码。

同步:当有一个线程在对内存进行操作时,其他线程都不可以对这个内存地址进行操作,直到该线程完成操作。

内存可见性问题:java中线程通过共享变量的方式进行通讯,那么一个线程要跟另外一个线程进行通讯,什么时候将这个共享变量刷新到内存;另外一个线程什么时候该去内存中读取。这就是内存可见性问题。JMM就是解决这个问题的。

内存可见性问题,就是一个线程更新共享变量后,其他线程无法看到该共享变量最新的值。这就是内存可见性问题。

概述

Java内存模型,决定了一个线程对共享变量的写入何时对另外一个线程可见。有两点需要注意:

  • 这里何时指的并非时间而是某个动作的完成。有哪些动作呢?
    • 当写一个volatile变量时,JMM会把该线程对应的本地内存中的共享变量值刷到主内存中,同时使其它处理器中的缓存失效,让其去主内存中读取该值;
    • 还有synchronized的锁释放,CAS操作。
  • 同步是显式的,是需要我们来做的。JMM对未同步或未正确同步的多线程程序只提供最小的安全性,也就是JMM保证线程读取到的值不会无中生有,要么是之前线程写入的值,要么是默认值(0,null,false)。

首先来看下JMM下线程与主内存之间的关系问题。共享变量在主内存中,每个线程都有一个自己私有的本地内存,里面存储着内存中共享变量的副本。

本地内存是对缓存、写缓冲区、寄存器等的抽象

来看看JMM的抽象结构示意图:

image-20191012153824636

假设两个线程A,B。

A将其更新后的共享变量刷新到主内存,B到主内存中去读取该共享变量的值,实质上就是线程A在向线程B发送消息,基于的是主内存,JMM控制的就是主内存与每个线程的本地内存的交互。上面说Java线程间通信机制是隐式的,对程序员不可见,那么JMM就为我们提供了内存可见性的保证,对于正确同步的代码(指的是synchronized,volatile,final的运用),我们就可以得到正确的执行结果。

重排序

###关于重排序

重排序是指编译器和处理器为了优化程序性能,而对指令序列进行重新排序

重排序分为3种类型:

  • 编译器优化的重排序
    • 如果不存在数据依赖性,编译器可以重新安排语句的执行顺序
  • 指令级并行的重排序
    • 现代处理器采用了指令级并行技术,可以将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应指令的执行顺序。
  • 内存系统的重排序
    • 指的是处理器在执行指令时,使用缓存和读写缓冲区。使得指令看起来不是顺序执行的。写缓冲区可能导致写-读重排序。
    • image-20191111232800659

从Java源代码到最终执行的指令,会经历下面3种重排序:

image-20191111231113768

编译器、处理器重排序,导致多线程的程序出现内存可见性的问题。

  • 对于编译器,JMM编译器重排序的规则会禁止特定类型的重排序
  • 对于处理器重排序,Java编译器在生成指令序列的适当位置 插入特定类型的内存屏障指令,来禁止特定类型的处理器重排序

JMM就是通过此来确保在不同的编译器和处理器平台上的内存可见性保证。

数据依赖性

两个操作访问同一个变量,且至少有一个为写操作,则二者之间存在数据依赖性。

image-20191112130608277

编译器与处理器不会改变存在数据依赖关系的两个操作的执行顺序,因为对它们的重排序会改变程序的执行结果。

注意:数据依赖性指的是单个处理器的指令序列和单个线程中执行的操作,不同处理器和不同线程之间的数据依赖性不被考虑。也就是说,不同线程和不同处理器的指令不干扰,可以进行重排序。

关于内存屏障

image-20191012155729906

StoreLoader 屏障同时具有其它三个屏障的效果。执行该屏障开销会很昂贵,因为当前处理器通常要把写缓冲区的数据全部刷到内存中。

volatile的内存语义,final的内存语义都是通过上述内存屏障来实现的。

针对重排序,JMM的基本方针就是:在不改变正确同步的程序的执行结果的前提下,尽可能为编译器和处理器的优化打开方便之门。

as-if-serial

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

为了遵守as-if-serial语义,编译器和处理器不会对存在数据依赖关系的操作做重排序,因为这种重排序会改变执行结果。但是,如果操作之间不存在数据依赖关系,那么这些操作就可能被编译器和处理器做重排序。

as-if-serial把单线程程序保护起来。

重排序对多线程的影响

public 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
        }
    }
}

当一些操作不存在数据依赖性,可以进行重排序,但是如果还存在控制依赖性,进行重排序可能就会影响程序执行结果,比如上面的操作3和操作4交换执行顺序,就会使结果出错。但是控制依赖性又会导致并行度降低。

编译器和处理器为了提高并行度,使用猜测执行来克制控制依赖性对并发度的影响。以处理器猜测执行为例,该线程的处理器可以提前读取a,并且计算a*a的值,然后把结果放到重排序缓冲中。当操作3的条件判断为真,就把结果写入到变量i中。

**在单线程程序中,对存在控制依赖关系的操作重排序,不会改变程序的执行结果。**比如writer()和reader()函数在单线程中顺序执行,即1234操作顺序执行,并且1和2 happen-before 3和4。于是1和2、3和4可以重排序,但是不影响程序执行结果。

但是在多线程程序中,对存在控制依赖关系的操作重排序,可能会改变程序的执行结果。比如writer()在线程A中执行,reader()在线程B中执行,1和2不存在数据依赖性,3和4不存在数据依赖性。因此1和2可以进行重排序,3和4由于存在控制依赖性,使用猜测执行。假如经过重排序之后,执行顺序为 2431,那么i的结果为0。这就改变了程序的执行结果。

顺序一致性

如果程序是正确同步的,程序的执行将具有顺序一致性——即程序的执行结果与程序在顺序一致性内存模型中的执行结果相同。

顺序一致性内存模型

顺序一致性内存模型是一个理论参考模型,它为程序员提供了极强的内存可见性保证。顺序一致性模型的特征:

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

顺序一致性模型为程序员提供的视图如下:

image-20191113232359329

在概念上,顺序一致性模型有一个单一的全局内存,这个内存通过一个左右摆动的开关可以连接到任意一个线程,同时每一个线程必须按照程序的顺序来执行内存读/写操作。在任意时间点最多只能有一个线程可以连接到内存。

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

未同步程序的执行特性

对未同步或者未正确同步的程序,JMM提供最小安全性:线程读取到的值,要么之前某个线程写入的值,要么是默认值。

为了实现最小安全性,JVM在堆上分配对象时,首先会对内存空间进行清零,然后才会在上面分配对象,因此,在已清零的内存空间分配对象时,域的默认初始化已经完成了。

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

  • 顺序一致性模型会保证单线程内的操作会按照程序的顺序执行;而JMM不保证单线程内的操作会按照程序的顺序执行,因为可能会重排序。
  • 顺序一致性模型保证所有线程能看到一致的操作执行顺序,而JMM不保证所有线程能看到一致的操作执行顺序。
  • JMM不保证对64位long型和double型变量的写操作具有原子性,而顺序一致性模型能保证对所有的内存读/写操作都具有原子性。

第3个差异与处理器总线的工作机制密切相关。在计算机中,数据通过总线在处理器和内存之间传递。每次处理器和内存之间的数据传递都是通过一系列步骤来完成的,这一系列步骤称之为总线事务。

总线事务包括读事务和写事务。

  • 读事务从内存传送数据到处理器
  • 写事务从处理器传送数据到内存

每个事务会读/写内存中一个或多个物理上连续的字。这里的关键是,总线会同步试图并发使用总线的事务。在一个处理器执行总线事务期间,总线会禁止其他的处理器和I/O设备执行内存的读/写。下面,让我们通过一个示意图来说明总线的工作机制,如下所示。

image-20191113234650011

由图可知,假设处理器A、B和C同时向总线发起总线事务,这时总线仲裁会对竞争做出裁决,这里假设总线在仲裁后判定处理器A在竞争中获胜(总线仲裁会确保所有处理器都能公平的访问内存)。此时处理器A继续他的总线事务,而其他两个处理器则要等待处理器A的总线事务完成后才能再次执行内存访问。假设在处理器A执行总线事务期间(不管这个总线事务是读事务还是写事务),处理器D向总线发起了总线事务,此时处理器D的请求会被总线禁止。

总线的这些工作机制可以把所有处理器对内存的访问以串行化的方式来执行。在任意时间点,最多只能有一个处理器可以访问内存。这个特性确保了单个总线事务之中的内存读/写操作具有原子性。

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

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

image-20191113234722254

如上图所示,假设处理器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中都必须具有原子性(即任意读操作必须要在单个读事务中执行)。

锁的内存语义

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

锁除了让临界区互斥执行外,还可以让释放锁的线程 向 获取同一个锁的线程发送消息。

看下面代码实例:

image-20191116171802182

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

  • 根据程序顺序规则,1happen-before2,2happen-before3。 4happen-before5,5happen-before6
  • 根据监视器规则,3happen-before4
  • 根据happen-before的传递性:2happen-before5

锁的获取与释放的内存语义

当释放锁时,JMM会把该线程的本地内存的共享变量刷到主内存中。以上面MonitorExample为例,A释放锁后,共享数据的状态如下:

image-20191116182014490

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

image-20191116182209653

锁的释放与volatile写有相同的内存语义;锁的获取与volatile读有相同的内存语义。

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

锁内存语义的实现

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

ReentrantLock分为公平锁和非公平锁。

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

非公平锁获取时,首先会用CAS更新volatile变量的值。

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

  • 利用volatile变量写-读的内存语义
  • 利用CAS附带的volatile读和volatile写的内存语义

concurrent包的实现

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

  • A线程写volatile变量,随后B线程读这个volatile变量
  • A线程写volatile变量,随后B线程使用CAS更新这个volatile变量
  • A线程用CAS更新一个volatile变量,随后B线程用CAS更新这个volatile变量
  • A线程用CAS更新一个volatile变量,随后B线程读这个volatile变量

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

  • 首先,声明变量为volatile
  • 然后,使用CAS原子更新实现线程之前的同步
    • 使用CAS实现乐观锁,可以实现复合操作的原子性,保证线程安全
  • 同时,以volatile变量的读写 和 CAS所具有的volatile读写内存语义 来实现线程之间的通讯

AQS、原子变量类,这些concurrent包中的基础类都是这种模式实现的,而concurrent包中的高层类又依赖于这些基础类来实现,从整体看:

image-20191116185856617

final域的内存语义

final域的重排序规则

对于final域,编译器和处理器遵守2个重排序规则:

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

下面通过一个示例性代码来说明问题:

image-20191117161927890

这里假设一个线程A执行writer()方法,随后另外一个线程B执行reader()方法。下面我们通过这个两个线程的交互来说明这两个规则:

写final域的重排序规则

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

  • JMM禁止编译器把final域的写重排序到构造函数外。
  • 编译器会在final域的写之后,构造函数return之前,插入一个StoreStore屏障。这个屏障禁止把final域的写重排序到构造函数之外。(构造函数返回也是一个写操作)

现在让我们分析writer()方法。writer()方法只包含一行代码:finalExample = new FinalExample();,这个代码包含2个步骤:

  • 构造一个FinalExample类型的对象
  • 把这个对象的引用赋值给引用变量obj

图3-29是一种可能的执行顺序:

image-20191117163724577

在图3-29中,写普通域的操作被编译器重排序到构造函数外(return之后),线程B错误的读取了普通变量i初始化之前的值。而写final域的重排序规则限定了final域的写在构造函数内,线程B正确的读取到了final变量初始化之后的值。

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

读final域的重排序规则

读final域的重排序规则是:在一个线程中,初次读对象的引用 和 初次读该对象包含的final域,JMM禁止处理器重排序这两个操作(注意,这个规则仅仅针对处理器)。编译器会在读final域前面加入一个LoadLoad内存屏障。

初次读对象的引用 和初次读该对象包含的final域,这两个操作之间存在间接依赖关系。因此一般的编译器不会对这两个操作进行重排序。但是少数处理器允许这两个操作的重排序(比如说alpha处理器),这个规则就是专门针对这种处理器的。

reader()方法包含3个操作:

  • 初次读引用变量obj
  • 初次读引用变量obj指向的对象的普通域j
  • 初次读引用变量obj指向对象的final域

现在假设线程A没有发生重排序,同时程序在不遵守间接依赖关系的处理器上执行,图3-30是一种可能的执行顺序

image-20191117165412348

读对象的普通域被重排序到读对象引用的操作 之间。读普通域时,该域还没有被线程A写入,因此这个是一次错误的读取。而读对象final域会被限定在读对象引用操作之前,从而保证读final域时,final域已经被初始化了,这是一个正确的读取操作。

final域为引用类型

上面我们看到的final域是基础数据类型,如果final域是引用类型,将会有什么效果?请看下面的示例代码:

image-20191117171114885

本例final域是一个引用类型,它引用int类型的数组对象。对于引用类型,final域的写重排序规则会增加一个约束: **在构造函数内,对final域引用的对象的成员域的写入 和 随后在构造函数外把这个对象的引用赋值给一个引用变量 ** 这两个操作不能重排序。

对上面示例,假设线程A先执行writerOne()方法,执行完后线程B执行writerTwo()方法,执行完后线程C执行reader()方法,图3-31是一种可能的执行顺序。

image-20191117171553179

在图3-31中,1是对final域的写入,2是对final域引用对象的成员域的写入,3是被构造对象的引用的赋值给引用变量。这里除了前面提到的1和3不能重排序外,2和3也不能重排序。

为什么对象引用不能从构造函数内溢出

前面我们提到过,写final域的重排序规则可以保证:在引用变量为任意线程可见之前,该引用变量指向对象的final域一定被正确的初始化了。其实,要达到这个效果,还需要一个保证:在构造函数内,不能让这个被构造对象的引用为其他线程可见,即对象的引用不能在构造函数中溢出了。

为了说明问题,看下面示例代码:

image-20191117172540555

假设线程A执行writer()方法,另外一个线程B执行reader()方法。这里的操作2使得对象的还没完成构造前就为线程B可见。即时这里的操作2是构造函数的最后一步,且在程序中操作2排在操作1后面,执行reader()方法的线程仍然可能无法看到final域被初始化后的值,因为操作1和操作2可能被重排序,所以执行时序可能如下:

image-20191117172801963

为了避免线程看到未初始化的变量,需要避免对象引用在构造函数内逸出。

final语义在处理器中的实现

以x86处理器为例,说说final语义在处理器中的实现。

上面我们提到,写final域的重排序规则会要求编译器在final域写之后,构造函数return之前插入StoreStore内存屏障。读final域之前插入LoadLoad内存屏障。

由于x86不会对写-写操作重排序,因此写final操作的StoreStore屏障会被省略掉。

由于x86不会对间接依赖关系的操作做重排序,所以读final操作的LoadLoad屏障也会被省略掉。

也就是说,在x86中,读写final不会插入任何内存屏障。

JSR133为什么增强final的语义

在旧的内存模型中,线程可能看到final域的值会改变,比如说一个线程先看到整型finl域的值是初始化之前的值0,后面看到初始化后的值1。

为了解决这个问题,专家们就增强了final语义。只要构造函数不发生对象引用逸出,那么不需要同步就可以保证任意线程看到final域在构造函数中被初始化之后的值。

happens-before

happens-before是JMM最核心的概念。用来阐述操作之间的内存可见性。

如一个操作的执行结果需要对另外一个操作可见,那么这两个操作之间必须存在happens-before关系

这两个操作可以在一个线程内,也可以在不同线程之间。

happens-before是JMM最核心的概念,程序员基于它的内存可见性保证来编程。

JMM通过happens-before向程序员提供跨线程的内存可见性保证。(比如A线程的写操作a 和 B线程的读操作b 之间存在happens-before关系,那么JMM保证a操作将对b操作可见,即时在不同线程中)

image-20191012212205542

happens-before的定义

1、如果一个操作发生在另外一个操作之前,第一个操作的结果将对第二个操作可见

我们也就是依据此保证来理解阅读源码的。

2、两个操作存在happens-before关系,编译器、处理器也不一定按照happens-Before指定的顺序执行。还是可能进行重排序的,只不过重排序执行的结果和 按照happens-Before关系来执行的结果一样就可以了。

上面1是JMM对程序员的承诺。从程序员的角度来说,可以这样理解happens-before关系:如果a happens-before b,那么JMM想程序员保证——A的操作结果将对B可见,且A的执行顺序排在B之前。注意,这只是JMM对程序员做出的保证。

上面2是JMM对编译器、处理器重排序的约束规则。只要不改变程序执行结果,编译器和处理器怎么优化都行。happens-before和as-if-serial本质上是一回事:

  • as-if-serial保证单线程内程序的执行结果不被改变。happens-before保证正确同步的多线程的执行结果不被改变
  • as-if-serial给编写单线程程序的程序员创造了一个假象:单线程程序是按照程序顺序来执行的。happens-before给编写正确同步的多线程程序的程序员一个假象:正确同步的多线程程序是按照happens-before指定的顺序来执行的。
  • as-if-serial和happens-before这样做的目的,都是为了不改变程序执行结果的前提下,尽可能的提高程序执行的并行度。

happens-before不要求前一个操作和后一个操作的发生顺序, 仅仅要求前一个操作的执行完成并刷新回内存发生在后一个操作读取结果之前

hannpens-before规则

  • 程序顺序规则
    • 指的是一个线程中的每个操作,都hannpens-before于该线程中任意后续的操作
  • 监视器锁的规则
    • 对一个锁的解锁happens-before于随后对这个锁的加锁
  • volatile变量的规则
    • 对一个volatile域的写 happens-before于任意后续对这个volatile域的读。
  • 传递性
    • A happens-before B, B happens-before C,那么A happens-before C
  • start()规则
    • 如果线程A中执行ThreadB.start()启动线程B,那么线程A的ThreadB.start() happens-before于 线程B中任意操作
    • 假如线程A修改了共享变量,然后执行ThreadB.start(),那么通过start()规则,线程B可以看到共享变量被线程A修改后的值
  • join()规则
    • 如果线程A执行ThreadB.join()并且成功返回,那么线程B中任意操作 happens-before 线程A从ThreadB.join()成功返回。
    • 假如线程A执行ThreadB.join(),线程B修改了共享变量,然后ThreadB.join()成功返回,那么线程A可以看到线程B对共享变量的修改。

happens-before与JMM之间的关系

image-20191012233427623

一个happens-before规则其背后的实现依赖于多个编译器和处理器的重排序规则,我们不需要去掌握这些复杂的重排序规则及他们的实现,我们只需根据happens-before的规则来编程。

想想自己在阅读JUC下源码时是怎么理解那些正确同步的代码的:

  • 我们看到synchronized会想到互斥,锁的释放还会引起共享变量的刷新,一个线程的对锁的释放与随后获取的线程实质上是在通信;
  • 看到volatile会想到它的读/写是原子的,且与锁的获取/释放具有相同的内存语义;
  • 看到循环CAS想到原子操作,且它具有volatile读/写的内存语义;
  • 对于代码的执行顺序我们都默认是按顺序的,我们认为程序是按代码顺序来执行的,可编译器与处理器是会重排序的。
  • 那是谁给了你这种保障,让你有这种按顺序执行的幻觉?是JMM,你只要按照happens-before规则来编程,编写的程序是正确同步的,你就可以按顺序来理解它,编译器和处理器的重排序不会影响到你,因为JMM对他们的限制,禁止了那些会改变执行结果的重排序。

关于JMM与顺序一致性模型

顺序一致性模型是一个理论参考模型,JMM和处理器内存模型在设计时通常以它为参照。JMM对正确同步的多线程程序的内存一致性做了如下保证:正确同步的程序的执行具有一致性,即程序的执行结果与该程序在顺序一致性内存模型中的执行结果相同。

首先模型有两大特点:

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

2,每个操作必须是原子且立即对所有线程可见,这样所有线程都将看到一个单一的操作执行顺序。

顺序一致性模型下的多线程程序执行情况:

假设有一个正确同步程序,A线程3个操作执行后释放监视器锁,随后B获取该锁执行。其执行效果图:

image-20191012234115144

对于正确同步程序JMM与顺序一致性模型执行的不同:JMM中临界区内的代码可以重排序,只要不改变程序执行结果。

image-20191012234156395

双重检查与延迟初始化

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

双重检查锁定的由来

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

image-20191117185504371

如上如所示,假设A线程执行代码1的同时,B线程执行代码2.此时,线程A可能会看到instance引用的对象还没有完成初始化。
对于上图代码来说,我们可以改造getInstance()方法做同步处理来实现线程安全的延迟初始化:

image-20191117185520519

如上图所示,虽然对getInstance()方法做了同步处理,但如果多个线程频繁调用该方法,则会导致性能的下降。因此,人们想出一个”聪明“的技巧:双重检查锁定。即通过双重检查锁定来降低同步的开销:

image-20191117185535670

虽然看起来上图代码即能保证线程安全同时也可以降低同步的开销,但这是一个错误的优化:在线程执行到第4行时,代码读取到instance不为null时,instance引用的对象有可能还没有完成初始化。

问题的根源

前面的实例代码第7行,可以分解为以下3行伪代码:

image-20191117185608421

上面3行伪代码之间,2和3可能发生重排序。在单线程中并不会出现问题,因为单线程中,始终是先初始化然后再访问。

但如果在多线程中,就会出现问题,时序图如下:

image-20191117185936185

如上图所示,如果线程A中,2和3发生重排序,则在线程B中判断instance不为空,并访问对象时,该对象是未完成初始化的。也就是说线程B访问了一个未初始化的对象。

为了解决上述问题,我们可以用以下方法来实现线程安全的延迟初始化:
- 不允许2和3重排序。(基于volatile的解决方案)
- 允许2和3重排序,但不允许其他线程”看到“这个重排序。(基于类初始化的解决方案)

基于volatile的解决方案

对于上述问题,可以把instance声明为volatile型,就可以实现线程安全的延迟初始化。

image-20191117190040315

若instance声明为volatile类型,则会禁止伪代码中2和3的重排序,因为volatile写的内存语义保证,如果第二个操作为volatile写时,无论第一个操作是什么,都不允许进行重排序。
优化后的执行时序为:

image-20191117190155347

基于类初始化的解决方案

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

image-20191117190327275

假设两个线程并发执行getInstance()方法,则执行示意图如下:

image-20191117190346464

在上图代码中,首次执行getInstance()方法的线程将导致InstanceHolder类被初始化。在Java语言规范中规定:对于每一个类或接口C,都有一个唯一的初始化锁LC与之对应。JVM在类初始化期间会获取这个初始化锁,并且每个线程至少获取一次锁来确保这个类已经被初始化过了。(JVM具体实现中可能会做一些优化)

Java初始化一个类或接口的处理过程如下:

  • 第1阶段:通过在Class对象上的同步(即获取Class对象的初始化锁),来控制类或接口的初始化。这个获取锁的线程会一直等待,直到当前线程能够获取到这个初始化锁。
    • 假设Class对象当前还没被初始化(假设初始化状态state=noInitialization),且有两个线程A和B试图同时初始化这个Class对象,对应的示意图及时序表如下:
    • image-20191117190524637
    • image-20191117190553988
  • 第2阶段:线程A执行类的初始化,同时线程B在初始化锁对应的condition上等待。对应的示意图及时序表如下:
    • image-20191117190629380
    • image-20191117190644810
  • **第3阶段:线程A设置state=initialized,然后唤醒在condition中等待的所有线程。**对应的示意图及时序表如下:
    • image-20191117190718242
    • image-20191117190735617
  • **第4阶段:线程B结束类的初始化处理。**对应的示意图及时序表如下:
    • image-20191117190801274
    • 线程A在第2阶段的A1执行类的初始化,并在第3阶段的A4释放初始化锁;线程B在第4阶段的B1获取同一个初始化锁,并在第4阶段的B4之后才开始访问这个类,根据Java内存模型规范的锁规则,存在happens-before关系。happens-before锁规则保证,线程A执行类的初始化时的写入操作,对线程B可见。
  • **第5阶段:线程C执行类的初始化的处理。**对应的示意图及时序表如下:
    • image-20191117190915391
    • 在第3阶段后,类已经完成了初始化。因此线程C在第5阶段的类初始化过程相对简单一些。其目的是确保该类已经被初始化完毕。
    • 这里的condition和state标记是虚拟出来的。Java语言规范并没有硬性规定一定要使用condition和state标记。JVM的具体实现只要实现类似功能即可。

通过对比基于volatile的双重检查锁定的方案和基于类初始化的方案,我们会发现基于类初始化的方案的实现代码更简洁。但基于volatile的双重检查锁定的方案有一个额外优势:除了可以对静态字段实现延迟初始化外,还可以对实例字段实现延迟初始化。

字段延迟初始化降低了初始化类或创建实例的开销,但增加了访问被延迟初始化的字段的开销。在大多数时候,正常的初始化要优于延迟初始化。如果确实需要对实例字段使用线程安全的延迟初始化,请使用基于volatile的延迟初始化方案,如果确实需要对静态字段使用线程安全的延迟初始化,请使用基于类初始化的方案

基于类的延迟初始化为什么只能对静态字段进行延迟初始化?

因为访问类的静态变量的时候,会触发类的初始化。并且会同步初始化类。基于2个特性,产生了类初始化方案。而访问实例化字段的时候,不会触发类的初始化。

参考

《并发编程实战》

https://blog.csdn.net/sinat_34976604/article/details/88762147

  • 3
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值