单例模式你不得不知道的底层原理
?
[](()2、问题的根源
[](()2.1 分析 instance = new Instance();
instance = new Instance(); 这行代码在可以理解为三行伪代码(JVM 中的指令):
memory = allocate(); // 分配对象的内存空间
ctorInstance(memory); // 初始化对象
instance = memory; // 设置 instance 指向刚分配的内存地址
?
上述代码 2 和 3 可能会被重排序(部分 JIT 编译器真实存在),重排序后如下所示:
memory = allocate(); // 分配对象的内存空间
instance = memory; // 设置 instance 指向刚分配的内存地址 (未初始化完成)
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 实例化时指令重排序导致对象“逸出”了,因此我们有如下两种解决思路:
不允许 2 和 3 重排序
运行 2 和 3 重排序,但是不允许其他线程“看到”这个重排序
下面讲述具体实现方案。
[](()3、基于 volatile 的解决方案
在 DoubleCheckedLocking 上做小修改即可(需要基于 JDK1.5 及以上)
package com.lizba.p1;
/**
<p>
</p>
@Author: Liziba
@Date: 2021/6/12 22:51
*/
public class DoubleCheckedLocking {
// private static Instance instance;
private volatile static Instance instance;
p 《一线大厂 Java 面试题解析+后端开发学习笔记+最新架构讲解视频+实战项目源码讲义》开源 ublic static Instance getInstance() {
if (instance == null) { // 第一次检查
synchronized (DoubleCheckedLocking.class) { // 加锁
if (instance == null) { // 第二次检查
instance = new Instance(); // instance 为 volatile, Java 开源项目【ali1024.coding.net/public/P7/Java/git】 问题得以解决
}
}
}
return instance;
}
}
声明 instance 为 volatile 引用变量时,2 和 3 的重排序会被禁止,执行时序图如下:
多线程执行时序图
该方案是通过禁止重排序来实现。
?
[](()4、基于类初始化的解决方案
JVM 在类的初始化阶段(即在 Class 被加载后,且被线程使用前),会执行类的初始化。在执行类的初始化期间,JVM 会去获取一个锁。这个锁可以同步多个线程对同一个类的初始化。
基于这个特性实现的方案被称之为(Initialization On Demand Holder idiom)。
示例代码:
package com.lizba.p1;
/**
<p>
</p>
@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 将会被立即初始化:
T 是一个类,而且一个 T 类型的实例被创建
T 是一个类,且 T 中声明的一个静态方法被调用
T 中声明的一个静态字段被赋值
T 中声明的一个静态字段被使用,而且这个字段不是一个常量字段
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 第三阶段
最后
光给面试题不给答案不是我的风格。这里面的面试题也只是凤毛麟角,还有答案的话会极大的增加文章的篇幅,减少文章的可读性
评论