单例模式
文章目录
- 单例模式
-
- 前言
- 一、单例模式介绍
- [二、 六种实现方式](#二、 六种实现方式)
-
- [2.1 饿汉式](#2.1 饿汉式)
-
- [2.1.1 模板代码](#2.1.1 模板代码)
- [2.1.2 详细测试代码](#2.1.2 详细测试代码)
- [2.2 懒汉式 - 线程不安全版](#2.2 懒汉式 - 线程不安全版)
-
- [2.2.1 模板代码](#2.2.1 模板代码)
- [2.2.2 详细测试代码](#2.2.2 详细测试代码)
- [2.3 懒汉式 - 同步锁版(线程安全但低效)](#2.3 懒汉式 - 同步锁版(线程安全但低效))
-
- [2.3.1 模板代码](#2.3.1 模板代码)
- [2.3.2 详细测试代码](#2.3.2 详细测试代码)
- [2.4 双重检查锁(DCL, Double-Checked Locking)- 面试/工程高频](#2.4 双重检查锁(DCL, Double-Checked Locking)- 面试/工程高频)
-
- [2.4.1 模板代码](#2.4.1 模板代码)
- [2.4.2 详细测试代码](#2.4.2 详细测试代码)
- [2.5 静态内部类(Static Inner Class)- 优雅且安全](#2.5 静态内部类(Static Inner Class)- 优雅且安全)
-
- [2.5.1 模板代码](#2.5.1 模板代码)
- [2.5.2 详细测试代码](#2.5.2 详细测试代码)
- [2.6 枚举(Enum)- 单例的终极防御](#2.6 枚举(Enum)- 单例的终极防御)
-
- [2.6.1 模板代码](#2.6.1 模板代码)
- [2.6.2 详细测试代码](#2.6.2 详细测试代码)
- 总结
前言
单例模式的目的极其明确:确保一个类在任何情况下都绝对只有一个实例,并提供一个全局访问点。
这看似简单,但在实际的工程应用(尤其是高并发后端系统)中,要做到"绝对安全"却暗藏玄机。
在系统开发中,有些对象如果出现多个实例,会导致资源浪费、状态不一致或逻辑混乱。
- 全局状态与历史管理器 :例如在医疗辅助诊断系统中,针对某次会话的诊断历史记录管理器(Diagnostic History Manager)。如果在不同模块(如影像解析模块和问诊模块)中存在多个历史管理器实例,会导致历史数据无法同步,出现丢记录的 Bug。
- 硬件/系统资源访问:数据库连接池(Connection Pool)、线程池。建立连接极其消耗资源,整个系统应当共享同一个连接池来统一调度。
- 系统配置信息类 :读取全局的配置文件(如
.properties或.yml),这些配置在内存中只需存一份即可。
本文项目源码链接:https://github.com/likerhood/CodeDesignWork/tree/main/codedesign4.0-0
一、单例模式介绍

单例模式是设计模式中最简单、也最符合开发者直觉的模式。
它在实际开发中的应用极高,其核心设计理念可以高度浓缩为两点:
- 绝对唯一:在多线程等复杂环境下,保证一个类仅有一个实例,并提供一个全局访问点。
- 性能优化:阻断全局类的频繁创建与销毁,降低资源开销,从根本上提升系统整体性能。
二、 六种实现方式
单例的核心构造原则是:构造方法私有化(Private Constructor)。
但在多线程环境下,如何安全地向外暴露这个实例,就演化出了不同的写法。
2.1 饿汉式
最简单,但可能浪费资源。 类加载时就立即初始化,利用类加载机制保证了线程安全。
- 私有化构造函数;
- 通过
static静态变量保证类加载时就会创建该类的实例对象; - 提供全部访问这个单例对象的公开方法(静态方法)。
在 Java 中,当一个类被 JVM 加载到内存并进行初始化时,JVM 会自动执行类中的静态代码块(
static {})以及静态变量的赋值操作。因为我们写了
private static final EagerSingleton INSTANCE = new EagerSingleton();,所以当EagerSingleton这个类一被 JVM 识别并加载,这行代码就会立刻执行,从而调用了私有构造器,把对象创建出来了。而且,JVM 底层的类加载机制天生是线程安全的,这就保证了在多线程环境下,这个对象也只会被创建一次。
缺点:如果这个类包含了庞大的数据字典或需要加载大量本地资源,且系统启动后很久都没用到它,就会白白占用内存。
2.1.1 模板代码
java
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 的内存和数据库连接也就被死死占用了。这就叫"白白浪费了系统资源"。
Java
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();
}
}
上述测试结果如下:

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

2.2 懒汉式 - 线程不安全版
懒汉式单例模式为了解决饿汉式的资源浪费问题:将对象实例的初始化改为延迟加载。
2.2.1 模板代码
Java
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():
- 程序走到
if (instance == null),发现确实是null(条件成立)。 - 于是进入
if代码块内部,执行new LazyUnsafeSingleton(),在这个时刻,才真正去申请内存、消耗 CPU 资源来创建对象。 - 创建完成后,把这个对象的地址赋给
instance变量,最后返回。
回忆一下在前文对"饿汉式"的测试与总结:饿汉式最大的缺点是"只要触发类加载,不管你用不用,对象都会被立刻创建,白白浪费资源"。
而懒汉式的这个 if,就是专门为了解决这个资源浪费而生的。
场景对比(以耗时 50MB 内存的诊断影像解析引擎为例):
- 使用"饿汉式"的灾难: 系统刚启动,仅仅是因为某个模块读取了该类的一个普通静态配置,JVM 触发了类加载。不管当前有没有医生在使用影像解析功能,引擎立刻被初始化,瞬间吃掉 50MB 内存,甚至霸占了几个数据库连接。如果今天一整天都没有人拍片子,这 50MB 内存就白白浪费了一整天。
- 使用"懒汉式"的优雅(有了这个
if): 系统启动,类被加载,但此时instance只是一个空荡荡的null,完全不占用那 50MB 的核心内存。
但在多线程下会发生"意外"。
意外情况 :在高并发下,多个线程同时通过了 if (instance == null) 的判断,最终会产生多个实例,导致内存泄漏或状态覆盖。
- 线程 A 发现它是
null,刚准备进去new,还没来得及new,操作系统的 CPU 把线程 A 暂停了。- 此时线程 B 来了,它看到
instance依然是null(因为 A 还没造出来),于是 B 进去了,并且顺利new了一个对象。- 接着 CPU 切换回线程 A,A 从刚才暂停的地方继续往下走,它不会再去判断一次
if,而是直接又执行了一次new!这就导致了之前压测中出现的情况:原本为了节省资源的
if,在并发下由于防线被集体突破,反而造出了无数个多余的实例。这就是为什么我们在后面的演进中,必须在这个
if上面加锁(synchronized),甚至演化出双重检查锁(DCL) ------本质上都是在给这个脆弱但又极其重要的if穿上防弹衣。
2.2.2 详细测试代码
模拟以下场景:
维纳斯系统(一个医学图像辅助诊断系统)(Venus System)刚刚部署上线或重启,系统还处于冷启动状态,内存中尚未初始化"全局诊断影像解析引擎"。
突然,早上 8 点整,全院 100 位门诊医生在同一瞬间 点击了患者历史病历中的"查看影像诊断"按钮。这 100 个请求犹如潮水一般涌入后端服务器,几乎在同一微秒向系统索要解析引擎的实例(即疯狂调用 getInstance())。
java
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();
}
}
上述测试结果如下:

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

系统中唯一的对象重复创建了 96 次 ,底部的那些 @43211023 和 @4f667964 就是这些对象在 JVM 堆内存中的真实物理地址,说明这个引擎不是同一个引擎。
在真实的维纳斯系统里,这个引擎可能包含庞大的图像解析模型。正常情况下它只需要加载一次(消耗 50MB 内存)。但在这种并发漏洞下,系统瞬间为它分配了 96 次内存(近 5GB 资源被瞬间吞噬!)。如果是在生产环境,这会导致两个灾难性后果:
- OOM (Out Of Memory) 内存溢出:系统资源瞬间被抽干,服务器直接宕机。
- 状态覆盖:哪怕内存没爆,96 个独立的引擎实例也会导致后续处理影像时,数据状态完全无法同步,出现"医生 A 保存的诊断记录,医生 B 完全看不到"的幽灵 Bug。
2.3 懒汉式 - 同步锁版(线程安全但低效)
为了解决上面的线程不安全问题,接下来在 getInstance() 方法上直接加上了 synchronized 关键字。在 Java 中,静态方法上的 synchronized 相当于把整个类对象(Class 对象)当作了锁。
2.3.1 模板代码
Java
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(),看看有锁和无锁在极端高频"读操作"下的耗时天堑。
java
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();
}
}
代码运行结果如下:

这里 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 交给别人。这一来一回的上下文切换,每一次都要耗费数万个时钟周期。
这里是性能分析可视化:

2.4 双重检查锁(DCL, Double-Checked Locking)- 面试/工程高频
为了兼顾**"延迟加载(省内存)"和 "高性能并发(省CPU)"**而设计的一种高级加锁方案。它把沉重的同步锁(synchronized)退到了方法内部,并用两次 if 判断将其巧妙地包裹起来。
实现原理:先判断是否需要加锁,加锁后再次判断是否需要创建实例。
关键点:
volatile防止指令重排- 第一次判断提升性能
- 第二次判断保证线程安全
适用场景:对性能有要求但又需要延迟加载的场景
2.4.1 模板代码
Java
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 并非原子操作,它分为三步:- 分配内存空间。
- 初始化对象(执行构造方法)。
- 将
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)?-
为什么要有外层的第一重
if?(为了性能,(门外隔着玻璃看,省去拿钥匙的麻烦)-
对象一旦创建成功,后续几千万次调用走到这层
if,发现不为null,就直接把对象拿走用了。 -
程序根本不会去碰里面那把极度耗时的重型锁,保证了极高的读取速度。
-
-
为什么有了锁,锁里面还要有第二重
if?(为了绝对安全)-
假设线程 A 和 B 同时越过了第一重
if。线程 A 抢到了锁,进去把对象new出来了,然后出门释放锁。 -
此时线程 B 终于拿到锁冲进去,如果没有这第二层
if拦着,线程 B 就会不管三七二十一,再new一个新对象覆盖掉旧的,单例就彻底崩溃了。
-
-
2.4.2 详细测试代码
java
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 模板代码
综合了懒加载和线程安全,且不需要加锁,强烈推荐。
Java
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 保证了类的初始化过程是线程安全的。
原理详细分析:
外部类加载,不会触发内部类加载(天然懒加载)
当 JVM 加载外部类 时,只要你不去使用内部类 ,JVM 就绝对不会去加载这个内部类。这就完美解决了"饿汉式"只要类一加载就立刻分配内存的资源浪费问题。
类的初始化阶段是绝对线程安全的(天然防并发)
当我们第一次调用
getInstance()方法,JVM 才会去加载并初始化内部类。JVM 规范严格保证:虚拟机会保证一个类的
<clinit>()(类初始化方法)在多线程环境中被正确地加锁、同步。 如果有 100 个线程同时去初始化这个内部类,JVM 在底层会默默加上一把不可见的初始化锁,保证只有 1 个线程能执行new操作,其他 99 个线程都在外面阻塞等待。总结: 我们不用手写任何锁,而是直接白嫖了 JVM 内部的类加载锁!
2.5.2 详细测试代码
测试代码分为两个环节:
- 环节一:验证懒加载。我们尝试访问外部类的其他变量,看看单例对象会不会被意外创建。
- 环节二:验证并发安全。00个线程同时去获取单例。
java
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();
}
}
代码运行结果:

运行结果说明:
懒加载(按需加载)实现: 在阶段一,我们读取了外部类的普通变量,触发了外部类 的加载。但是内部类并没有被加载,构造器也没有被执行。那 50MB 的内存没有被哦占用。
高并发安全被彻底证实: 在阶段二,当 100 个线程冲向 getInstance() 方法时,JVM 锁住了内部类 SingletonHolder 的加载过程。最终只有 pool-1-thread-4 真正执行了 new 操作。其他 99 个线程都在等待 JVM 的底层锁释放,并在锁释放后直接拿到了现成的对象。
关于这里静态内部类的加载和应用原理如下:
-
外部类加载,不会触发内部类加载(天然懒加载)
当 JVM 加载外部类 时,只要你不去使用内部类 ,JVM 就绝对不会去加载这个内部类,这就完美解决了"饿汉式"只要类一加载就立刻分配内存的资源浪费问题。
-
类的初始化阶段是绝对线程安全的(天然防并发)
当我们第一次调用 getInstance() 方法,代码执行到内部类SingletonHolder.INSTANCE 时,JVM 才会去加载并初始化内部类。
JVM 规范严格保证:虚拟机会保证一个类的
<clinit>()(类初始化方法)在多线程环境中被正确地加锁、同步。 如果有 100 个线程同时去初始化这个内部类,JVM 在底层会默默加上一把不可见的初始化锁,保证只有 1 个线程能执行 new 操作,其他 99 个线程都在外面阻塞等待。
总结: 通过静态内部类,我们不用手写任何锁,而是直接白嫖了 JVM 内部的类加载锁!
可视化静态内部类加载:

2.6 枚举(Enum)- 单例的终极防御
《Effective Java》作者 Joshua Bloch 极力推荐的写法。
实现原理:利用 Java 枚举的天然单例特性。
优点:
- 写法最简单
- JVM 从语言层面保证线程安全和单例性
- 防止反射与反序列化攻击
缺点:
- 写法略有语法限制,不适合对类结构有复杂依赖的情境,要知道此种方式在存在继承场景下是不可用的。
2.6.1 模板代码
Java
public enum EnumSingleton {
INSTANCE;
// 可以添加具体的业务方法
public void doDiagnosticHistorySync() {
// ...业务逻辑...
}
}
2.6.2 详细测试代码
枚举单例之所以有更改的安全性,是因为前面的所有方案在面对两个高级黑客手段------反射攻击 和序列化破坏时,都会瞬间崩溃。
而枚举,是 Java 语言在编译器和 JVM 底层级别可以应对这两招。
模拟这个场景:
维纳斯系统(Venus System)的诊断历史同步引擎。这个引擎如果被反射或者序列化搞出了多个实例,会导致多线程同步病历记录时出现重复或丢失。但是在枚举的单例模式之下,就没有这个问题。
这段代码将分别使用"反射暴力破解"和"序列化克隆克隆"两种极端的破坏手段,来看看枚举能不能防得住。
java
/**
* 枚举单例:维纳斯系统 - 诊断历史同步引擎
* 注意:枚举天生实现了 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();
}
}
代码运行结果:

具体原理如下:
当你写下
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)方法,根据名字去内存里找已经存在的那个实例。这就彻底杜绝了反序列化克隆出新对象的可能。

总结
单例模式思想简单:确保一个类在任何情况下都绝对只有一个实例,并提供一个全局访问点。在实现时都包含:私有化构造函数、提供访问这个单例对象的公开方法。这个单例对象在类中才创建。
但是具体实现中有很多细节和考虑:
- 饿汉式:
- 在未获取该单例对象时就创建好该对象,通过静态变量来让类加载时就创建好单例对象;
- 缺点是在未使用该单例对象时该对象可能被创建,导致占用内存浪费资源。
- 懒汉式:
- 在获取该单例对象时才创建该对象;
- 需要考虑线程安全问题,这也是常见面试问题。
- 推荐静态内部类来返回单例对象。
我们使用的时候可以参考以下原则:
- 注重代码优雅与性能 :日常工程开发,首选静态内部类。
- 涉及配置项极多或面试考察底层:必须掌握**双重检查锁(DCL + volatile)**及其背后的内存模型原理。
- 涉及频繁序列化或需要绝对安全 :直接使用枚举。