并发编程笔记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的双重检查锁定方案有一个额外的优势:除了可以对静态字段实现延迟初始化外,还可以对实例实现字段实现延迟加载。

相关推荐
七星静香15 分钟前
laravel chunkById 分块查询 使用时的问题
java·前端·laravel
Jacob程序员16 分钟前
java导出word文件(手绘)
java·开发语言·word
ZHOUPUYU16 分钟前
IntelliJ IDEA超详细下载安装教程(附安装包)
java·ide·intellij-idea
stewie619 分钟前
在IDEA中使用Git
java·git
Elaine20239135 分钟前
06 网络编程基础
java·网络
咔叽布吉35 分钟前
【论文阅读笔记】CamoFormer: Masked Separable Attention for Camouflaged Object Detection
论文阅读·笔记·目标检测
G丶AEOM36 分钟前
分布式——BASE理论
java·分布式·八股
johnny23336 分钟前
《大模型应用开发极简入门》笔记
笔记·chatgpt
落落鱼201337 分钟前
tp接口 入口文件 500 错误原因
java·开发语言
想要打 Acm 的小周同学呀38 分钟前
LRU缓存算法
java·算法·缓存