单例模式双重检查锁定与延迟初始化你不得不知道的底层原理

return instance;

}

}

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

2、问题的根源

2.1 分析 instance = new Instance();

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

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

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

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

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

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

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

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

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

2.2 分析什么是intra-thread semantics

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

在这里插入图片描述

线程执行时序图

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

在这里插入图片描述

多线程执行时序图

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

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

多线程执行时序表:

| 时间 | 线程A | 线程B |

| — | — | — |

| t1 | A1:分配对象的内存空间 | |

| t2 | A3:设置instance指向内存空间 | |

| t3 | | B1:判断instance是否为null |

| t4 | | B2:由于instance不为null,线程B将访问instance引用的对象 |

| t5 | A2:初始化对象 | |

| t6 | A4:访问instance引用的对象 | |

2.3 分析问题关键点

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

  1. 不允许2和3重排序

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

下面讲述具体实现方案。

3、基于volatile的解决方案

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

package com.lizba.p1;

/**

  •  双重检查锁正确示例,JDK1.5及以上
    
  • @Author: Liziba

  • @Date: 2021/6/12 22:51

*/

public class DoubleCheckedLocking {

// private static Instance instance;

private volatile static Instance instance;

public static Instance getInstance() {

if (instance == null) { // 第一次检查

synchronized (DoubleCheckedLocking.class) { // 加锁

if (instance == null) { // 第二次检查

instance = new Instance(); // instance为volatile,问题得以解决

}

}

}

return instance;

}

}

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

在这里插入图片描述

多线程执行时序图

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

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

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

基于这个特性实现的方案被称之为(Initialization On Demand Holder idiom)。

示例代码:

package com.lizba.p1;

/**

  •  实例工厂
    
  • @Author: Liziba

  • @Date: 2021/6/12 23:52

*/

public class InstanceFactory {

private static class InstanceHolder {

public static Instance instance = new Instance();

}

public static Instance getInstance() {

return InstanceHolder.instance;

}

}

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

在这里插入图片描述

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

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

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

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

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

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

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

  5. T是一个顶级类(Top Level Class),而且一个断言语句嵌套在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 | 线程B |

| — | — | — |

| t1 | A1:尝试获取Class对象的初始化锁。这里假设线程A获取到初始化锁。 | B1:尝试获取Class对象的初始化锁,由于线程A获取到了锁,线程B等待获取初始化锁 |

| t2 | A2:线程A看到对象还未被初始化(state=noInitialization),线程设置state=noInitializating | |

| t3 | A3:线程A释放初始化锁 | |

5.2 第二阶段

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

图示如下:

在这里插入图片描述

类初始化-第2阶段

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

| 时间 | 线程A | 线程B |

| — | — | — |

| t1 | A1:执行类的静态初始化和初始化类中声明的静态字段 | B1:获取到初始化锁 |

| t2 | | B2:读取到state=initializing |

| t3 | | B3:释放初始化锁 |

| t4 | | B4:在初始化锁的condition中等待 |

5.3 第三阶段

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

在这里插入图片描述

类初始化-第3阶段

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

| 时间 | 线程A |

| — | — |

| t1 | A1:获取初始化锁 |

| t2 | A2:设置state=initialized |

| t3 | A3:唤醒在condition中等待的所有线程 |

| t4 | A4:释放初始化锁 |

| t5 | A5:线程A的初始化过程完成 |

5.4 第四阶段

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

在这里插入图片描述

类初始化-第4阶段

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

| 时间 | 线程B |

| — | — |

| t1 | B1:获取初始化锁 |

| t2 | B2:读取到state=initialized |

| t3 | B3:释放初始化锁 |

| t4 | B4:线程B的类的初始化过程完成 |

第五阶段

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

在这里插入图片描述

类初始化-第5阶段

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

| 时间 | 线程C |

| — | — |

| t1 | C1:获取初始化锁 |

| t2 | C2:读取到state=initialized |

| t3 | C3:释放初始化锁 |

| t4 | C4:线程B的类的初始化过程完成 |

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

6、总结

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

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

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

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

最后

自我介绍一下,小编13年上海交大毕业,曾经在小公司待过,也去过华为、OPPO等大厂,18年进入阿里一直到现在。

深知大多数初中级Android工程师,想要提升技能,往往是自己摸索成长,自己不成体系的自学效果低效漫长且无助

因此我收集整理了一份《2024年Android移动开发全套学习资料》,初衷也很简单,就是希望能够帮助到想自学提升又不知道该从何学起的朋友,同时减轻大家的负担。

既有适合小白学习的零基础资料,也有适合3年以上经验的小伙伴深入学习提升的进阶课程,基本涵盖了95%以上Android开发知识点!不论你是刚入门Android开发的新手,还是希望在技术上不断提升的资深开发者,这些资料都将为你打开新的学习之门

如果你觉得这些内容对你有帮助,需要这份全套学习资料的朋友可以戳我获取!!

由于文件比较大,这里只是将部分目录截图出来,每个节点里面都包含大厂面经、学习笔记、源码讲义、实战项目、讲解视频,并且会持续更新!
因此我收集整理了一份《2024年Android移动开发全套学习资料》,初衷也很简单,就是希望能够帮助到想自学提升又不知道该从何学起的朋友,同时减轻大家的负担。*

[外链图片转存中…(img-uG4LzRlP-1715861112826)]

[外链图片转存中…(img-UPiqLx9o-1715861112828)]

[外链图片转存中…(img-MqjnxNiU-1715861112829)]

[外链图片转存中…(img-pL73SiSN-1715861112830)]

既有适合小白学习的零基础资料,也有适合3年以上经验的小伙伴深入学习提升的进阶课程,基本涵盖了95%以上Android开发知识点!不论你是刚入门Android开发的新手,还是希望在技术上不断提升的资深开发者,这些资料都将为你打开新的学习之门

如果你觉得这些内容对你有帮助,需要这份全套学习资料的朋友可以戳我获取!!

由于文件比较大,这里只是将部分目录截图出来,每个节点里面都包含大厂面经、学习笔记、源码讲义、实战项目、讲解视频,并且会持续更新!

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值