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

相关推荐
数据小爬虫@8 分钟前
Java爬虫实战:深度解析Lazada商品详情
java·开发语言
咕德猫宁丶10 分钟前
探秘Xss:原理、类型与防范全解析
java·网络·xss
F-2H2 小时前
C语言:指针4(常量指针和指针常量及动态内存分配)
java·linux·c语言·开发语言·前端·c++
苹果酱05672 小时前
「Mysql优化大师一」mysql服务性能剖析工具
java·vue.js·spring boot·mysql·课程设计
_oP_i3 小时前
Pinpoint 是一个开源的分布式追踪系统
java·分布式·开源
mmsx3 小时前
android sqlite 数据库简单封装示例(java)
android·java·数据库
武子康3 小时前
大数据-258 离线数仓 - Griffin架构 配置安装 Livy 架构设计 解压配置 Hadoop Hive
java·大数据·数据仓库·hive·hadoop·架构
豪宇刘4 小时前
MyBatis的面试题以及详细解答二
java·servlet·tomcat
秋恬意4 小时前
Mybatis能执行一对一、一对多的关联查询吗?都有哪些实现方式,以及它们之间的区别
java·数据库·mybatis
Aileen_0v05 小时前
【AI驱动的数据结构:包装类的艺术与科学】
linux·数据结构·人工智能·笔记·网络协议·tcp/ip·whisper