深浅模式
多线程-单例模式
更新: 1/23/2026 字数: 0 字
A. 双检锁 (DCL, Double-Checked Locking)
通过手动加锁和 volatile 关键字保证线程安全。
java
public class Singleton {
// 必须加 volatile 关键字
private static volatile Singleton instance;
private Singleton() {
}
public static Singleton getInstance() {
// 第一次检查:避免不必要的加锁
if (instance == null) {
synchronized (Singleton.class) {
// 第二次检查:确保只创建一个实例
if (instance == null) {
instance = new Singleton();
}
}
}
return instance;
}
}为什么必须加 volatile?
如果不加,instance = new Singleton(); 可能会发生指令重排:
- 1.分配内存
- 2.将 instance 指向内存地址
- 3.初始化对象
线程 A 执行到步骤 2 时,线程 B 判断 instance != null 直接返回,导致拿到一个未初始化完成的“半成品”对象。
B. 静态内部类 (Holder 模式)
利用 JVM 类加载机制保证安全,是目前最推荐的写法。
java
public class Singleton {
private Singleton() {
}
private static class Holder {
// static: 全局唯一
// final: 确保初始化安全性(写屏障)
// 写操作屏障:在 Holder 类完成初始化(即 <clinit> 执行完毕)并允许其他线程访问 Holder.INSTANCE 之前,INSTANCE 指向的内存必须已经完全写入。
// 构造函数边界:这里的“构造函数返回之前”对 static 变量来说,指的就是 类初始化动作完成之前。
static final Singleton INSTANCE = new Singleton();
}
public static Singleton getInstance() {
return Holder.INSTANCE;
}
}核心优势分析
懒加载:加载 Singleton 类时,Holder 不会被加载。只有调用 getInstance() 时才会触发 Holder 的初始化。
线程安全:JVM 在加载类时会获取内部锁,确保多个线程同时访问时,类只被初始化一次。
高性能:无需手动加锁,利用 JVM 底层机制实现同步。
静态内部类单例深度解析
- 加载 Singleton 类时,内部类
Holder并不会被加载,也不会初始化; - 第一次调用 getInstance() 方法时,代码访问了
Holder.INSTANCE。此时,JVM才会发现需要加载并初始化Holder类; - 线程安全性:当多个线程同时尝试初始化同一个类时,
JVM会获取初始化锁,确保所有线程看到同一个完全构造好的实例。
JMM 要求编译器和处理器在 final 域的写之后,构造函数返回之前,插入一个 StoreStore 屏障,保证在构造器返回之前,final 字段是完全初始化完成的; 针对读方的约束(LoadLoad 屏障):初次读一个包含 final 域的对象引用,与随后初次读这个 final 域,这两个操作之间不能重排序; final 保护的是“内部属性”:它保证对象里面的成员变量是构造完整的; volatile 保护的是“引用本身”:在 DCL 中,instance = new Singleton() 这个赋值操作本身在非 final 情况下可能由于重排序导致,instance 提前变为非 null;
在 Holder 模式下,即便内部是普通变量,依然是安全的,因为 类加载(Class Loading)的可见性保障 比普通的 final 规则还要强;
为什么普通变量也安全?(类加载的可见性屏障) 在
Java中,类的初始化过程是由JVM严格控制的。根据JMM规范,类的初始化阶段(执行<clinit>)和“读取该类静态变量”之间存在一个显式的Happens-Before关系。 线程 A:初始化Holder类,执行new Singleton()。此时它修改了Singleton内部的普通变量int x = 10。JVM内部锁:JVM保证在线程 A 完成<clinit>之前,其他任何线程都不能访问这个类。 可见性传递:当线程 A 完成初始化并释放类初始化锁后,JMM 保证初始化阶段产生的所有写入(包括对Singleton内部普通变量的写入)对后续访问该类的线程全部可见。既然如此,为什么还要写
final? 主要有三个原因: 防止引用被篡改: 如果没有final,单例实例可能会被类内部的其他方法(或者通过反射)修改,指向另一个对象,破坏了“单例”的唯一性。 额外的JMM保障(双重保险):final提供的“初始化安全性”是语言层面的强约束。万一未来代码演变成不再通过类加载器初始化(虽然在这个模式下不可能),final依然能保证对象的完整性。 性能优化:JVM编译器(如 JIT)对final变量有特殊的优化逻辑,因为它知道这个值一旦设定就不会改变,可以更放心地址进行内联或常量折叠。
安全性来源:程序员手动加锁
java
// 情况 A:Holder 模式
// Holder 内部
static final Singleton INSTANCE = new Singleton();
// 安全性来源:JVM 类加载锁 + Happens-Before 规则。
// 结论:哪怕 Singleton 里的变量全是普通的,也 100% 安全。
// 情况 B:双重检查锁定 (DCL)
public static Singleton getInstance() {
if (instance == null) {
synchronized (Singleton.class) {
if (instance == null) {
// 这里如果没有 volatile 或 final,就会出事
instance = new Singleton();
}
}
}
return instance;
}结论:
如果 instance 不加 volatile(或者内部变量不加 final),线程 B 可能会在 synchronized 块外面看到一个“半成品”对象。因为 DCL 避开了类加载器的自动同步。
Holder 静态内部类写法中:
1、final 确保了 INSTANCE 引用本身不可变,并提供了对象构造的“写屏障”。
2、static 触发了 JVM 的类加载机制,利用类初始化锁保证了线程安全和全局可见性。
这种组合拳让它成为了 Java 中性能最高、最简洁的懒加载单例实现
2. 深度对比:Holder 模式 vs DCL
| 维度 | Holder 内部类模式 | 双重检查锁定 (DCL) |
|---|---|---|
| 安全性来源 | JVM 类加载锁 + Happens-Before | 程序员手动加锁 (synchronized) |
| 可见性保障 | 类加载的可见性屏障(比 final 更强) | 必须依赖 volatile 或 final |
| 性能表现 | 极高(利用 JVM 原生机制,无额外锁开销) | 较高(但存在 volatile 内存屏障开销) |
| 实现风险 | 简洁、稳固,不易出错 | 容易遗漏关键字导致重排序 Bug |
| 结论 | 推荐:哪怕内部是普通变量也 100% 安全 | 慎用:避开了类加载器的自动同步 |
Happens-Before
是 Java 内存模型(JMM)中最核心的概念。它是判断数据是否存在竞争、线程是否安全的主要依据。
简单来说,Happens-Before 是一套规则,用来定义**“操作 A 是否对操作 B 可见”**。如果操作 A Happens-Before 操作 B,那么 A产生的所有影响(修改变量、内存写入等)在 B 执行时都能被观察到。
1. 为什么要引入 Happens-Before?
在底层,为了提高性能,编译器、处理器和缓存会频繁进行指令重排序和内存刷新延迟。 如果没有一套规则,程序员将完全无法预测多线程代码的结果。Happens-Before就像是程序员与 JVM 之间的一份协议:
“只要你遵循这些规则,我就保证你的代码执行结果是符合逻辑可见性的。”
2. Happens-Before 的核心规则
JMM 定义了 8 条原生规则,其中与我们日常开发最相关的有 5 条:
① 程序次序规则 (Program Order Rule) 内容:在一个线程内,按照代码书写顺序,前面的操作 Happens-Before 后面的操作。
解读:虽然单线程内也会重排序,但 JVM 保证最终执行结果与顺序执行的结果一致。
② 管程锁定规则 (Monitor Lock Rule) 内容:一个 unlock(解锁)操作 Happens-Before 后续对同一个锁的 lock(加锁)操作。
解读:如果线程 A 释放了锁,随后线程 B 获取了锁,那么线程 A 在释放锁之前做的所有操作,对线程 B 都是可见的。
③ volatile 变量规则 (Volatile Variable Rule) 内容:对一个 volatile 变量的写操作 Happens-Before 后续对这个变量的读操作。
解读:这强制了变量从工作内存刷新到主内存,保证了实时可见性。
④ 传递性 (Transitivity) 内容:如果 A Happens-Before B,且 B Happens-Before C,那么 A Happens-Before C。
⑤ 线程启动/终止规则 内容:Thread.start() Happens-Before 该线程的每一个动作;线程的所有动作都 Happens-Before 此线程的终止检测(如 Thread.join())。
重点:类初始化规则(解开你单例模式的疑惑) 这一条规则是解决单例模式安全性的关键:
一个类的初始化过程(<clinit> 方法)Happens-Before 于后续任何对该类的静态变量的访问。
在你的 Singleton Holder 模式中:
线程 A 触发 Holder 类加载。
JMM 保证:执行静态常量 INSTANCE 的赋值(构造函数逻辑)是初始化阶段的一部分。
Happens-Before 发生:初始化阶段的所有操作 Happens-Before 任何线程调用 getInstance() 时读取 Holder.INSTANCE。
结果:即使 Singleton 内部有普通变量(非 volatile,非 final),由于这个规则的存在,它们也会在 INSTANCE 被读到之前强制刷新到主内存。
Happens-Before 与“时间先后”的区别 这是最容易混淆的点。
时间先后:A 先发生,B 后发生,不代表 A 对 B 可见(比如缓存导致可见性问题)。
Happens-Before:这是逻辑可见性。即便 A 和 B 发生了指令重排(B 的指令在 A 之前),只要 JMM 保证 A 的结果对 B 可见,我们就说 A Happens-Before B。
总结:如何利用它? 当你分析一段多线程代码是否安全时,不需要去研究复杂的汇编指令,只需套用这几条规则:
是否有 synchronized(管程锁定规则)?
是否有 volatile 变量(volatile 规则)?
是否有 final 变量(虽然不是 HB 规则,但有类似的初始化安全性)?
是否利用了 类初始化锁(单例 Holder 模式的原理)?
如果以上都没有,且两个线程在操作同一个变量,那么这段代码就是不安全的。
我们可以利用 volatile 的 可见性 和 Happens-Before 的 传递性 规则,实现一种非常巧妙的“顺带同步”。
- 传递性规则的妙用 假设我们有两个变量,一个是普通变量 a,一个是 volatile 变量 v。
java
public class VolatileExample {
int a = 0;
volatile boolean v = false;
public void writer() {
a = 42; // 步骤 1
v = true; // 步骤 2
}
public void reader() {
if (v) { // 步骤 3
System.out.println(a); // 步骤 4
}
}
}JVM 底层的 内存屏障(Memory Barrier) 机制
- 什么是内存屏障? 内存屏障是一组硬件指令,它主要做两件事:
阻止重排序:禁止屏障两侧的指令交换顺序。
强制刷新缓存:确保数据写入主内存,或从主内存读取最新数据。
对于 volatile 变量,JVM 会在指令序列中插入四种屏障:
StoreStore 屏障:保障屏障前的写操作,先于屏障后的写操作刷新到内存。StoreLoad 屏障:最重的一个屏障,保证写操作先于后面的读操作。LoadLoad 屏障:保障屏障前的读操作,先于屏障后的读操作。LoadStore 屏障:保障屏障前的读操作,先于屏障后的写操作。
- 正常顺序下的内存布局(步骤 1 在前)
当代码顺序是 a = 42; v = true; 时,JVM 的底层动作如下:
| 步骤 | 操作 | 底层屏障/动作 | 作用 |
|---|---|---|---|
| 1 | a = 42 | 普通写 (Store) | 修改线程本地内存/缓存中的共享变量 |
| 屏障 | StoreStore 屏障 | 禁止禁止重排序:确保在该屏障之前的普通写操作(a)在 volatile 写操作(v)执行前,已经刷新到主内存中 | |
| 2 | v = true | Volatile 写 (Store) | 更新 volatile 变量的值,发出状态信号 |
| 屏障 | StoreLoad 屏障 | 最重屏障:确保 volatile 写操作刷新到主内存,并防止其与后面可能存在的读操作重排序 |
在这种情况下,由于 StoreStore 屏障的存在,a = 42 永远不可能被重排序到 v = true 之后。
- 互换顺序后的灾难(步骤 2 在前) 如果你把代码改为 v = true; a = 42;,底层逻辑就变成了:
执行 v = true(Volatile 写)。
触发屏障,同步 v 的值。
执行 a = 42(普通写)。
问题出在哪里? 线程 B(Reader)在执行 if (v) 时,只要步骤 1 完成了,v 就是 true。但此时线程 A 可能还没来得及执行步骤 3(a = 42),或者执行了但还没刷新到主内存。线程 B 读到的 a 依然是初始值 0。
- Happens-Before 规则的“传递性” 这是 Java 并发内存模型(JMM)中的核心概念。在原代码中:
根据程序次序规则:a = 42 Happens-Before v = true。
根据 Volatile 规则:线程 A 的 v = true Happens-Before 线程 B 的 v == true 判定。
根据传递性:a = 42 Happens-Before 线程 B 的 System.out.println(a)。
一旦你互换了顺序,第一条规则就失效了,传递性也就断了。
总结
volatile 变量就像是一个门卫。
原顺序:搬运工先把货(a=42)放进仓库,门卫再锁门(v=true)。你看到锁门了,货一定在。
互换后:门卫先锁门,搬运工才开始搬货。你看到锁门了,仓库里可能还是空的。
这种“先改数据,后改标志位”的模式也被称为 Release-Acquire 语义,是构建无锁编程(Lock-free)的基础。
3.按照 Happens-Before 规则推导:
程序次序规则:操作 1 Happens-Before 操作 2。
volatile 变量规则:操作 2(写 v)Happens-Before 操作 3(读 v)。
传递性:因为 1 -> 2 且 2 -> 3,所以 操作 1 Happens-Before 操作 3。
最终推导:操作 1 也 Happens-Before 操作 4。
结论:即使 a 是一个普通变量,只要线程 B 读到了 v 为 true,它就一定能看到线程 A 对 a 做的修改。这就是所谓的“搭便车”机制。
- 回到你的单例模式:Happens-Before 的全家桶 你的 Holder 单例模式其实集齐了 JMM 中最强大的几条保证:
类加载锁(内置同步):JVM 在执行 static final Singleton INSTANCE = new Singleton(); 时,会自动获取一个锁,确保只有一个线程能执行初始化。
初始化可见性:根据“类初始化规则”,这个初始化写入对任何读取 INSTANCE 的线程可见。
final 语义:final 确保了 Singleton 对象内部构造的完整性。
- 反思:为什么 DCL (双重检查) 容易写错? 对比一下,很多开发者写的 DCL 为什么会挂:
java
// 错误案例
public static Singleton getInstance() {
if (instance == null) {
// 1. 这里没有 Happens-Before 保证,可能读到半成品
synchronized (Singleton.class) {
if (instance == null) {
instance = new Singleton();
}
}
}
return instance;
}由于步骤 1 在同步块之外,它不遵循管程锁定规则。如果没有 volatile 保证,线程在这里看到的 instance 可能因为指令重排,指向了一块还没初始化完的内存(即 Happens-Before 断开了)。
4. 总结:JMM 的核心哲学
JMM 的 Happens-Before 原则实际上是给编译器和 CPU 留出了优化空间,同时给程序员提供了一个逻辑上的“确定性”锚点。
没有规则,就没有可见性。
有了规则,就有了跨线程的顺序保证。
你的 Holder 模式之所以被称为“教科书级”,就是因为它不需要你手动去维护这些复杂的 Happens-Before 链条,而是直接利用了 JVM 最底层、最稳固的类加载协议。
在最新的 Java 版本(如 Java 17、21 乃至 2026 年的现状)中,单例模式的核心逻辑依然稳固,但在性能优化、底层内存模型增强以及新的语言特性方面,有一些非常值得关注的演进。
- VarHandle:比 volatile 更细粒度的控制 在 Java 9 之后引入了 VarHandle,它允许开发者以更细粒度的方式处理 Happens-Before 关系。
在传统的 DCL 单例中,我们将变量声明为 volatile。但这会带来一点性能开销,因为每次读取 instance 都会触发内存屏障。使用 VarHandle,我们可以实现**“普通读 + 获取(Acquire)语义写”**,在某些高性能场景下进一步压榨 CPU 性能。
密封类(Sealed Classes)与单例安全性 Java 17 引入了 Sealed Classes。在以前,为了防止单例类被继承破坏,我们必须使用 final 关键字。 现在,你可以通过 sealed 更加精准地控制继承树。虽然单例通常就是 final,但在复杂的框架设计中,密封类提供了一种“有证可循”的扩展方式,同时保证了单例逻辑不被意外篡改。
Project Loom (虚拟线程) 带来的冲击 Java 21 正式发布了虚拟线程(Virtual Threads)。
以前:线程很贵,单例模式中的 synchronized 锁竞争会导致内核级线程阻塞,开销巨大。
现在:虚拟线程是用户态的,阻塞成本极低。这意味着即使你使用简单的同步方法实现单例,在海量虚拟线程并发时,性能表现也会比以前好得多。
但注意:你的 Holder 模式 依然是虚拟线程时代的首选,因为它完全不阻塞(除了第一次初始化),这完美契合了响应式和高并发的需求。
- 永久代(PermGen)消失后的类加载 在旧版本 Java 中,类元数据存在永久代,容易 OOM。现在类信息存在 Metaspace(元空间)。
这一点强化了 Holder 模式的可靠性:即使应用中有大量的单例 Holder,也不会像以前那样容易撑爆内存,因为元空间使用的是本地内存,且回收机制更高效。
最后的思考:为什么你给出的这段代码经久不衰? 即使 Java 版本不断更迭,你开头给出的那 10 行代码依然是面试和实战中的“黄金标准”。它优雅地平衡了 Happens-Before 安全性、懒加载性能 和 代码简洁度。
可以说,理解了这段代码,你就理解了 Java 类加载机制与内存模型的精华。
