单例模式

前言

单例模式的目的极其明确:确保一个类在任何情况下都绝对只有一个实例,并提供一个全局访问点。

这看似简单,但在实际的工程应用(尤其是高并发后端系统)中,要做到“绝对安全”却暗藏玄机。

在系统开发中,有些对象如果出现多个实例,会导致资源浪费、状态不一致或逻辑混乱。

  1. 全局状态与历史管理器:例如在医疗辅助诊断系统中,针对某次会话的诊断历史记录管理器(Diagnostic History Manager)。如果在不同模块(如影像解析模块和问诊模块)中存在多个历史管理器实例,会导致历史数据无法同步,出现丢记录的 Bug。
  2. 硬件/系统资源访问:数据库连接池(Connection Pool)、线程池。建立连接极其消耗资源,整个系统应当共享同一个连接池来统一调度。
  3. 系统配置信息类:读取全局的配置文件(如 .properties.yml),这些配置在内存中只需存一份即可。

本文项目源码链接:https://github.com/likerhood/CodeDesignWork/tree/main/codedesign4.0-0


一、单例模式介绍

image-20260430151518892

单例模式是设计模式中最简单、也最符合开发者直觉的模式。

它在实际开发中的应用极高,其核心设计理念可以高度浓缩为两点:

  • 绝对唯一:在多线程等复杂环境下,保证一个类仅有一个实例,并提供一个全局访问点。
  • 性能优化:阻断全局类的频繁创建与销毁,降低资源开销,从根本上提升系统整体性能。

二、 六种实现方式

单例的核心构造原则是:构造方法私有化(Private Constructor)

但在多线程环境下,如何安全地向外暴露这个实例,就演化出了不同的写法。

2.1 饿汉式

最简单,但可能浪费资源。 类加载时就立即初始化,利用类加载机制保证了线程安全。

  1. 私有化构造函数;
  2. 通过static静态变量保证类加载时就会创建该类的实例对象
  3. 提供全部访问这个单例对象的公开方法(静态方法)。

在 Java 中,当一个类被 JVM 加载到内存并进行初始化时,JVM 会自动执行类中的静态代码块(static {})以及静态变量的赋值操作

因为我们写了 private static final EagerSingleton INSTANCE = new EagerSingleton();,所以当 EagerSingleton 这个类一被 JVM 识别并加载,这行代码就会立刻执行,从而调用了私有构造器,把对象创建出来了。而且,JVM 底层的类加载机制天生是线程安全的,这就保证了在多线程环境下,这个对象也只会被创建一次。

缺点:如果这个类包含了庞大的数据字典或需要加载大量本地资源,且系统启动后很久都没用到它,就会白白占用内存。

2.1.1 模板代码

1
2
3
4
5
6
7
8
9
10
public class EagerSingleton {
// 1. 私有化构造器
private EagerSingleton() {}
// 2. 类加载时立刻实例化,天生线程安全
private static final EagerSingleton INSTANCE = new EagerSingleton();
// 3. 提供全局访问点
public static EagerSingleton getInstance() {
return INSTANCE;
}
}

2.1.2 详细测试代码

模拟如下场景,在单例模式的类中初始化需要做这些工作:

  • 读取了一个 50MB 的本地配置文件。
  • 建立了一个数据库连接池。
  • 初始化了一个庞大的数据字典。

如果你的系统在启动时触发了这个类的加载(比如你不小心调用了这个类的其他静态方法,或者用反射扫到了它),即使你当前根本不需要用到这个单例对象,这 50MB 的内存和数据库连接也就被死死占用了。这就叫“白白浪费了系统资源”。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
package com.likerhood.design;

/**
* 饿汉式,单例模式
*/
public class Singleton_00 {

// 假设这是一个其他的静态变量
public static final String SOME_OTHER_STATIC_FIELD = new String("饿汉式单例模式");

// 1. 静态变量,在类加载时初始化
private static final Singleton_00 INSTANCE = new Singleton_00();

// 2. 私有化构造器,里面模拟“耗时且占用资源”的操作
private Singleton_00() {
System.out.println(">>> 构造器被调用!Singleton_00实例正在被创建...");
System.out.println(">>> 正在加载 50MB 配置文件...");
System.out.println(">>> 正在建立数据库连接...\n");
}

// 3. 提供全局访问点
public static Singleton_00 getInstance() {
System.out.println("--- getInstance() 方法被调用 ---");
return INSTANCE;
}

public void doSomething() {
System.out.println("执行饿汉式单例对象的核心业务逻辑。");
}
}


public class ApiTest {

public static void test_singleton_00(){

System.out.println("========== 系统启动 ==========\n");

// 模拟系统运行了一段时间,我们仅仅是访问了这个类的另一个静态变量
System.out.println("业务代码:我尝试访问 HeavyEagerSingleton 的另一个静态变量...");
String temp = Singleton_00.SOME_OTHER_STATIC_FIELD;
System.out.println("业务代码获取到的值: " + temp + "\n");
// 注意:我们并没有调用 HeavyEagerSingleton.getInstance() !!!
System.out.println("此时没有在该方法中接受饿汉式单例模式的实例对象,但是该实例对象已经被创建\n");

// 直到此刻,我们才真正需要用到这个单例对象
System.out.println("业务代码:我现在真正需要饿汉式单例模式的单例对象了!");
Singleton_00 instance1 = Singleton_00.getInstance();
Singleton_00 instance2 = Singleton_00.getInstance();

System.out.println("\ninstance1 和 instance2 是同一个对象吗? " + (instance1 == instance2));

instance1.doSomething();

System.out.println("饿汉式单例模式执行完成");
}


public static void main(String[] args) {

test_singleton_00();
}

}

上述测试结果如下:

image-20260425214652565

该代码案例中的内存图可视化如下:

image-20260425214816026

2.2 懒汉式 - 线程不安全版

懒汉式单例模式为了解决饿汉式的资源浪费问题:将对象实例的初始化改为延迟加载。

2.2.1 模板代码

1
2
3
4
5
6
7
8
9
10
11
public class LazyUnsafeSingleton {
private static LazyUnsafeSingleton instance;
private LazyUnsafeSingleton() {}

public static LazyUnsafeSingleton getInstance() {
if (instance == null) { // 线程A跑到这里,时间片耗尽
instance = new LazyUnsafeSingleton(); // 线程B跑到这里并创建了实例
}
return instance; // 最终线程A恢复,又创建了一个实例!单例被打破。
}
}

系统刚启动,或者很长一段时间内:没有任何业务调用 getInstance(),那么 instance 就一直是 null。什么都没发生,内存极其干净。

第一次有人调用 getInstance()

  1. 程序走到 if (instance == null),发现确实是 null(条件成立)。
  2. 于是进入 if 代码块内部,执行 new LazyUnsafeSingleton(),在这个时刻,才真正去申请内存、消耗 CPU 资源来创建对象。
  3. 创建完成后,把这个对象的地址赋给 instance 变量,最后返回。

回忆一下在前文对“饿汉式”的测试与总结:饿汉式最大的缺点是“只要触发类加载,不管你用不用,对象都会被立刻创建,白白浪费资源”。

而懒汉式的这个 if,就是专门为了解决这个资源浪费而生的。

场景对比(以耗时 50MB 内存的诊断影像解析引擎为例):

  • 使用“饿汉式”的灾难: 系统刚启动,仅仅是因为某个模块读取了该类的一个普通静态配置,JVM 触发了类加载。不管当前有没有医生在使用影像解析功能,引擎立刻被初始化,瞬间吃掉 50MB 内存,甚至霸占了几个数据库连接。如果今天一整天都没有人拍片子,这 50MB 内存就白白浪费了一整天。
  • 使用“懒汉式”的优雅(有了这个 if): 系统启动,类被加载,但此时 instance 只是一个空荡荡的 null完全不占用那 50MB 的核心内存

但在多线程下会发生“意外”。

意外情况:在高并发下,多个线程同时通过了 if (instance == null) 的判断,最终会产生多个实例,导致内存泄漏或状态覆盖。

  1. 线程 A 发现它是 null,刚准备进去 new,还没来得及 new,操作系统的 CPU 把线程 A 暂停了。
  2. 此时线程 B 来了,它看到 instance 依然是 null(因为 A 还没造出来),于是 B 进去了,并且顺利 new 了一个对象。
  3. 接着 CPU 切换回线程 A,A 从刚才暂停的地方继续往下走,它不会再去判断一次 if,而是直接又执行了一次 new

这就导致了之前压测中出现的情况:原本为了节省资源的 if,在并发下由于防线被集体突破,反而造出了无数个多余的实例。

这就是为什么我们在后面的演进中,必须在这个 if 上面加锁(synchronized),甚至演化出双重检查锁(DCL)——本质上都是在给这个脆弱但又极其重要的 if 穿上防弹衣。

2.2.2 详细测试代码

模拟以下场景:

维纳斯系统(一个医学图像辅助诊断系统)(Venus System)刚刚部署上线或重启,系统还处于冷启动状态,内存中尚未初始化“全局诊断影像解析引擎”。

突然,早上 8 点整,全院 100 位门诊医生在同一瞬间点击了患者历史病历中的“查看影像诊断”按钮。这 100 个请求犹如潮水一般涌入后端服务器,几乎在同一微秒向系统索要解析引擎的实例(即疯狂调用 getInstance())。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
public class Singleton_01 {

private static Singleton_01 instance;

private Singleton_01() {
System.out.println(Thread.currentThread().getName() + " >>> 正在分配内存,初始化诊断影像解析引擎...");
try {
// 【关键点】:模拟引擎初始化的耗时操作,也是为了故意放大并发漏洞
Thread.sleep(50);
} catch (InterruptedException e) {
e.printStackTrace();
}
}

public static Singleton_01 getInstance() {
// 步骤 1:判断实例是否为空
if (instance == null) {

// 【极其危险的区域】:在高并发下,如果有多个线程同时通过了上面的 null 判断,
// 它们会在这里排队执行 new 操作。
// 为了让测试更容易暴露出问题,我们让线程在这里稍微停顿一下(模拟时间片耗尽)
try {
Thread.sleep(10);
} catch (InterruptedException e) {
e.printStackTrace();
}

// 步骤 2:创建实例
instance = new Singleton_01();
}
return instance;
}
}


public class ApiTest {

public static void test_singleton_01() throws InterruptedException{

System.out.println("========== 懒汉式单例模式线程不安全版本场景模拟:\n 维纳斯系统启动:高并发获取影像解析引擎 ==========\n");

int threadCount = 100; // 模拟 100 个并发请求

// 发令枪:用于让所有线程在同一起跑线等待
CountDownLatch startLatch = new CountDownLatch(1);
// 终点线:用于等待所有线程执行完毕
CountDownLatch endLatch = new CountDownLatch(threadCount);

// 线程安全的 Set,用来收集各个线程获取到的实例
// 如果是真正的单例,最后 Set 的 size 应该是 1
Set<Singleton_01> instanceSet = ConcurrentHashMap.newKeySet();

// 创建线程池
ExecutorService executor = Executors.newFixedThreadPool(threadCount);

for (int i = 0; i < threadCount; i++) {
executor.submit(() -> {
try {
// 1. 所有线程在此阻塞,等待发令枪倒计时归零
startLatch.await();

// 2. 发令枪响,大家同时去获取单例!
Singleton_01 parser = Singleton_01.getInstance();

// 3. 将获取到的对象放入 Set 中(Set 会利用对象的内存地址自动去重)
instanceSet.add(parser);

} catch (InterruptedException e) {
e.printStackTrace();
} finally {
// 4. 线程执行完毕,终点线计数减 1
endLatch.countDown();
}
});
}

// 100个线程瞬间并发执行
startLatch.countDown();

// 主线程等待所有子线程跑到终点
endLatch.await();
executor.shutdown();

System.out.println("\n========== 测试结果统计 ==========");
System.out.println("并发请求数量: " + threadCount);
System.out.println("实际创建的影像解析引擎实例数量: " + instanceSet.size());

if (instanceSet.size() > 1) {
System.err.println("❌ 灾难发生:单例被打破!内存中出现了多个不同的实例!");
for (Singleton_01 instance : instanceSet) {
System.out.println("实例内存地址:" + instance);
}
} else {
System.out.println("✅ 单例正常工作。");
}



}


public static void main(String[] args) {

test_singleton_01();
}

}

上述测试结果如下:

image-20260429133040913

结果说明:

面对 100 个同时涌入的线程, getInstance() 方法彻底失效了。

image-20260429134458825

系统中唯一的对象重复创建了 96 次,底部的那些 @43211023@4f667964 就是这些对象在 JVM 堆内存中的真实物理地址,说明这个引擎不是同一个引擎。

在真实的维纳斯系统里,这个引擎可能包含庞大的图像解析模型。正常情况下它只需要加载一次(消耗 50MB 内存)。但在这种并发漏洞下,系统瞬间为它分配了 96 次内存(近 5GB 资源被瞬间吞噬!)。如果是在生产环境,这会导致两个灾难性后果:

  1. OOM (Out Of Memory) 内存溢出:系统资源瞬间被抽干,服务器直接宕机。
  2. 状态覆盖:哪怕内存没爆,96 个独立的引擎实例也会导致后续处理影像时,数据状态完全无法同步,出现“医生 A 保存的诊断记录,医生 B 完全看不到”的幽灵 Bug。

2.3 懒汉式 - 同步锁版(线程安全但低效)

为了解决上面的线程不安全问题,接下来在 getInstance() 方法上直接加上了 synchronized 关键字。在 Java 中,静态方法上的 synchronized 相当于把整个类对象(Class 对象)当作了锁。

2.3.1 模板代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class Singleton_02 {

private static Singleton_02 instance;

private Singleton_02() {
// 模拟第一次加载庞大的维修问答知识图谱
System.out.println(">>> 正在初始化维修问答系统的全局知识图谱...");
}

// 【性能瓶颈】:直接在方法上加锁(相当于锁住了整个 Singleton_02.class)
public static synchronized Singleton_02 getInstance() {
if (instance == null) {
instance = new Singleton_02();
}
// 对于已经创建好实例之后的几百万次调用来说,这里纯粹只是一个返回引用的“读操作”
return instance;
}
}
  • 缺点:锁的粒度太大。如果只有第一次创建实例时才需要同步,后续获取实例全是读操作。每次调用 getInstance() 都要竞争锁,性能极差。

2.3.2 详细测试代码

模拟以下场景:

假设系统里有一个全局海洋装备知识图谱检索组件。这个组件只需要在第一次被调用时加载一次知识库(写操作),但之后,每一次用户的提问、每一个并发的检索任务,都需要频繁调用 getInstance() 来获取它进行查询(高频读操作)。

为了体现出差距,我们在测试中用饿汉式 Singleton_00(它的 getInstance() 是无锁的)作为参照物。

我们让 100 个线程,每个线程疯狂调用 100 万次 getInstance(),看看有锁和无锁在极端高频“读操作”下的耗时天堑。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
public class LazySafeSingleton {
private static LazySafeSingleton instance;
private LazySafeSingleton() {}

// 直接在方法上加锁
public static synchronized LazySafeSingleton getInstance() {
if (instance == null) {
instance = new LazySafeSingleton();
}
return instance;
}
}



public class ApiTest {

public static void test_singleton_02() throws InterruptedException {
System.out.println("========== 维修问答系统并发性能压测:有锁 VS 无锁 ==========\n");

int threadCount = 100; // 100个并发线程
int loopCount = 1_000_000; // 每个线程调用 100万次获取实例的方法

ExecutorService executor = Executors.newFixedThreadPool(threadCount);

// ================= 第 1 轮:测试无锁的获取方式(对照组) =================
// 先触发一次类加载,排除初始化耗时干扰
Singleton_00.getInstance();

CountDownLatch startLatch1 = new CountDownLatch(1);
CountDownLatch endLatch1 = new CountDownLatch(threadCount);

for (int i = 0; i < threadCount; i++) {
executor.submit(() -> {
try {
startLatch1.await();
// 模拟高频次获取实例进行知识库检索
for (int j = 0; j < loopCount; j++) {
Singleton_00.getInstance(); // 无锁调用
}
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
endLatch1.countDown();
}
});
}

long startTime1 = System.currentTimeMillis();
startLatch1.countDown(); // 发令枪响
endLatch1.await(); // 等待所有线程跑完
long endTime1 = System.currentTimeMillis();
System.out.println("✅ [无锁获取] 100个线程各获取 100万次,总耗时: " + (endTime1 - startTime1) + " ms");


// ================= 第 2 轮:测试同步锁方法(性能瓶颈组) =================
// 先触发一次初始化,确保接下来的测试全是“读操作”
Singleton_02.getInstance();

CountDownLatch startLatch2 = new CountDownLatch(1);
CountDownLatch endLatch2 = new CountDownLatch(threadCount);

for (int i = 0; i < threadCount; i++) {
executor.submit(() -> {
try {
startLatch2.await();
// 模拟高频次获取实例进行知识库检索
for (int j = 0; j < loopCount; j++) {
Singleton_02.getInstance(); // 有锁调用!
}
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
endLatch2.countDown();
}
});
}

long startTime2 = System.currentTimeMillis();
startLatch2.countDown(); // 发令枪响
endLatch2.await(); // 等待所有线程跑完
long endTime2 = System.currentTimeMillis();
System.out.println("❌ [同步锁获取] 100个线程各获取 100万次,总耗时: " + (endTime2 - startTime2) + " ms");

executor.shutdown();
}

public static void main(String[] args) {

test_singleton_02();
}

}

代码运行结果如下:

image-20260429141431127

这里 78 ms 对比 6045 ms 的效果,说明高并发场景下最真实的底层性能鸿沟,简单说明原因:

使用静态变量返回单例的底层操作:

  • 在 Java 底层,当 JVM 第一次加载你的静态内部类或饿汉式类时,它会执行一个叫 <clinit>(Class Initialization)的底层方法来给静态变量赋值。

  • JVM 规定:多线程同时去初始化一个类时,JVM 会在底层加一把极其严格的隐式锁,只允许一个线程去执行 <clinit>,其他线程全部在外面死等。

  • 当 100 个线程执行 return INSTANCE; 时,这完全是一个纯粹的内存屏障/寄存器寻址操作。所有的线程在“用户态”下并行狂奔,直接从 L1/L2 高速缓存或者主存中把引用地址读出来。不排队、不等待,CPU 的算力被 100% 用于执行业务逻辑。

使用 synchronized 保证线程安全的底层性能差距:

  • 串行化灾难(Monitor Lock):100 个线程瞬间变成只能单列排队过独木桥,并发执行退化为串行执行。
  • 线程挂起与唤醒(EntryList):抢不到锁的 99 个线程不能在原地瞎等,JVM 会把它们塞进一个叫 EntryList 的等待队列里。
  • 核心元凶:用户态与内核态的上下文切换(Context Switch):图中标红虚线的部分是性能杀手。当 JVM 要挂起或唤醒线程时,它自己做不到,必须向底层操作系统(OS)发送系统调用指令
  • 操作系统的线程调度器介入,把 CPU 当前的寄存器状态、程序计数器全部保存到内存,再把 CPU 交给别人。这一来一回的上下文切换,每一次都要耗费数万个时钟周期。

这里是性能分析可视化:

image-20260429160349737

2.4 双重检查锁(DCL, Double-Checked Locking)- 面试/工程高频

为了兼顾**“延迟加载(省内存)”“高性能并发(省CPU)”**而设计的一种高级加锁方案。它把沉重的同步锁(synchronized)退到了方法内部,并用两次 if 判断将其巧妙地包裹起来。

实现原理:先判断是否需要加锁,加锁后再次判断是否需要创建实例。

关键点

  • volatile 防止指令重排
  • 第一次判断提升性能
  • 第二次判断保证线程安全

适用场景:对性能有要求但又需要延迟加载的场景

2.4.1 模板代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class DclSingleton {
// 必须加 volatile 关键字!
private static volatile DclSingleton instance;
private DclSingleton() {}

public static DclSingleton getInstance() {
if (instance == null) { // 第一次检查,避免不必要的同步
synchronized (DclSingleton.class) {
if (instance == null) { // 第二次检查,保证单例
instance = new DclSingleton();
}
}
}
return instance;
}
}
  • 为什么必须用 volatile

    instance = new DclSingleton(); 这行代码在底层 JVM 并非原子操作,它分为三步:

    1. 分配内存空间。
    2. 初始化对象(执行构造方法)。
    3. instance 引用指向分配的内存空间。

    编译器和 CPU 为了性能优化,可能会发生指令重排(Instruction Reordering),将步骤变为 1 -> 3 -> 2。

    如果是 1->3->2,当线程 A 执行完 3(此时 instance 已经不为 null,但还没初始化),线程 B 抢占 CPU,执行到外层的 if (instance == null)。线程 B 发现不为 null,直接返回了这个半成品(未初始化完)的对象,一旦使用就会报空指针异常(NPE)。volatile 关键字的作用就是禁止指令重排,保证了内存的可见性和有序性。

  • 为什么代码里要写两个 if (instance == null)?

    1. 为什么要有外层的第一重 if?(为了性能,(门外隔着玻璃看,省去拿钥匙的麻烦)

      • 对象一旦创建成功,后续几千万次调用走到这层 if,发现不为 null,就直接把对象拿走用了。

      • 程序根本不会去碰里面那把极度耗时的重型锁,保证了极高的读取速度。

    2. 为什么有了锁,锁里面还要有第二重 if?(为了绝对安全)

      • 假设线程 A 和 B 同时越过了第一重 if。线程 A 抢到了锁,进去把对象 new 出来了,然后出门释放锁。

      • 此时线程 B 终于拿到锁冲进去,如果没有这第二层 if 拦着,线程 B 就会不管三七二十一,再 new 一个新对象覆盖掉旧的,单例就彻底崩溃了。

2.4.2 详细测试代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
public class Singleton_03 {

// 【核心重点 1】:volatile 严防指令重排(禁止 1->3->2)
private static volatile Singleton_03 instance;

// 【核心重点 2】:私有化构造器
private Singleton_03() {
System.out.println(Thread.currentThread().getName() + " 真正执行了 new 操作,创建了单例!");
}

// 【核心重点 3】:全局访问点
public static Singleton_03 getInstance() {
// 第一重检查:如果已经创建好了,直接返回,避开下面沉重的 synchronized 锁
if (instance == null) {

// 加锁:确保只有第一个冲进来的线程能去创建对象
synchronized (Singleton_03.class) {

// 第二重检查:防止后面的线程排队进来后重复创建
if (instance == null) {

// 这里的底层逻辑:1.分配空间 -> 2.初始化 -> 3.指针赋值
// 因为加了 volatile,绝对保证按 1->2->3 执行
instance = new Singleton_03();
}
}
}
return instance;
}
}



public class ApiTest {

public static void test_singleton_03() throws InterruptedException {
System.out.println("========== DCL 单例并发安全测试 ==========\n");

int threadCount = 100; // 100 个并发线程

CountDownLatch startLatch = new CountDownLatch(1); // 发令枪
CountDownLatch endLatch = new CountDownLatch(threadCount); // 终点线

// 用线程安全的 Set 收集获取到的实例
Set<Singleton_03> instanceSet = ConcurrentHashMap.newKeySet();

ExecutorService executor = Executors.newFixedThreadPool(threadCount);

for (int i = 0; i < threadCount; i++) {
executor.submit(() -> {
try {
startLatch.await(); // 所有线程在此等待发令枪

// 去获取 DCL 单例
Singleton_03 parser = Singleton_03.getInstance();

// 存入集合中
instanceSet.add(parser);
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
endLatch.countDown(); // 跑到终点
}
});
}

// 发令枪响,100个线程瞬间并发!
startLatch.countDown();

// 等待所有线程执行完毕
endLatch.await();
executor.shutdown();

System.out.println("\n========== 测试结果 ==========");
System.out.println("并发线程数: " + threadCount);
System.out.println("Set 集合中实例的数量: " + instanceSet.size());

if (instanceSet.size() == 1) {
System.out.println("✅ 测试通过!完美的单例,多线程下依然绝对安全。");
} else {
System.err.println("❌ 测试失败!单例被打破了!");
}
}

public static void main(String[] args) {

test_singleton_03();
}

}

2.5 静态内部类(Static Inner Class)- 优雅且安全

实现原理:利用 Java 类加载机制延迟加载,且由 JVM 保证线程安全。

优点

  • 延迟加载
  • 线程安全
  • 实现简单优雅

注意:类加载器会在第一次调用 getInstance() 时加载内部类,从而实现懒加载。

2.5.1 模板代码

综合了懒加载和线程安全,且不需要加锁,强烈推荐。

1
2
3
4
5
6
7
8
9
10
11
12
public class InnerClassSingleton {
private InnerClassSingleton() {}

// 静态内部类,只有在被调用时才会被类加载器加载
private static class SingletonHolder {
private static final InnerClassSingleton INSTANCE = new InnerClassSingleton();
}

public static InnerClassSingleton getInstance() {
return SingletonHolder.INSTANCE; // 此时才会触发 SingletonHolder 的加载
}
}
  • 原理:利用了 JVM 的类加载机制。外部类被加载时,静态内部类并不会被加载;只有调用 getInstance() 时才会加载并初始化 INSTANCE。JVM 保证了类的初始化过程是线程安全的。

原理详细分析:

  1. 外部类加载,不会触发内部类加载(天然懒加载)

    当 JVM 加载外部类 时,只要你不去使用内部类 ,JVM 就绝对不会去加载这个内部类。这就完美解决了“饿汉式”只要类一加载就立刻分配内存的资源浪费问题。

  2. 类的初始化阶段是绝对线程安全的(天然防并发)

    当我们第一次调用 getInstance() 方法,JVM 才会去加载并初始化内部类。

    JVM 规范严格保证:虚拟机会保证一个类的 <clinit>()(类初始化方法)在多线程环境中被正确地加锁、同步。 如果有 100 个线程同时去初始化这个内部类,JVM 在底层会默默加上一把不可见的初始化锁,保证只有 1 个线程能执行 new 操作,其他 99 个线程都在外面阻塞等待。

总结: 我们不用手写任何锁,而是直接白嫖了 JVM 内部的类加载锁

2.5.2 详细测试代码

测试代码分为两个环节:

  • 环节一:验证懒加载。我们尝试访问外部类的其他变量,看看单例对象会不会被意外创建。
  • 环节二:验证并发安全。00个线程同时去获取单例。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
public class Singleton_04 {

// 模拟一个外部类的普通静态变量
public static String NORMAL_STATIC_FIELD = "我是外部类的普通静态变量";

// 外部类的静态代码块,用于监控外部类何时被 JVM 加载
static {
System.out.println("【JVM 日志】 >>> 外部类 MaintenanceConfigManager 正在被加载...");
}

// 私有化构造器
private Singleton_04() {
System.out.println(Thread.currentThread().getName() + " >>> 真正执行 new 操作:维修问答系统配置项加载中(耗时50MB内存)...");
}

// 静态内部类:它像一个潜伏的刺客,只有被召唤时才会出动
private static class SingletonHolder {
// 内部类的静态代码块,用于监控内部类何时被 JVM 加载
static {
System.out.println("【JVM 日志】 >>> 静态内部类 SingletonHolder 正在被加载...");
}

// JVM 会保证这行代码的线程绝对安全!
private static final Singleton_04 INSTANCE = new Singleton_04();
}

public static Singleton_04 getInstance() {
return SingletonHolder.INSTANCE; // 只有执行到这一行,内部类才会被触发加载
}
}



public class ApiTest {
public static void test_singleton_04() throws InterruptedException {
System.out.println("========== 阶段一:验证【懒加载】特性 ==========\n");

System.out.println("业务代码:我现在仅仅想读取一下外部类的普通变量...");
String temp = Singleton_04.NORMAL_STATIC_FIELD;
System.out.println("业务代码获取完成。\n");

// 模拟系统运行了一段时间...
Thread.sleep(1000);

System.out.println("========== 阶段二:验证【高并发安全】特性 ==========\n");
System.out.println("业务代码:100 个请求同时涌入维修问答系统,同时索要配置管理器!\n");

int threadCount = 100;
CountDownLatch startLatch = new CountDownLatch(1);
CountDownLatch endLatch = new CountDownLatch(threadCount);
Set<Singleton_04> instanceSet = ConcurrentHashMap.newKeySet();
ExecutorService executor = Executors.newFixedThreadPool(threadCount);

for (int i = 0; i < threadCount; i++) {
executor.submit(() -> {
try {
startLatch.await(); // 100个线程在此阻塞等待发令枪

// 核心:并发调用 getInstance()
Singleton_04 manager = Singleton_04.getInstance();
instanceSet.add(manager);

} catch (InterruptedException e) {
e.printStackTrace();
} finally {
endLatch.countDown();
}
});
}

// 发令枪响!
startLatch.countDown();
endLatch.await();
executor.shutdown();

System.out.println("\n========== 测试结果统计 ==========");
System.out.println("并发线程数: " + threadCount);
System.out.println("Set 集合中实例的数量: " + instanceSet.size());

if (instanceSet.size() == 1) {
System.out.println("✅ 测试通过!完美的单例,既实现了懒加载,又保证了绝对的并发安全。");
}
}

public static void main(String[] args) {

test_singleton_04();
}

}

代码运行结果:

image-20260429215029670

运行结果说明:

懒加载(按需加载)实现: 在阶段一,我们读取了外部类的普通变量,触发了外部类 的加载。但是内部类并没有被加载,构造器也没有被执行。那 50MB 的内存没有被哦占用。

高并发安全被彻底证实: 在阶段二,当 100 个线程冲向 getInstance() 方法时,JVM 锁住了内部类 SingletonHolder 的加载过程。最终只有 pool-1-thread-4 真正执行了 new 操作。其他 99 个线程都在等待 JVM 的底层锁释放,并在锁释放后直接拿到了现成的对象。

关于这里静态内部类的加载和应用原理如下:

  1. 外部类加载,不会触发内部类加载(天然懒加载)

    当 JVM 加载外部类 时,只要你不去使用内部类 ,JVM 就绝对不会去加载这个内部类,这就完美解决了“饿汉式”只要类一加载就立刻分配内存的资源浪费问题。

  2. 类的初始化阶段是绝对线程安全的(天然防并发)

    当我们第一次调用 getInstance() 方法,代码执行到内部类SingletonHolder.INSTANCE 时,JVM 才会去加载并初始化内部类。

    JVM 规范严格保证:虚拟机会保证一个类的 <clinit>()(类初始化方法)在多线程环境中被正确地加锁、同步。 如果有 100 个线程同时去初始化这个内部类,JVM 在底层会默默加上一把不可见的初始化锁,保证只有 1 个线程能执行 new 操作,其他 99 个线程都在外面阻塞等待。

总结: 通过静态内部类,我们不用手写任何锁,而是直接白嫖了 JVM 内部的类加载锁!

可视化静态内部类加载:

image-20260429220301994

2.6 枚举(Enum)- 单例的终极防御

《Effective Java》作者 Joshua Bloch 极力推荐的写法。

实现原理:利用 Java 枚举的天然单例特性。

优点

  • 写法最简单
  • JVM 从语言层面保证线程安全和单例性
  • 防止反射与反序列化攻击

缺点

  • 写法略有语法限制,不适合对类结构有复杂依赖的情境,要知道此种方式在存在继承场景下是不可用的

2.6.1 模板代码

1
2
3
4
5
6
7
8
public enum EnumSingleton {
INSTANCE;

// 可以添加具体的业务方法
public void doDiagnosticHistorySync() {
// ...业务逻辑...
}
}

2.6.2 详细测试代码

枚举单例之所以有更改的安全性,是因为前面的所有方案在面对两个高级黑客手段——反射攻击序列化破坏时,都会瞬间崩溃。

而枚举,是 Java 语言在编译器和 JVM 底层级别可以应对这两招。

模拟这个场景:

维纳斯系统(Venus System)的诊断历史同步引擎。这个引擎如果被反射或者序列化搞出了多个实例,会导致多线程同步病历记录时出现重复或丢失。但是在枚举的单例模式之下,就没有这个问题。

这段代码将分别使用“反射暴力破解”和“序列化克隆克隆”两种极端的破坏手段,来看看枚举能不能防得住。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
/**
* 枚举单例:维纳斯系统 - 诊断历史同步引擎
* 注意:枚举天生实现了 Serializable 接口,所以可以直接被序列化
*/
public enum Singleton_05 {

// 1. 定义一个枚举元素,它就是那个全局唯一的实例
INSTANCE;

// 2. 内部的业务状态
private int syncCount = 0;

// 3. 具体的业务方法
public void doDiagnosticHistorySync(String patientId) {
syncCount++;
System.out.println("正在同步患者 [" + patientId + "] 的诊断影像历史... (当前同步总数: " + syncCount + ")");
}

public int getSyncCount() {
return syncCount;
}
}

public class ApiTest {
public static void test_singleton_05() throws InterruptedException {
System.out.println("========== 维纳斯系统防御测试:枚举单例的终极考验 ==========\n");

Singleton_05 originalInstance = Singleton_05.INSTANCE;
originalInstance.doDiagnosticHistorySync("P-001"); // 初始状态,同步数 = 1

// ================= 攻击一:反射暴力破解 =================
System.out.println("\n[攻击一] 黑客尝试使用反射机制暴力破解单例...");
try {
// 枚举的底层其实有一个 (String name, int ordinal) 的隐式构造器
Constructor<Singleton_05> constructor =
Singleton_05.class.getDeclaredConstructor(String.class, int.class);

// 强行关闭安全检查!
constructor.setAccessible(true);

// 尝试强行 new 一个新的枚举实例
System.out.println("准备执行 constructor.newInstance()...");
Singleton_05 evilInstance = constructor.newInstance("EVIL_INSTANCE", 1);

} catch (Exception e) {
System.err.println("🛡️ 攻击失败!底层 JVM 拦截了反射创建枚举的请求!");
System.err.println("拦截原因: " + e.getCause());
}

// ================= 攻击二:序列化克隆破坏 =================
System.out.println("\n[攻击二] 黑客尝试使用序列化与反序列化来克隆单例对象...");
Singleton_05 clonedInstance = null;
try {
// 1. 将原实例写入内存数组(模拟写入磁盘/网络传输)
ByteArrayOutputStream bos = new ByteArrayOutputStream();
ObjectOutputStream oos = new ObjectOutputStream(bos);
oos.writeObject(originalInstance);
oos.close();

// 2. 从内存数组中重新读取出来(模拟反序列化)
ByteArrayInputStream bis = new ByteArrayInputStream(bos.toByteArray());
ObjectInputStream ois = new ObjectInputStream(bis);
clonedInstance = (Singleton_05) ois.readObject();
ois.close();

System.out.println("克隆完成!正在比对克隆对象与原对象...");

// 3. 验证两个对象是否是同一个内存地址
if (originalInstance == clonedInstance) {
System.out.println("🛡️ 攻击失败!反序列化出来的依然是原本内存中的那个实例!");
} else {
System.err.println("❌ 灾难爆发!单例被克隆破坏了!");
}

// 4. 验证状态是否一致
System.out.println("原对象同步总数: " + originalInstance.getSyncCount());
System.out.println("反序列化对象的同步总数: " + clonedInstance.getSyncCount());

} catch (Exception e) {
e.printStackTrace();
}
}

public static void main(String[] args) {

test_singleton_04();
}

}

代码运行结果:

image-20260429222528531

具体原理如下:

当你写下 public enum Singleton_05 { INSTANCE; } 时,Java 编译器在底层其实把它编译成了一个普通的类,继承自 java.lang.Enum

  • 天生线程安全(和静态内部类一样)INSTANCE 在底层会被编译为 public static final Singleton_05 INSTANCE;,并在静态代码块中初始化。所以它同样白嫖了 JVM 类加载机制的隐式锁,绝对线程安全。
  • 防御一:绝对免疫“反射攻击”: 普通的单例类,黑客可以通过 Class.getDeclaredConstructor() 强行获取你的私有构造器,并用 setAccessible(true) 破门而入,暴力 new 出新对象。 但 Java 底层 Constructor.newInstance() 的源码,里面有一行极其霸道的强制判断:如果发现当前类是 ENUM 修饰的,直接抛出 IllegalArgumentException("Cannot reflectively create enum objects") 也就是 JVM 源码级别直接不让枚举的反射创建。
  • 防御二:绝对免疫“序列化破坏”: 当一个对象被写入磁盘(序列化)再读取出来(反序列化)时,Java 默认会通过反射绕过构造器,重新生成一个全新的对象。 但 Java 规范对枚举的序列化做了特殊规定:序列化时仅仅是将枚举的 name(比如 “INSTANCE” 字符串)输出。反序列化时,底层强制调用 Enum.valueOf(Class, String) 方法,根据名字去内存里找已经存在的那个实例。这就彻底杜绝了反序列化克隆出新对象的可能。

image-20260430145336175

总结

单例模式思想简单:确保一个类在任何情况下都绝对只有一个实例,并提供一个全局访问点。在实现时都包含:私有化构造函数、提供访问这个单例对象的公开方法。这个单例对象在类中才创建。

但是具体实现中有很多细节和考虑:

  1. 饿汉式:
    • 在未获取该单例对象时就创建好该对象,通过静态变量来让类加载时就创建好单例对象;
    • 缺点是在未使用该单例对象时该对象可能被创建,导致占用内存浪费资源。
  2. 懒汉式:
    • 在获取该单例对象时才创建该对象;
    • 需要考虑线程安全问题,这也是常见面试问题。
    • 推荐静态内部类来返回单例对象。

我们使用的时候可以参考以下原则:

  • 注重代码优雅与性能:日常工程开发,首选静态内部类
  • 涉及配置项极多或面试考察底层:必须掌握**双重检查锁(DCL + volatile)**及其背后的内存模型原理。
  • 涉及频繁序列化或需要绝对安全:直接使用枚举