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

news/2024/11/16 8:32:05/

双重检查锁定的由来

在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引用的对象还没有完成初始化
我们可以对getInstance()方法做同步处理来实现线程安全的延迟初始化。示例代码如下。
public class SafeLazyInitialization {private static Instance instance;public synchronized static Instance getInstance() {if (instance == null)instance = new Instance();return instance;}
}
由于对getInstance()方法做了同步处理,synchronized将导致性能开销。如果getInstance()方法被多个线程频繁的调用,将会导致程序执行性能的下降。反之,如果getInstance()方法不会被多个线程频繁的调用,那么这个延迟初始化方案将能提供令人满意的性能。
早期的JVM中,synchronized(甚至是无竞争的synchronized)存在巨大的性能开销。因此,人们想出了一个“聪明”的技巧双重检查锁定 (Double-Checked Locking)。人们想通过双重检查锁定来降低同步的开销。下面是使用双重检查锁定来实现延迟初始化的示例代码。
public class DoubleCheckedLocking {                      // 1private static Instance instance;                    // 2public static Instance getInstance() {               // 3if (instance == null) {                          // 4:第一次检查synchronized (DoubleCheckedLocking.class) {  // 5:加锁if (instance == null)                    // 6:第二次检查instance = new Instance();           // 7:问题的根源出在这里}                                            // 8}                                                // 9return instance;                                 // 10}                                                    // 11
}
如上面代码所示,如果第一次检查instance不为null,那么就不需要执行下面的加锁和初始化操作。因此,可以大幅降低synchronized带来的性能开销。上面代码表面上看起来,似乎两全其美。
·多个线程试图在同一时间创建对象时,会通过加锁来保证只有一个线程能创建对象。
·在对象创建好之后,执行getInstance()方法将不需要获取锁,直接返回已创建好的对象。
双重检查锁定看起来似乎很完美,但这是一个错误的优化!在线程执行到第4行, 代码读取到instance不为null时,instance引用的对象有可能还没有完成初始化。

问题的根源

前面的双重检查锁定示例代码的第7行(instance=new Singleton();)创建了一个对象。这一行代码可以分解为如下的3行伪代码。
memory = allocate();  // 1:分配对象的内存空间
ctorInstance(memory);  // 2:初始化对象
instance = memory;   // 3:设置instance指向刚分配的内存地址
上面3行伪代码中的2和3之间,可能会被重排序。2和3之间重排序之后的执行时序如下。
memory = allocate();  // 1:分配对象的内存空间
instance = memory;   // 3:设置instance指向刚分配的内存地址 。注意,此时对象还没有被初始化!
ctorInstance(memory);  // 2:初始化对象
根据《The Java Language Specification,Java SE 7 Edition》(后文简称为Java语言规范),所有线程在执行Java程序时必须要遵守intra-thread semantics。intra-thread semantics保证重排序不会改变单线程内的程序执行结果。换句话说,intra-thread semantics允许那些在单线程内,不会改变单线程程序执行结果的重排序。上面3行伪代码的2和3之间虽然被重排序了,但这个重排序并不会违反intra-thread semantics。这个重排序在没有改变单线程程序执行结果的前提下,可以提高程序的执行性能。
单线程执行时序图
在这里插入图片描述
只要保证2排在4的前面,即使2和3之间重排序了,也不会违反intra-thread semantics。
多线程执行时序图
在这里插入图片描述
由于单线程内要遵守intra-thread semantics,从而能保证A线程的执行结果不会被改变。但是,B线程将看到一个还没有被初始化的对象。
回到本文的主题,DoubleCheckedLocking示例代码的第7行(instance=new Singleton();)如果发生重排序,另一个并发执行的线程B就有可能在第4行判断instance不为null。线程B接下来将访问instance所引用的对象,但此时这个对象可能还没有被A线程初始化!下表是这个场景的具体执行时序。
在这里插入图片描述
这里A2和A3虽然重排序了,但Java内存模型的intra-thread semantics将确保A2一定会排在A4前面执行。因此,线程A的intra-thread semantics没有改变,但A2和A3的重排序,将导致线程B在B1处判断出instance不为空,线程B接下来将访问instance引用的对象。此时,线程B将会访问到一个还未初始化的对象。
在知晓了问题发生的根源之后,我们可以想出两个办法来实现线程安全的延迟初始化。
1)不允许2和3重排序。
2)允许2和3重排序,但不允许其他线程“看到”这个重排序。
后文介绍的两个解决方案,分别对应于上面这两点。

基于volatile的解决方案

public class SafeDoubleCheckedLocking {private volatile static Instance instance;public static Instance getInstance() {if (instance == null) {synchronized (SafeDoubleCheckedLocking.class) {if (instance == null)instance = new Instance(); // instance为volatile,现在没问题了}}return instance;}
}
当声明对象的引用为volatile后,如下3行伪代码中的2和3之间的重排序,在多线程环境中将会被禁止
memory = allocate();  // 1:分配对象的内存空间
ctorInstance(memory);  // 2:初始化对象
instance = memory;   // 3:设置instance指向刚分配的内存地址
上面示例代码将按如下的时序执行,
在这里插入图片描述
这个方案本质上是通过禁止上图中的2和3之间的重排序,来保证线程安全的延迟初始化。

基于类初始化的解决方案

JVM在类的初始化阶段(即在Class被加载后,且被线程使用之前),会执行类的初始化。在执行类的初始化期间,JVM会去获取一个。这个锁可以同步多个线程对同一个类的初始化。
基于这个特性,可以实现另一种线程安全的延迟初始化方案(这个方案被称之为Initialization On Demand Holder idiom)。
public class InstanceFactory {private static class InstanceHolder {public static Instance instance = new Instance();}public static Instance getInstance() {return InstanceHolder.instance ;  // 这里将导致InstanceHolder类被初始化}
}
假设两个线程并发执行getInstance()方法,下面是执行的示意图,
在这里插入图片描述
这个方案的实质是:允许如下3行伪代码中的2和3重排序,但不允许非构造线程(这里指线程B)“看到”这个重排序。
memory = allocate();  // 1:分配对象的内存空间
ctorInstance(memory);  // 2:初始化对象
instance = memory;   // 3:设置instance指向刚分配的内存地址

总结

通过对比基于volatile的双重检查锁定的方案和基于类初始化的方案,我们会发现基于类初始化的方案的实现代码更简洁。但基于volatile的双重检查锁定的方案有一个额外的优势:除了可以对静态字段实现延迟初始化外,还可以对实例字段实现延迟初始化。
字段延迟初始化降低了初始化类或创建实例的开销,但增加了访问被延迟初始化的字段的开销。在大多数时候,正常的初始化要优于延迟初始化。如果确实需要对实例字段使用线程安全的延迟初始化,请使用上面介绍的基于volatile的延迟初始化的方案;如果确实需要对静态字段使用线程安全的延迟初始化,请使用上面介绍的基于类初始化的方案。

-----------------------------------------------------------------------------读书笔记摘自 书名:Java并发编程的艺术 作者:方腾飞;魏鹏;程晓明

在这里插入图片描述


http://www.ppmy.cn/news/300748.html

相关文章

【Spring Boot 初识丨三】starter

Soring Boot 初识 【Spring Boot 初识丨一】入门实战 【Spring Boot 初识丨二】maven 本篇来讲一讲 starter 依赖项 Starter 一、定义二、启动器2.1 应用启动器2.2 生产启动器2.3 技术启动器 一、定义 启动器是一组方便的依赖关系描述符,它包含了一系列可以集成到应…

通俗易懂:快速理解P2P技术中的NAT穿透原理

目录 1、基础知识1.1、什么是NAT?1.2、为什么会有NAT?1.3、NAT有什么优缺点? 2、NAT的实现方式2.1、静态NAT2.2、NAPT 3、NAT的主要类型3.1、完全锥型NAT(Full Cone NAT,后面简称FC)3.2、受限锥型NAT&#…

免费P2P穿透通信(6) P2P穿透模块使用注意事项

注意事项 测试的用户,如果没有P2P服务器,可以联系作者,提供测试服务器。 模块配对使用 在wkf lib p2p模块中,提供了服务器和客户端模块的应用。例如: 1 PS服务和PC客户端配对使用; 2 RTDS服务和RDTC客户…

通过nps p2p穿透内网windows虚拟机中的centos的使用ssh的注意点

本文略去nps配置部分,写此主要为以后做同样配置,在容易忽略的地方提个醒,最主要的就是防火墙入站规则一定要开。 0、对于无ip配置的,且你没有权限在虚拟机内修改网络配置的虚拟机,可以通过额外增加一块NAT类型的虚拟网…

nps p2p穿透中遇到的NAT类型过低的问题解决

我为什么需要p2p穿透呢,因为我喜欢带着轻便的surface go在外用,但是由于性能和存储的原因,还是家里的台式机香啊。蒲公英,向日葵等提供的内网穿透大多数时候都是走的服务器转发,自己有时候用frp也是走服务器转发。服务器的带宽又太低,远程传输文件十分不方便,连接质量还…

SQL Server和Oracle 19c 没有管理员权限时配置开发工具

网络环境下连接SQL Server和Oracle 19c的方法 1、SQL Server 2019连接方法 1.1 启动SQL Server 2019配置管理器 因为软件都是64位的,从SQL Server网络配置开始 TCP/IP,右键-属性 重点:IP地址,将SQL SERVER数据库服务器的IP地址输…

P2P 穿透原理及使用指南

P2P 技术可以说目前比较流行的技术无论在视频直播还是在远程控制方便,看完本文后你可以详细了解 P2P 技术原理帮助你解决一些困扰! 本场 Chat 内容如下: P2P 技术与普通并发服务器的对比及优缺点;P2P 技术原理;P2P 客户端及服务…

轻量P2P穿透

对于物联网,相信大家并不陌生,国家领导在公共场合已经多次提到物联网,各大媒体也有大量关于物联网的报道。物联网三要素,物体(设备),传输介质(网络)和客户端(…