写点什么

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

  • 2013-10-07
  • 本文字数:6068 字

    阅读完需:约 20 分钟

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

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

在 UnsafeLazyInitialization 中,假设 A 线程执行代码 1 的同时,B 线程执行代码 2。此时,线程 A 可能会看到 instance 引用的对象还没有完成初始化(出现这种情况的原因见后文的“问题的根源”)。

对于 UnsafeLazyInitialization,我们可以对 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 { //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
return instance; //10
} //11
} //12

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

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

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

问题的根源

前面的双重检查锁定示例代码的第 7 行(instance = new Singleton();)创建一个对象。这一行代码可以分解为如下的三行伪代码:

复制代码
memory = allocate(); //1:分配对象的内存空间
ctorInstance(memory); //2:初始化对象
instance = memory; //3:设置 instance 指向刚分配的内存地址

上面三行伪代码中的 2 和 3 之间,可能会被重排序(在一些 JIT 编译器上,这种重排序是真实发生的,详情见参考文献 1 的“Out-of-order writes”部分)。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 允许那些在单线程内,不会改变单线程程序执行结果的重排序。上面三行伪代码的 2 和 3 之间虽然被重排序了,但这个重排序并不会违反 intra-thread semantics。这个重排序在没有改变单线程程序的执行结果的前提下,可以提高程序的执行性能。

为了更好的理解 intra-thread semantics,请看下面的示意图(假设一个线程 A 在构造对象后,立即访问这个对象):

如上图所示,只要保证 2 排在 4 的前面,即使 2 和 3 之间重排序了,也不会违反 intra-thread semantics。

下面,再让我们看看多线程并发执行的时候的情况。请看下面的示意图:

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

※注:本文统一用红色的虚箭线标识错误的读操作,用绿色的虚箭线标识正确的读操作。

回到本文的主题,DoubleCheckedLocking 示例代码的第 7 行(instance = new Singleton();)如果发生重排序,另一个并发执行的线程 B 就有可能在第 4 行判断 instance 不为 null。线程 B 接下来将访问 instance 所引用的对象,但此时这个对象可能还没有被 A 线程初始化!下面是这个场景的具体执行时序:

时间

线程 A

线程 B

t1

A1:分配对象的内存空间

复制代码
t2

A3:设置 instance 指向内存空间

复制代码
t3
B1:判断 instance 是否为空

t4

复制代码
B2:由于 instance 不为 null,线程 B 将访问 instance 引用的对象

t5

A2:初始化对象

复制代码
t6

A4:访问 instance 引用的对象

这里 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 的双重检查锁定的解决方案

对于前面的基于双重检查锁定来实现延迟初始化的方案(指 DoubleCheckedLocking 示例代码),我们只需要做一点小的修改(把 instance 声明为 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;
}
}

注意,这个解决方案需要 JDK5 或更高版本(因为从 JDK5 开始使用新的 JSR-133 内存模型规范,这个规范增强了 volatile 的语义)。

当声明对象的引用为 volatile 后,“问题的根源”的三行伪代码中的 2 和 3 之间的重排序,在多线程环境中将会被禁止。上面示例代码将按如下的时序执行:

这个方案本质上是通过禁止上图中的 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(),下面是执行的示意图:

这个方案的实质是:允许“问题的根源”的三行伪代码中的 2 和 3 重排序,但不允许非构造线程(这里指线程 B)“看到”这个重排序。

初始化一个类,包括执行这个类的静态初始化和初始化在这个类中声明的静态字段。根据 java 语言规范,在首次发生下列任意一种情况时,一个类或接口类型 T 将被立即初始化:

  • T 是一个类,而且一个 T 类型的实例被创建;
  • T 是一个类,且 T 中声明的一个静态方法被调用;
  • T 中声明的一个静态字段被赋值;
  • T 中声明的一个静态字段被使用,而且这个字段不是一个常量字段;
  • T 是一个顶级类(top level class,见 java 语言规范的§7.6),而且一个断言语句嵌套在 T 内部被执行。

在 InstanceFactory 示例代码中,首次执行 getInstance() 的线程将导致 InstanceHolder 类被初始化(符合情况 4)。

由于 java 语言是多线程的,多个线程可能在同一时间尝试去初始化同一个类或接口(比如这里多个线程可能在同一时刻调用 getInstance() 来初始化 InstanceHolder 类)。因此在 java 中初始化一个类或者接口时,需要做细致的同步处理。

Java 语言规范规定,对于每一个类或接口 C,都有一个唯一的初始化锁 LC 与之对应。从 C 到 LC 的映射,由 JVM 的具体实现去自由实现。JVM 在类初始化期间会获取这个初始化锁,并且每个线程至少获取一次锁来确保这个类已经被初始化过了(事实上,java 语言规范允许 JVM 的具体实现在这里做一些优化,见后文的说明)。

对于类或接口的初始化,java 语言规范制定了精巧而复杂的类初始化处理过程。java 初始化一个类或接口的处理过程如下(这里对类初始化处理过程的说明,省略了与本文无关的部分;同时为了更好的说明类初始化过程中的同步处理机制,笔者人为的把类初始化的处理过程分为了五个阶段):

第一阶段:通过在 Class 对象上同步(即获取 Class 对象的初始化锁),来控制类或接口的初始化。这个获取锁的线程会一直等待,直到当前线程能够获取到这个初始化锁。

假设 Class 对象当前还没有被初始化(初始化状态 state 此时被标记为 state = noInitialization),且有两个线程 A 和 B 试图同时初始化这个 Class 对象。下面是对应的示意图:

下面是这个示意图的说明:

时间

线程 A

线程 B

t1

A1: 尝试获取 Class 对象的初始化锁。这里假设线程 A 获取到了初始化锁

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

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 的类初始化处理过程完成

线程 A 在第二阶段的 A1 执行类的初始化,并在第三阶段的 A4 释放初始化锁;线程 B 在第四阶段的 B1 获取同一个初始化锁,并在第四阶段的 B4 之后才开始访问这个类。根据 java 内存模型规范的锁规则,这里将存在如下的 happens-before 关系:

这个 happens-before 关系将保证:线程 A 执行类的初始化时的写入操作(执行类的静态初始化和初始化类中声明的静态字段),线程 B 一定能看到。

第五阶段:线程 C 执行类的初始化的处理:

下面是这个示意图的说明:

时间

线程 B

t1

C1:获取初始化锁

t2

C2:读取到 state == initialized

t3

C3:释放初始化锁

t4

C4:线程 C 的类初始化处理过程完成

在第三阶段之后,类已经完成了初始化。因此线程 C 在第五阶段的类初始化处理过程相对简单一些(前面的线程 A 和 B 的类初始化处理过程都经历了两次锁获取 - 锁释放,而线程 C 的类初始化处理只需要经历一次锁获取 - 锁释放)。

线程 A 在第二阶段的 A1 执行类的初始化,并在第三阶段的 A4 释放锁;线程 C 在第五阶段的 C1 获取同一个锁,并在在第五阶段的 C4 之后才开始访问这个类。根据 java 内存模型规范的锁规则,这里将存在如下的 happens-before 关系:

这个 happens-before 关系将保证:线程 A 执行类的初始化时的写入操作,线程 C 一定能看到。

※注 1:这里的 condition 和 state 标记是本文虚构出来的。Java 语言规范并没有硬性规定一定要使用 condition 和 state 标记。JVM 的具体实现只要实现类似功能即可。

※注 2:Java 语言规范允许 Java 的具体实现,优化类的初始化处理过程(对这里的第五阶段做优化),具体细节参见 java 语言规范的 12.4.2 章。

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

总结

延迟初始化降低了初始化类或创建实例的开销,但增加了访问被延迟初始化的字段的开销。在大多数时候,正常的初始化要优于延迟初始化。如果确实需要对实例字段使用线程安全的延迟初始化,请使用上面介绍的基于 volatile 的延迟初始化的方案;如果确实需要对静态字段使用线程安全的延迟初始化,请使用上面介绍的基于类初始化的方案。

参考文献

  1. Double-checked locking and the Singleton pattern
  2. The Java Language Specification, Java SE 7 Edition
  3. JSR-133: Java Memory Model and Thread Specification
  4. Java Concurrency in Practice
  5. Effective Java (2nd Edition)
  6. JSR 133 (Java Memory Model) FAQ
  7. The JSR-133 Cookbook for Compiler Writers
  8. Java theory and practice: Fixing the Java Memory Model, Part 2

个人简介

程晓明,java 软件工程师,专注于并发编程,现就职于富士通南大。个人邮箱:asst2003@163.com。


感谢方腾飞对本文的审校。

给InfoQ 中文站投稿或者参与内容翻译工作,请邮件至 editors@cn.infoq.com 。也欢迎大家通过新浪微博( @InfoQ )或者腾讯微博( @InfoQ )关注我们,并与我们的编辑和其他读者朋友交流。

2013-10-07 08:3034793

评论 3 条评论

发布
用户头像
写的太好了,几年前就看过这篇文章 ,现在看还是觉得写的很好~
2020-08-13 14:13
回复
用户头像
volatile有两个作用嘛,一个是直接读写主内存,一个是加入内存屏障。按照笔者的意思,这个用的是volatile的加入内存屏障特性,这么理解对吗
2020-04-07 19:36
回复
用户头像
如果对静态实例字段进行初始化,那么优选哪个实例化方式呢,理由是什么
2020-04-07 19:33
回复
没有更多了
发现更多内容

4个因素会影响LED显示屏的安全防火问题

Dylan

LED显示屏 全彩LED显示屏 led显示屏厂家

DNS 代理?Pipy:这我也可以

Flomesh

Pipy 可编程代理 流量管理

16款跨平台应用开发框架,你Pick谁?

Speedoooo

跨端开发 跨端框架 跨端应用平台 跨端开发平台

从0.5到4.0,OceanBase单机分布式一体化的技术演进|DTCC 2022

OceanBase 数据库

数据库 oceanbase

漏洞优先级排序的六大关键因素

SEAL安全

安全 漏洞 企业号 1 月 PK 榜 优先级排序

成功上岸字节全靠这份Redis技术笔记,深入浅出值得一看

小小怪下士

Java redis 程序员 面试 字节

Apache Spark + 海豚调度:PB 级数据调度挑战,教你如何构建高效离线工作流

白鲸开源

海豚调度 Apache Spark 大数据 开源

年度重磅!《2022华为开发者宝典》免费下载

华为云开发者联盟

开源 华为云 鲲鹏 昇腾 企业号 1 月 PK 榜

Payso×OceanBase:云上拓新,开启云数据库的智能托管

OceanBase 数据库

数据库 oceanbase

如何定义算法?10分钟带你弄懂算法的基本概念

九章云极DataCanvas

机器学习 机器学习算法

火山引擎DataTester:5个优化思路,构建高性能A/B实验平台

字节跳动数据平台

大数据 AB testing实战

目标检测模型基础知识

嵌入式视觉

Focal Loss IOU NMS Soft NMS anchor

软件测试/测试开发 | 使用 Zabbix + Grafana 搭建服务器监控系统

测试人

软件测试 Grafana 自动化测试 zabbix 测试开发

不会还有人不知道吧?BOM上的器件也能在PCB上快速定位啦!(内附高效手焊攻略)

华秋PCB

工艺 PCB PCB设计 焊接 PCB工艺

NineData获"IT168 & ITPub 年度创新产品"奖

NineData

数据库 数据管理 多云管理平台 玖章算术 NineData

神经网络模型复杂度分析

嵌入式视觉

params 模型计算量分析 FLOPs 卷积层MAC 浮点计算能力

让开源和标准成为云原生的确定性力量

阿里巴巴云原生

阿里云 开源 云原生

软件测试 | 测试开发| 跨平台设备管理方案Selenium Grid

测吧(北京)科技有限公司

一站式云原生体验|龙蜥云原生ACNS + Rainbond

北京好雨科技有限公司

Kubernetes 云原生

不懂任务调度系统,快来看这篇

华为云开发者联盟

后端 开发 华为云 企业号 1 月 PK 榜

全景剖析阿里云容器网络数据链路(二):Terway EN

阿里巴巴云原生

阿里云 容器 云原生

目标检测模型的评价标准-AP与mAP

嵌入式视觉

ap map roc PR曲线 精确率与召回率

Spring Boot 3.0横空出世,快来看看是不是该升级了

程序那些事

Java spring 程序那些事 spring boot3

PolarDB for PostgreSQL 14 开源实战训练营免费报名中!

阿里云数据库开源

数据库 阿里云 开源 postgre PolarDB for PostgreSQL

智能流程机器人助你“聚划算”

华为云开发者联盟

人工智能 机器人 华为云 企业号 1 月 PK 榜

聊聊Cookie、Session、Token 背后的故事

华为云开发者联盟

前端 华为云 企业号 1 月 PK 榜

AI创作惊艳四方,诸多挑战仍在路上

科技热闻

腾讯企点助力建发纸业:浆纸产业数字化战略,传统行业在低增长时代的新路径

人称T客

双重检查锁定与延迟初始化_Java_程晓明_InfoQ精选文章