双重检查锁定与延迟优化

article/2023/9/24 23:14:43

双重检查锁定与延迟优化

  • 1. 双重所检查的由来
  • 2. 问题根源
  • 3. 基于volatile的解决方案
  • 4. 基于类初始化的解决方案

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

1. 双重所检查的由来

在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)存在巨大的性能开销。因此,人们想出了一个”聪明的技巧“:双重检查锁定,人们想通过双重检查锁定来降低同步的开销。

private static Instance instance; // 2public static Instance getInstance() { // 3if (instance == null) { // 4:第一次检查synchronized (DoubleCheckedLocking.class) { // 5:加锁if (instance == null) // 6:第二次检查instance = new Instance(); // 7:问题的根源出在这里} // 8} // 9 return instance; // 10} // 11
}

像上面的代码,如果第一次检查instance的时候不为null,那么就不需要执行下面的加锁和初始化操作。因此,可以大幅度降低synchronized带来的性能开销,上面的代码看起来很完美。

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

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

2. 问题根源

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

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

上面3行伪代码中的2和3之间,可能会被重排序(在一些JIT编译器上,这种重排序是真实发生的)。2和3之间的重排序之后的执行时序如下:

memory = allocate(); // 1:分配对象的内存空间
instance = memory; // 3:设置 instance 指向刚分配的内存地址 
// 注意,此时对象还没有被初始化!
ctorInstance(memory); // 2:初始化对象

根据Java语言规范,所有线程在执行时必须要遵守intra-thread-semantics。intra-thread-semantics保证重排序不会改变单线程内程序执行结果。换句话说,intra-thread-semantics允许那些在单线程内,不会改变单线程执行结果的重排序。上面3行伪代码的2和3之间虽然被重排序了,但这个重排序并不会违反intra-thread-semantics。这个重排序在没有改变单线程执行结果的前提下,可以提高程序的执行结果。

为了更好的理解intrea-thread-semantics,举个例子:
在这里插入图片描述

在这里插入图片描述
如第一张图所示,只要保证2排在4钱买你,即使2和3之间重排序了,也不会违背intra-thread-semantics。

第二张图是多线程并发执行的情况,由于单线程内要遵守intra-thread-semantics,从而能保证A线程执行结果不会被改变。但是当线程A和线程B按第二张图执行时,B线程将看到一个还没有被初始化的对象。
回答本文的主题,双重检查锁定示例代码的第7行(instance = new Singleton)如果发生重排序,另一个并发执行的线程B就有可能在第4行判断instance不为null。线程接下来访问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重排序,但是不允许其他线程看到这个重排序

3. 基于volatile的解决方案

对于前面的基于双重锁定来实现延迟初始化的方案,只需要做一点小的修改(把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;}}

当声明对象的引用为volatile后。2和3之间的重排序在多线程环境中将会被禁止。上面的代码将按以下顺序执行:
在这里插入图片描述
这个方案的本质是通过禁止2和3之间的排序,来保证线程安全的延迟初始化。

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

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

基于这个特性,可以实现另一种线程安全的延迟初始化方案。

 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将立刻被初始化。

  1. T是一个类,而且一个T类型的实例被创建
  2. T是一个类,且T中声明的一个静待方法被调用
  3. T中声明的一个静态字段被赋值
  4. T中声明的一个静态字段被使用,而且这个字段不是一个常量字段
  5. T是一个顶级类,而且一个断言语句嵌套在T内部被执行

在InstanceFactory 代码中,首次执行getInstance()方法的线程将导致InstanceHolder类被初始化。


http://www.ngui.cc/article/show-1007531.html

相关文章

STM32-9 STM32CubeMX的使用方法

一、 说明 本项目是基于FreeRTOS项目的STM32CubeMX开发方式,说明了具体配置与相关参数,以及mdk使用,裸机也可以参考本配置。 二、项目建立步骤 1、新建项目 2、选择芯片型号 3、配置时钟 RCC 设置,选择 HSE(外部高速时钟) 和L…

Baumer工业相机堡盟相机在BGAPI SDK中如何实现Bitmap的复制克隆(C#)

项目场景 Baumer工业相机堡盟相机是一种高性能、高质量的工业相机,可用于各种应用场景,如物体检测、计数和识别、运动分析和图像处理。 Baumer的万兆网相机拥有出色的图像处理性能,可以实时传输高分辨率图像。此外,该相机还具…

Go map 内存泄露

前言 在Go中, map这个结构使用的频率还是比较高的. 其实在所有的语言中, map使用的频率都是很高的. 之前在使用中, 一直都知道map的内存在元素删除的时候不会回收, 但一直没有仔细的研究为什么. 今天就来好好揣摩揣摩. func main() {m : make(map[int][128]byte)for i : 0; …

Python参数类型定义、私有函数与变量、全局变量

函数的参数类型定义 参数名 冒号 类型函数函数定义在Python3.7之后可用函数不会对参数类型进行验证 def add(a:int, b:int3):print(a b)add(1, 2) add(hello, xiaomu)def test(a:int, b:int3, *args:int, **kwargs):print(a, b, args, kwargs)test(1, 2, 3, 4, namexiaomu)…

用户态--fork函数创建进程

我们一般使用Shell命令行来启动一个程序&#xff0c;其中首先是创建一个子进程。但是由于Shell命令行程序比较复杂&#xff0c;为了便于理解&#xff0c;我们简化了Shell命令行程序&#xff0c;用如下一小段代码来看怎样在用户态创建一个子进程。 #include <stdio.h> #i…

OceanBase CTO杨传辉:持续降低使用门槛,打造开发者友好的分布式数据库

3月25日&#xff0c;首届OceanBase开发者大会在北京举行。大会发布了OceanBase 4.1版本&#xff0c;公布两大友好工具&#xff0c;升级文档易用性&#xff0c;统一企业版和社区版代码分支&#xff0c;全面呈现了OceanBase打造极致的开发者友好数据库的成果。过去13年&#xff0…

【广州华锐互动】电力线路检测VR实训系统有哪些特色?

在电力系统运行中&#xff0c;故障测试是非常重要的一环&#xff0c;旨在检测和排除系统中可能存在的故障&#xff0c;保障电力系统的正常运行。传统的故障测试方法往往需要在实际场景下进行操作&#xff0c;不仅操作难度较大&#xff0c;而且存在安全隐患&#xff0c;同时操作…

详解内核态与用户态

介绍下内核态与用户态 内核态和用户态是操作系统中的两种不同的运行状态&#xff0c;它们的区别如下&#xff1a; 权限不同&#xff1a;内核态是操作系统拥有最高权限的运行状态&#xff0c;可以访问系统的所有资源&#xff0c;而用户态只能访问受限的资源。 系统调用&#x…

网易云音乐API部署Vercel获取接口过程

前提&#xff1a;部署自己的网易云接口主要用途在于在完成前端的仿网易云播放器的时候&#xff0c;根据自己部署的接口可以用于获取数据。大体流程是通过在github上fork别人的API接口项目&#xff0c;然后在Vercel部署即可获得自己的网易云后端数据接口了&#xff0c;不过根据我…

(五)Tomcat源码阅读:Connector组件分析

一、概述 因为Connector组件没有实现接口规范&#xff0c;因此我们直接对该类的方法进行分析即可。 二、源码阅读 阅读思路&#xff0c;我的阅读思路是这样的&#xff0c;大的类无非就是对小类的使用&#xff0c;因此我们想分析整体的一下架构的化我们就先从大类出发找到比较…