并发学习笔记11-双重检查锁定与延迟初始化

  • 2020 年 1 月 22 日
  • 筆記

并发学习系列以阅读《Java并发编程的艺术》一书的笔记为蓝本,汇集一些阅读过程中找到的解惑资料而成。这是一个边看边写的系列,有兴趣的也可以先自行购买此书学习。 本文首发:windCoder.com

关于双重检测锁定,了解过单例的应该不陌生,但也容易写错。这里以单例模式为例一起探索。

问题分析

首先看一下基本的懒汉模式

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

我们都知晓这种方式不是线程安全的,在多线程吓不能正常工作:当A线程执行代码1的同时,B线程执行代码2.此时,A线程可能会看到instance引用的对象还未初始化。

对此,我们可以对getInstance()方法做同步处理来实现线程安全的延迟初始化,其优化如下:

public class Singleton {      private static Singleton instance;        private Singleton(){}        public synchronized static Singleton getInstance() {          if(instance==null) {              instance = new Singleton();          }          return instance;      }  }

由于对getInstance()方法做了同步处理,synchronize将导致性能开销。如果getInstance()方法被多个线程频繁的调用,将会导致程序执行性能的下降。

在早期JMM中,synchronized(甚至是无竞争的synchronized)存在巨大性能问题。为了继续优化,因此人们想出了一个“聪明”的技巧,即双重检查锁定(Double-Checked Locking,简称DCL):

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

上面的看起来两全其美: – 多个线程试图在同一时间创建对象时,会通过加锁来保证只有一个线程能创建对象。 – 在对象创建后,执行getInstance()方法将不需要获取锁,直接返回已创建好的对象。

问题根源

虽然看起来完美,但是一个错误的优化。在线程执行到第4行,代码读取到instance不为null时,instance引用的对象可能还没初始化完成。下面看一下具体根源。 实例化一个对象(如第7行的instrance=new Singleton();)要分三个步骤: – 1.分配对象的内存空间。 – 2.初始化对象。 – 3.设置instance指向(即将内存空间分配给对应的引用)

但由于重排序,2和3可能发生重排序(在一些JIT编译器上,这种重排序是真实发生的),其过程如下: – 1.分配对象的内存空间。 – 3.设置instance指向(即将内存空间分配给对应的引用)—注意:此时对象还未被初始化。 – 2.初始化对象。

所有线程在执行Java程序时必须要遵守intra-thread semantics。intra-thread semantics保证重排序不会改变单线程内的程序执行结果。

上面的2、3的重排序在没改变单线程程序的执行结果的前提下,可以提高程序的执行性能,故并未违反intra-thread semantics。然A线程正常执行时,B线程将看到一个还没被初始化的对象:B线程会导致第二个判断出错,instance != null,但它获得的仅是一个地址,此时A线程还未初始化,故B线程返回的instance对象是一个没有初始化的对象,如图:

知晓问题根源后,可以想到两个办法来解决: – 1.不允许2和3重排序。 – 2.允许2和3重排序,但不允许其他线程“看到”这个重排序。

解决方案

基于volatile的解决方案

对于上面基于DCL方案只需做一点小的修改即可,亦既把instance声明为volatile型:

public class Singleton {      private volatile static Singleton instance;      private Singleton(){}        public static Signleton getInstance() {          if(instance == null) {              synchronized(Singleton.class) {                  if(instance == null) {                      instrance = new Singleton();                  }              }          }          return instance;      }  }

当instance声明为volatile后,步骤2、3的重排序在多线程环境中将会被禁止,从而解决问题。该解决方案需要JDK5及以上。

基于类初始化的解决方案

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

基于该特性,可以实现另一种线程安全的延迟初始化方案,该方案被称之为Initialization On Demand Holder idiom:

public class Singleton {      private static class InstanceHolder {          public static Singleton instance = new Singleton();      }      private Singleton(){}        public static Signleton getInstance() {          return InstanceHolder.instance; // 这里将导致 InstanceHolder类被初始化      }  }

该方案的解决是指是:允许2和3重排序,但不允许非构造线程(此处指B线程)“看到”这个重排序。执行示意图如下:

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

  • 1.T是一个类,而且一个T类型的实例被创建。
  • 2.T是一个类,且T中声明的一个静态方法被调用。
  • 3.T中声明的一个静态字段被赋值。
  • 4.T中声明的一个静态字段被使用,而且这个字段不是一个常量字段。
  • 5.T是一个顶级类(Top Level Class),而且一个断言语句嵌套在T内部被执行。

在Singleton中,首次执行getInstance()方法的线程将导致InstanceHolder类被初始化(符合情况4)。

  • Java语言规定,对于每个类或者接口C,都有一个唯一的初始化锁LC与之对应。
  • 从C到LC的映射,由JVM的具体实现去自由实现。
  • JVM在类初始化期间会获取这个初始化锁,并且每个线程至少获取一次锁来保证这个类已经被初始化过了。