单例模式双重检查锁定与延迟初始化你不得不知道的底层原理

Posted 李子捌

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了单例模式双重检查锁定与延迟初始化你不得不知道的底层原理相关的知识,希望对你有一定的参考价值。

简介

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

1、双重检查锁定的由来

在Java程序中,有时候可能需要推迟一些高开销的对象初始化操作,并且只有在使用这些对象时才进行初始化。此时,程序员可能会采用延迟初始化。但要争取实现线程安全的延迟化需要一些技巧,以此来避免不必要的问题。
非线程安全延迟初始化代码示例:

package com.lizba.p1;

/**
 * <p>
 *      实例对象
 * </p>
 *
 * @Author: Liziba
 * @Date: 2021/6/12 22:42
 */
public class Instance {

    public Instance() {
        System.out.println("init...");
    }

}
package com.lizba.p1;

/**
 * <p>
 *      延迟初始化
 * </p>
 *
 * @Author: Liziba
 * @Date: 2021/6/12 22:40
 */
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对象未完成初始化(后续会讲问题根源)。

同步处理解决方法:

package com.lizba.p1;

/**
 * <p>
 *
 * </p>
 *
 * @Author: Liziba
 * @Date: 2021/6/12 22:46
 */
public class SafeLazyInitialization {

    private static Instance instance;

    public synchronized static Instance getInstance() {
        if (instance == null) {             // 线程A执行
            instance = new Instance();      // 线程B执行
        }
        return instance;
    }
    
}

给getInstance()方法做了同步处理,synchronized会带来性能开销。在getInstance()调用不频繁的情况下,这种解决方案是可以接收的,但是如果getInstance()被频繁调用,程序的整体性能将会下降。(尤其是在早期JVM中,没有锁升级策略的时候)。

双重检查锁解决方法:

package com.lizba.p1;

/**
 * <p>
 *      双重检查锁
 * </p>
 *
 * @Author: Liziba
 * @Date: 2021/6/12 22:51
 */
public class DoubleCheckedLocking {

    private static Instance instance;

    public static Instance getInstance() {
        if (instance == null) {                                 // 第一次检查
            synchronized (DoubleCheckedLocking.class) {         // 加锁
                if (instance == null) {                         // 第二次检查
                    instance = new Instance();                  // 仍然存在问题的代码
                }
            }
        }
        return instance;
    }

}

如上代码,如果第一次检查instance不为null,那么久不需要执行加锁和初始化工作,可以极大的减少synchronized带来的性能开销,但是双重检查锁也存在一个问题,就是判断instance == null这行代码可能会在Instance未正确初始化的时候成立,这个问题产生的原因是指令重拍,下面会详细讲述,也可以看我往期的文章哈!因此这是一个错误的不完美的解决方案。

2、问题的根源

2.1 分析 instance = new Instance();

instance = new Instance(); 这行代码在可以理解为三行伪代码(JVM中的指令):

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


上述代码2和3可能会被重排序(部分JIT编译器真实存在),重排序后如下所示:

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

由于上述重排序,遵守Java程序执行时必须遵守的intra-thread semantics,重排序并未改变在单线程中程序执行结果,且如果该重排序能带来性能优化则是被Java语言规范《The Java Language Specification》允许的。

2.2 分析什么是intra-thread semantics

单线程内instance = new Instance(); 执行时序图:
在这里插入图片描述

线程执行时序图

多线程内instance = new Instance(); 可能存在的一种执行时序图:
在这里插入图片描述

多线程执行时序图

由于单线程内要遵守intra-thread semantics,从而保证线程A的执行结果不会被改变;但是在上图多线程执行中,线程B可能读到一个未正确完成初始化的Instance对象。
回到DoubleCheckedLocking这个示例代码中,线程B可能在第一次instance == null判断时为真,线程B接下来将访问instance引用指向的对象,但是此时这个对象并没有初始化完成。

多线程执行时序表:

时间线程A线程B
t1A1:分配对象的内存空间
t2A3:设置instance指向内存空间
t3B1:判断instance是否为null
t4B2:由于instance不为null,线程B将访问instance引用的对象
t5A2:初始化对象
t6A4:访问instance引用的对象

2.3 分析问题关键点

有上述的时序图表和解释我们不难发现,出现的问题是对象instance实例化时指令重排序导致对象“逸出”了,因此我们有如下两种解决思路:

  1. 不允许2和3重排序
  2. 运行2和3重排序,但是不允许其他线程“看到”这个重排序

下面讲述具体实现方案。

3、基于volatile的解决方案

在DoubleCheckedLocking上做小修改即可(需要基于JDK1.5及以上)

package com.lizba.p1;

/**
 * <p>
 *      双重检查锁正确示例,JDK1.5及以上
 * </p>
 *
 * @Author: Liziba
 * @Date: 2021/6/12 22:51
 */
public class DoubleCheckedLocking {

//    private static Instance instance;
    private volatile static Instance instance;

    public static Instance getInstance() {
        if (instance == null) {                                 // 第一次检查
            synchronized (DoubleCheckedLocking.class) {         // 加锁
                if (instance == null) {                         // 第二次检查
                    instance = new Instance();                  // instance为volatile,问题得以解决
                }
            }
        }
        return instance;
    }

}

声明instance为volatile引用变量时,2和3的重排序会被禁止,执行时序图如下:
在这里插入图片描述

多线程执行时序图

该方案是通过禁止重排序来实现。

4、基于类初始化的解决方案

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

package com.lizba.p1;

/**
 * <p>
 *      实例工厂
 * </p>
 *
 * @Author: Liziba
 * @Date: 2021/6/12 23:52
 */
public class InstanceFactory {

    private static class InstanceHolder {
        public static Instance instance = new Instance();
    }

    public static Instance getInstance() {
        return InstanceHolder.instance;
    }

}

假设线程A和线程B同时执行getInstance()方法,下面是执行示意图:
在这里插入图片描述

这个方案实质上是运行重排序,但是不允许非构造线程B看到未实例化完成的对象,利用了JVM类初始化的特性。

初始化一个类包括执行这个类的静态初始化和初始化在这个类中声明的静态字段。
那么类什么时候会被初始化呢?在Java语言规范中,首次发生如下情况中的任意一种,一个类或者一个接口类型T将会被立即初始化:

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

在InstanceFactory示例代码中,符合情况4,InstanceHolder中静态字段instance被使用,导致触发InstanceHolder对象的初始化,从而初始化Instance对象。
在Java代码执行过程中,会存在多线程同时尝试去初始化一个类或者一个接口,因此在Java语言规范中,会要求具体的JVM实现对这个过程做同步处理。(实现规范是每个类或者接口有一个唯一的初始化锁LC与之对应。从C到LC的映射,由JVM去实现)。

5、Java初始化类或接口的具体过程

我们来看看《Java并发编程艺术》的作者是如何通过5个步骤阐述这个过程的。

5.1 第一阶段

通过在Class对象上同步(获取Class对象的初始化锁),来控制类或接口的初始化。这个获取锁的线程会一直等待,知道当前线程能够获取到这个Class对象的初始化锁。
假设线程A和线程B同时初始化一个未被初始化的Class对象(初始化状态state,此时被标记为state=noInitialization),图示如下:
在这里插入图片描述

类初始化-第一阶段

类初始化-第一阶段执行时序表:

时间线程A线程B
t1A1:尝试获取Class对象的初始化锁。这里假设线程A获取到初始化锁。B1:尝试获取Class对象的初始化锁,由于线程A获取到了锁,线程B等待获取初始化锁
t2A2:线程A看到对象还未被初始化(state=noInitialization),线程设置state=noInitializating
t3A3:线程A释放初始化锁

5.2 第二阶段

线程A执行类的初始化,同时线程B在初始锁对应的condition上等待。
图示如下:
在这里插入图片描述

类初始化-第2阶段

类初始化-第二阶段执行时序表:

时间线程A线程B
t1A1:执行类的静态初始化和初始化类中声明的静态字段B1:获取到初始化锁
t2B2:读取到state=initializing
t3B3:释放初始化锁
t4B4:在初始化锁的condition中等待

5.3 第三阶段

线程A设置state=initialized,然后唤醒等待在condition上的所有线程
在这里插入图片描述

类初始化-第3阶段

类初始化-第三阶段执行时序表:

时间线程A
t1A1:获取初始化锁
t2A2:设置state=initialized
t3A3:唤醒在condition中等待的所有线程
t4A4:释放初始化锁
t5A5:线程A的初始化过程完成

5.4 第四阶段

线程B结束类的初始化处理
在这里插入图片描述

类初始化-第4阶段

类初始化-第四阶段执行时序表:

时间线程B
t1B1:获取初始化锁
t2B2:读取到state=initialized
t3B3:释放初始化锁
t4B4:线程B的类的初始化过程完成

第五阶段

线程C执行类的初始化处理
在这里插入图片描述

类初始化-第5阶段

类初始化-第五阶段执行时序表:

时间线程C
t1C1:获取初始化锁
t2C2:读取到state=initialized
t3C3:释放初始化锁
t4C4:线程B的类的初始化过程完成

由于在第三阶段已经完成了类的初始化,因此线程C执行类的初始化过程相对简单。

6、总结

通过对比基于volatile的双重锁定的方案和基于类初始化的方案,发现使用类初始化的方案实现的代码更加简洁。但是基于volatile的双重检查锁定的方案有一个额外的优点就是其不仅可以对静态字段实现延迟初始化,也可以对实例字段实现延迟初始化(因为JVM类初始化这个方案只能初始化静态字段)。字段延迟初始化降低了初始化类和创建实例带来的开销,但也增加了访问被延迟初始化的字段的开销。而在实际开发中正常的初始化要优于延迟初始化。
如果确定要进行延迟初始化,那么具体如何选择呢?

  • 实例字段延迟初始化使用volatile方案
  • 静态字段延迟初始化使用类初始化方案

文章总结至《Java并发编程艺术》,Java内存模型的总结到此就完全结束了,花费了不少晚上。虽然文章知识点来自书本,但是作者也做了如下工作:

  1. 文章的重点知识做了梳理和标记
  2. 对黑白图片做了彩色画图,使其更加易懂
  3. 对部分繁琐的知识点做了概括
  4. 对少部分错误的知识(主要是错字)进行了勘误
  5. 对每一句代码做了全部重写和注释

码字不易,多多关注。

以上是关于单例模式双重检查锁定与延迟初始化你不得不知道的底层原理的主要内容,如果未能解决你的问题,请参考以下文章

单例模式你不得不知道的底层原理

双重检查锁定的单例模式和延迟初始化

双重检查锁定与延迟优化

为什么双重检查锁模式需要 volatile ?

双重检查锁定

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