我们一起学并发编程:Java内存模型(八)双重检查锁定与延迟初始化

如题所述

第1个回答  2024-09-05
简介

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

1、双重检查锁定的由来

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

非线程安全延迟初始化代码示例:

packagecom.lizba.p1;/***<p>*实例对象*</p>**@Author:Liziba*@Date:2021/6/1222:42*/publicclassInstance{publicInstance(){System.out.println("init...");}}packagecom.lizba.p1;/***<p>*延迟初始化*</p>**@Author:Liziba*@Date:2021/6/1222:40*/publicclassUnsafeLazyInitialization{privatestaticInstanceinstance;publicstaticInstancegetInstance(){if(instance==null){//1、线程A执行instance=newInstance();//2、线程B执行}returninstance;}}

在UnsafeLazyInitialization类中,假设线程A执行1的同时线程B执行2,此时线程A可能会看到Instance对象未完成初始化(后续会讲问题根源)。

同步处理解决方法:

packagecom.lizba.p1;/***<p>**</p>**@Author:Liziba*@Date:2021/6/1222:46*/publicclassSafeLazyInitialization{privatestaticInstanceinstance;publicsynchronizedstaticInstancegetInstance(){if(instance==null){//线程A执行instance=newInstance();//线程B执行}returninstance;}}

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

双重检查锁解决方法:

packagecom.lizba.p1;/***<p>*双重检查锁*</p>**@Author:Liziba*@Date:2021/6/1222:51*/publicclassDoubleCheckedLocking{privatestaticInstanceinstance;publicstaticInstancegetInstance(){if(instance==null){//第一次检查synchronized(DoubleCheckedLocking.class){//加锁if(instance==null){//第二次检查instance=newInstance();//仍然存在问题的代码}}}returninstance;}}

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

2、问题的根源2.1分析instance=newInstance();

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

memory=allocate();//分配对象的内存空间

ctorInstance(memory);//初始化对象

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

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

memory=allocate();//分配对象的内存空间

instance=memory;//设置instance指向刚分配的内存地址(未初始化完成)

ctorInstance(memory);//初始化对象

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

2.2分析什么是intra-threadsemantics

单线程内instance=newInstance();执行时序图:

线程执行时序图

多线程内instance=newInstance();可能存在的一种执行时序图:

多线程执行时序图

由于单线程内要遵守intra-threadsemantics,从而保证线程A的执行结果不会被改变;但是在上图多线程执行中,线程B可能读到一个未正确完成初始化的Instance对象。

回到DoubleCheckedLocking这个示例代码中,线程B可能在第一次instance==null判断时为真,线程B接下来将访问instance引用指向的对象,但是此时这个对象并没有初始化完成。

多线程执行时序表:

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

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

不允许2和3重排序

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

下面讲述具体实现方案。

3、基于volatile的解决方案

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

packagecom.lizba.p1;/***<p>*双重检查锁正确示例,JDK1.5及以上*</p>**@Author:Liziba*@Date:2021/6/1222:51*/publicclassDoubleCheckedLocking{//privatestaticInstanceinstance;privatevolatilestaticInstanceinstance;publicstaticInstancegetInstance(){if(instance==null){//第一次检查synchronized(DoubleCheckedLocking.class){//加锁if(instance==null){//第二次检查instance=newInstance();//instance为volatile,问题得以解决}}}returninstance;}}

声明instance为volatile引用变量时,2和3的重排序会被禁止,执行时序图如下:

多线程执行时序图

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

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

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

基于这个特性实现的方案被称之为(InitializationOnDemandHolderidiom)。

示例代码:

packagecom.lizba.p1;/***<p>*实例工厂*</p>**@Author:Liziba*@Date:2021/6/1223:52*/publicclassInstanceFactory{privatestaticclassInstanceHolder{publicstaticInstanceinstance=newInstance();}publicstaticInstancegetInstance(){returnInstanceHolder.instance;}}

假设线程A和线程B同时执行getInstance()方法,下面是执行示意图:

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

初始化一个类包括执行这个类的静态初始化和初始化在这个类中声明的静态字段。

那么类什么时候会被初始化呢?在Java语言规范中,首次发生如下情况中的任意一种,一个类或者一个接口类型T将会被立即初始化:

T是一个类,而且一个T类型的实例被创建

T是一个类,且T中声明的一个静态方法被调用

T中声明的一个静态字段被赋值

T中声明的一个静态字段被使用,而且这个字段不是一个常量字段

T是一个顶级类(TopLevelClass),而且一个断言语句嵌套在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线程Bt1A1:尝试获取Class对象的初始化锁。这里假设线程A获取到初始化锁。B1:尝试获取Class对象的初始化锁,由于线程A获取到了锁,线程B等待获取初始化锁t2A2:线程A看到对象还未被初始化(state=noInitialization),线程设置state=noInitializatingt3A3:线程A释放初始化锁5.2第二阶段

线程A执行类的初始化,同时线程B在初始锁对应的condition上等待。

图示如下:

类初始化-第2阶段

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

时间线程A线程Bt1A1:执行类的静态初始化和初始化类中声明的静态字段B1:获取到初始化锁t2B2:读取到state=initializingt3B3:释放初始化锁t4B4:在初始化锁的condition中等待5.3第三阶段

线程A设置state=initialized,然后唤醒等待在condition上的所有线程

类初始化-第3阶段

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

时间线程At1A1:获取初始化锁t2A2:设置state=initializedt3A3:唤醒在condition中等待的所有线程t4A4:释放初始化锁t5A5:线程A的初始化过程完成5.4第四阶段

线程B结束类的初始化处理

类初始化-第4阶段

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

时间线程Bt1B1:获取初始化锁t2B2:读取到state=initializedt3B3:释放初始化锁t4B4:线程B的类的初始化过程完成第五阶段

线程C执行类的初始化处理

类初始化-第5阶段

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

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

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

6、总结

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

如果确定要进行延迟初始化,那么具体如何选择呢?

实例字段延迟初始化使用volatile方案

静态字段延迟初始化使用类初始化方案

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

文章的重点知识做了梳理和标记

对黑白图片做了彩色画图,使其更加易懂

对部分繁琐的知识点做了概括

对少部分错误的知识(主要是错字)进行了勘误

对每一句代码做了全部重写和注释

码字不易,多多关注。

logo设计

创造品牌价值

¥500元起

APP开发

量身定制,源码交付

¥2000元起

商标注册

一个好品牌从商标开始

¥1480元起

公司注册

注册公司全程代办

¥0元起

    官方电话官方服务
      官方网站八戒财税知识产权八戒服务商企业需求数字市场

我们一起学并发编程:Java内存模型(八)双重检查锁定与延迟初始化
如上代码,如果第一次检查instance不为null,那么久不需要执行加锁和初始化工作,可以极大的减少synchronized带来的性能开销,但是双重检查锁也存在一个问题,就是判断instance==null这行代码可能会在Instance未正确初始化的时候成立,这个问题产生的原因是指令重拍,下面会详细讲述,也可以看我往期的文章哈!因此这是一个错误的...

我们一起学并发编程:Java内存模型(七)happens-before
2happens-before3是有volatile规则产生。一个volatile变量的读,总是能看到(任意线程)对这个volatile变量的最后写入。1happens-before4是由传递性规则产生的。这里的传递性是由volatile的内存屏障插入策略和volatile的编译器重排序规则来共同保证的。3.2start()规则假设线程A在执行的过程中,通过执行ThreadB...

什么是双重检查锁定,为什么要进行双重检查锁定
这将促使我们使用双重检查锁模式(double checked locking pattern),一种只在临界区代码加锁的方法。程序员称其为双重检查锁,因为会有两次检查 _instance == null,一次不加锁,另一次在同步块上加锁。这就是使用Java双重检查锁的示例:public static Singleton getInstanceDC() { if (_instance ==...

一种独特的单例模式写法,利用final语义实现
单例模式是Java编程中一个经典话题,通常有静态内部类、枚举、双重检查锁定(Double-Checked Locking)等多种实现方法。最近,我注意到一种使用final语义实现线程安全单例模式的新颖方式。final除了作为类、方法、属性不可变的标志外,还隐含着读写重排序规则。这一规则的存在是为了解决Java内存模型中一个关...

JAVA编程思想一共有几章
JAVA编程思想总共 22 个章节 你可以下载pdf查看 第1章 对象导论 第2章 一切都是对象 第3章 操作符 第4章 控制执行流程 第5章 初始化与清理 第6章 访问权限控制 第7章 复用类 第8章 多态 第9章 接口 第10章 内部类 第11章 持有对象 第12章 通过异常处理错误 第13章 字符串 第14章 类型...

java内存模型的JMM简介
可见性就是在多核或者多线程运行过程中内存的一种共享模式,在JMM模型里面,通过并发线程修改变量值的时候,必须将线程变量同步回主存过后,其他线程才可能访问到。可排序性提供了内存内部的访问顺序,在不同的程序针对不同的内存块进行访问的时候,其访问不是无序的,比如有一个内存块,A和B需要访问的时候,JMM会提供一定...

谁知道计算机方面的英文术语是哪些英文缩写?最好有中文注释
DIMM(Dual In-line Memory Modules,双重内嵌式内存模块)DLL(Delay-Locked Loop,延时锁定循环电路)DQS(Bidirectional data strobe,双向数据滤波)DRAM(Dynamic Random Access Memory,动态随机存储器)DRDRAM(Direct RAMBUS DRAM,直接内存总线DRAM)DRSL(Direct RAMBUS Signaling Level,直接RAMBUS信号级)DRSL(Differential ...

我们一起学并发编程:Java内存模型(七)happens-before
分析上图:2happens-before4由join()规则产生 4happens-before5由程序顺序规则产生 2happens-before5由传递性规则产生 因此线程A执行操作ThreadB.join()并成功返回,线程B中任意操作都将对线程A可见。文章总结至《Java并发编程艺术》,下篇总结“双重检查所定与延迟初始化”,敬请关注。

相似回答
大家正在搜