并发编程笔记6--双重检查锁与延迟初始化

在java多线程程序中,有时候需要采用延迟初始化的方法,来降低初始化类或者创建对象的开销。双重检查锁定是常见的延迟初始化技术,但是他是一个错误的用法。

一、双重检查锁定的由来

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

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

对于上面的代码,当线程A执行代码1时,线程B正在执行代码2,此时线程A可能看到instance引用的对象还未完成初始化。(原因在二、问题的根源中)

对于上面UnsafeLazyInitialization类中出现的问题,我们可以在方法上增加synchronized来做同步处理来实现线程安全的延迟初始化操作。示例代码如下:

复制代码
public class SafeLazyInitialization {
    
    private static Instance instance;
    
    public static synchronized 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
        }                                                              //10
        
        return instance;                                               //11
    }
}

对于上面的代码,当第一次检查instance不为空时,那么就不需要执行下面的加锁和初始化操作。因此可以大幅度降低synchronized带来的性能开销。上面的代码看起来似乎两全其美。

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

双重检查锁定,看起来似乎是很完美,但是这是一个错误的优化!在线程执行到第四行,代码读取到的instance不为null时,此时instance引用的对象可能还未完成初始化操作。

二、以上问题出现的根源

对于出现这种情况的原因在于instance=new Instance():

当创建一个对象时,其步骤可以分为3步伪代码:

memory=allocate(); //1:分配内存

ctrorInstance(memory); //2:初始化对象

instance=memory; //3:设置instance指向刚分配的内存地址

由于对象的创建不是原子操作的,对于上面对象创建涉及到的伪代码2,3来说可能会被重排序,变成

memory=allocate(); //1:分配内存

instance=memory; //3:设置instance指向刚分配的内存地址

// 注意此时对象还未完成初始化

ctrorInstance(memory); //2:初始化对象

在java语言规范中,规定所有线程在执行java代码时,必须遵守,intra-thread semantics。intra-thread semantics保证重排序不会改变单线程内的程序执行结果。也就是说,intra-thread semantics允许那些在单线程内,不会改变程序执行结果的重排序。上面的3行伪代码中的2和3虽然被重排序成了3,2但是这个重排序并没有违反intra-thread semantics。这个重排序并没有改变上面代码在单线程程序内的执行结果。

为了更好理解intra-thread semantics ,看下图(假设一个线程创建对象后,立即访问这个对象):

只要保证2在4前面,就不会违反intra-thread semantics协议。

下面看下多线程并发执行的情况:

由于单线程内要遵守intra-thread semantics协议,从而能保证A线程的执行结果不变。但是当线程A和B按照上图时序执行时,B线程将看到一个还没有初始化完成的对象。

回到本文,DoubleCheckedLocking示例代码在执行到第七行时(instance=new Instance()),如果发生重排序,另一个并发执行的线程B就有可能在第4行判断instance是否为null,判断为不为null,线程B接下来就访问instance引用的对象,但此时这个对象尚未被完全初始化。其具体的执行时序如下:

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

三、解决方案

3.1、基于volatile的解决方案

对于双重检查锁定来实现延迟初始化方案,只需要修改一点就可以实现多线程安全的延迟初始化方案。示例代码如下:

复制代码
public class SafeDoubleCheckLocking {
    
    private volatile static Instance instance;
    
    public static Instance getInstance(){
        if (instance==null){
            synchronized (SafeDoubleCheckLocking.class){
                if (instance==null){
                    instance=new Instance();
                }
            }
        }
        
        return instance;
    }
}

在JMM中的happens-before规则保证了多线程程序下,线程间的内存可见性。其有一条规则规定:对一个volatile域的写,happens-before于任意后续对这个volatile域的读。

所以我们使用volatile修饰变量可以实现线程安全的延迟初始化方案。

3.2、基于类初始化方案解决

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

基于这个特性可以说实现另一种线程安全的延迟初始化方案。示例代码如下:

复制代码
public class InstanceInitializationFactory {

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

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

假设两个线程同时并发执行getInstance()方法,下面是执行的示意图:

初始化一个类包括,执行这个类的静态初始化,和初始化这个类中定义的静态字段。根据Java语言规范,发生以下几种情况时,一个类或者接口类型T会被立即初始化。

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

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

由于Java是多线程的,这就有可能有多个线程在同一时刻初始化同一个类或者接口,因此Java在初始化时,需要做一个细致的同步处理。

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

在《Java并发编程的艺术》一书中,作者将初始化过程分成5个阶段。
第一阶段:通过在Class对象上同步(即获取Class对象的初始化锁),来控制类或接口的初始化。这个获取锁的线程会一直等待,直到当前线程能够获取到这个初始化锁。

假设,类还未被初始化(初始化状态位state,此时被标记为state=noIniaialization),且两个想成A,B视图同时初始化这个Class对象。示意图如下:

第一阶段执行时序表如下:

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

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

第二阶段示意图如下:

第二阶段时序图如下:

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

第三阶段:线程A设置state=Initialized,然后唤醒在Condition中等待的所有线程 。示例图如下:

第三阶段时序图如下:

时间 线程A 线程B
t1 A1:获取初始化锁
t2 A2:设置state=initialized
t3 A3:唤醒初始化锁Condition等待的所有线程
t4 A4:释方初始化锁
t5 A5:线程A结束类初始化处理过程

第四阶段:线程B结束类的初始化处理 。示例图如下:

第四阶段时序图如下:

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

第五阶段:线程C执行类的初始化的处理 ,示例图如下:

第五阶段时序图如下:

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

PS:这其中condition和state标记是作者虚构出来的。Java语言规范并未硬性规定这里要使用condition和state。JVM具体的实现只要类似即可。

3.3、总结

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

相关推荐
笑衬人心。几秒前
Ubuntu 22.04 修改默认 Python 版本为 Python3 笔记
笔记·python·ubuntu
风象南4 分钟前
SpringBoot 控制器的动态注册与卸载
java·spring boot·后端
金色光环22 分钟前
【Modbus学习笔记】stm32实现Modbus
笔记·stm32·学习
我是一只代码狗30 分钟前
springboot中使用线程池
java·spring boot·后端
hello早上好43 分钟前
JDK 代理原理
java·spring boot·spring
PanZonghui1 小时前
Centos项目部署之Java安装与配置
java·linux
沉着的码农1 小时前
【设计模式】基于责任链模式的参数校验
java·spring boot·分布式
zyxzyx6661 小时前
Flyway 介绍以及与 Spring Boot 集成指南
spring boot·笔记
Mr_Xuhhh2 小时前
信号与槽的总结
java·开发语言·数据库·c++·qt·系统架构
纳兰青华2 小时前
bean注入的过程中,Property of ‘java.util.ArrayList‘ type cannot be injected by ‘List‘
java·开发语言·spring·list