单例模式 (Singleton) —— 只有我一个是真的
前言
在深入探讨单例模式之前,想向大家推荐一个非常棒的开源项目:design-patterns-23。这个项目用最现代的技术栈重写了 23 种设计模式,非常适合实战学习,还配套了在线交互演示站,可以边读文章边动手玩。本文的实战案例灵感也来源于此。
1. 单例模式是什么?
单例模式(Singleton Pattern)是最简单也是最常用的设计模式之一。它的核心思想非常简单:保证一个类仅有一个实例,并提供一个访问它的全局访问点。
我们可以用生活中的例子来理解:
- 古代的皇帝:一个国家同一时期只能有一个皇帝。如果有两个,那就是天下大乱。所有的臣民(客户端)都要听这一个皇帝(单例对象)的号令。
- 公司的CEO:通常一家公司只有一个CEO,负责统筹全局。
- 电脑的任务管理器:你在 Windows 上按
Ctrl+Alt+Del打开任务管理器,无论你按多少次,系统永远只会弹出一个窗口。如果弹出多个,显示的 CPU 使用率都不一样,那用户该信谁?
1.1 为什么我们需要它?
在软件开发中,有些对象是非常“重”的,或者需要保持全局状态一致性。如果频繁创建这些对象,会带来巨大的资源消耗或逻辑错误。
痛点场景:
- 资源消耗过大:比如数据库连接池。创建一个数据库连接需要 TCP 三次握手、身份验证等繁琐过程,非常耗时。如果我们每执行一条 SQL 语句就创建一个新的连接池对象,服务器内存很快就会被撑爆,数据库也会因为连接数过多而崩溃。
- 全局状态管理:比如配置文件的读取。系统启动时加载了一个
application.properties,里面存了数据库密码、Redis 端口等信息。在系统的任何地方,我们读取的配置都应该是同一份。如果有多份配置对象,其中一份改了(比如动态刷新配置),另一份没改,就会导致数据不一致。 - 日志记录器:通常我们希望所有的日志都写入同一个文件,按照时间顺序排列。如果多个日志对象同时操作同一个文件,可能会导致文件锁竞争,或者日志内容交错混乱。
- 全局唯一序列号生成器:在分布式系统中,生成全局唯一的 ID 往往需要一个单例对象来维护序列状态,防止重复。
如果不使用单例模式(烂代码示例):
public class DatabaseConnection {
public DatabaseConnection() {
System.out.println("正在建立耗时的数据库连接...");
// 模拟耗时操作
}
}
public class Test {
public static void main(String[] args) {
// 每次需要连接数据库时都 new 一个
DatabaseConnection conn1 = new DatabaseConnection();
DatabaseConnection conn2 = new DatabaseConnection();
DatabaseConnection conn3 = new DatabaseConnection();
// 输出:
// 正在建立耗时的数据库连接...
// 正在建立耗时的数据库连接...
// 正在建立耗时的数据库连接...
// 结果:资源浪费,甚至导致系统崩溃
}
}2. Java 实战演练:单例的七种写法
单例模式看似简单,但要在多线程环境下写对、写好,其实非常有讲究。面试中这也是高频考点,特别是关于线程安全、指令重排序、反射攻击和序列化破坏等深层问题。
2.1 饿汉式 (Eager Initialization)
这是最简单、最安全的写法。类一加载,就立刻创建实例。
- 优点:实现简单,没有线程安全问题(JVM 在类加载时会保证静态变量的初始化是线程安全的)。
- 缺点:不管你用不用这个对象,它都创建了。如果这个对象初始化非常耗资源(比如加载几百M的配置),而你程序跑了一整天都没用到它,那就浪费了内存。
public class SingletonEager {
// 1. 私有化构造方法,防止外部 new
private SingletonEager() {}
// 2. 类加载时就创建实例
private static final SingletonEager INSTANCE = new SingletonEager();
// 3. 提供全局访问点
public static SingletonEager getInstance() {
return INSTANCE;
}
}2.2 懒汉式 (Lazy Initialization) - 线程不安全版本
为了解决饿汉式的内存浪费问题,我们想到了“懒加载”:只有当真正调用 getInstance() 时才去创建。
- 致命缺陷:在多线程环境下,如果线程 A 和线程 B 同时进入
if (instance == null),它们都会觉得实例还没创建,然后各自new了一个。这就破坏了单例!
public class SingletonLazyUnsafe {
private static SingletonLazyUnsafe instance;
private SingletonLazyUnsafe() {}
public static SingletonLazyUnsafe getInstance() {
// 危险!多线程下可能导致创建多个实例
if (instance == null) {
instance = new SingletonLazyUnsafe();
}
return instance;
}
}2.3 懒汉式 - 线程安全版本 (Synchronized Method)
最简单的解决办法是给 getInstance() 方法加锁。
- 优点:解决了线程安全问题。
- 缺点:性能极差!99% 的情况下,实例已经被创建了,我们只需要读取,不需要锁。但这种写法导致每次读取都要排队获取锁,在高并发下是严重的性能瓶颈。
public class SingletonLazySafe {
private static SingletonLazySafe instance;
private SingletonLazySafe() {}
// 加上 synchronized 锁
public static synchronized SingletonLazySafe getInstance() {
if (instance == null) {
instance = new SingletonLazySafe();
}
return instance;
}
}2.4 双重检查锁 (Double Check Lock, DCL) - 推荐
这是面试中最常考的写法。为了线程安全,我们加了 synchronized。但如果直接加在方法上,性能太差(每次获取实例都要锁)。所以我们只在实例未创建时才加锁。
核心细节:为什么变量要加 volatile? 因为 instance = new Singleton() 这行代码在 JVM 中并非原子操作,它分为三步:
- 给对象分配内存空间。
- 调用构造方法初始化对象。
- 将
instance引用指向分配的内存地址。
如果 CPU 进行指令重排序,执行顺序变成了 1 -> 3 -> 2。那么线程 A 执行完第 3 步(此时 instance 已经不为 null,但还没初始化),线程 B 抢占执行,判断 instance != null,直接拿去用了。结果线程 B 拿到的是一个半成品对象,导致程序报错。 volatile 关键字可以禁止指令重排序,保证线程安全。
public class SingletonDCL {
// 必须加 volatile 禁止指令重排序
private static volatile SingletonDCL instance;
private SingletonDCL() {}
public static SingletonDCL getInstance() {
// 第一次检查:如果已经创建了,就不用加锁,直接返回,提升性能
if (instance == null) {
synchronized (SingletonDCL.class) {
// 第二次检查:防止多个线程同时通过了第一次检查
if (instance == null) {
instance = new SingletonDCL();
}
}
}
return instance;
}
}2.5 静态内部类 (Static Inner Class) - 推荐
这是很多大牛推荐的写法。它结合了饿汉式的“安全”和懒汉式的“懒加载”。
- 原理:
SingletonHolder是一个静态内部类,外部类SingletonInner加载时,内部类并不会被加载。只有当调用getInstance()时,JVM 才会去加载SingletonHolder,从而初始化INSTANCE。 - 优点:代码简洁,无需
synchronized,线程安全由 JVM 类加载机制保证,且实现了懒加载。
public class SingletonInner {
private SingletonInner() {}
// 静态内部类
private static class SingletonHolder {
private static final SingletonInner INSTANCE = new SingletonInner();
}
public static SingletonInner getInstance() {
return SingletonHolder.INSTANCE;
}
}2.6 枚举 (Enum) - 最完美
这是《Effective Java》作者 Josh Bloch 极力推荐的写法。
- 优点:
- 写法最简单。
- 线程安全。
- 防止破坏:前面的几种写法,如果你用反射 (Reflection) 或者序列化 (Serialization),都能强行创建新实例,破坏单例。只有枚举类型,JVM 从底层保证了它绝对不会被反射创建,序列化也没问题。
public enum SingletonEnum {
INSTANCE;
public void doSomething() {
System.out.println("我是最安全的单例!");
}
}
// 调用方式
// SingletonEnum.INSTANCE.doSomething();2.7 ThreadLocal 单例
这是一种特殊的单例,它不保证全局唯一,而是保证线程内唯一。在同一个线程中,它永远是同一个对象;在不同线程中,它是不同的对象。这在处理 ThreadLocal 变量(如数据库连接、Session 管理)时非常有用。
public class ThreadLocalSingleton {
private static final ThreadLocal<ThreadLocalSingleton> threadLocalInstance =
ThreadLocal.withInitial(() -> new ThreadLocalSingleton());
private ThreadLocalSingleton() {}
public static ThreadLocalSingleton getInstance() {
return threadLocalInstance.get();
}
}3. 进阶:如何破坏单例?(这也是面试题)
除了枚举模式,其他的单例写法都是脆弱的,可以被“黑客”手段攻破。
3.1 反射攻击
通过 Java 反射机制,我们可以强行调用私有构造方法。
// 攻击代码演示
public static void main(String[] args) throws Exception {
SingletonDCL s1 = SingletonDCL.getInstance();
// 获取私有构造器
Constructor<SingletonDCL> constructor = SingletonDCL.class.getDeclaredConstructor();
constructor.setAccessible(true); // 暴力破解权限
// 创建新实例
SingletonDCL s2 = constructor.newInstance();
System.out.println(s1 == s2); // 输出 false,单例被破坏!
}防御手段: 在构造方法中加判断,如果已经存在实例,就抛异常。但这种防君子不防小人,因为反射也可以修改标记变量。只有枚举是真正防反射的。
3.2 序列化攻击
如果单例类实现了 Serializable 接口,我们可以把单例对象写入文件,再读出来。读出来的对象是一个全新的对象。
// 攻击代码演示
public static void main(String[] args) throws Exception {
SingletonDCL s1 = SingletonDCL.getInstance();
// 序列化
ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("singleton.bin"));
oos.writeObject(s1);
oos.close();
// 反序列化
ObjectInputStream ois = new ObjectInputStream(new FileInputStream("singleton.bin"));
SingletonDCL s2 = (SingletonDCL) ois.readObject();
System.out.println(s1 == s2); // 输出 false,单例被破坏!
}防御手段: 在单例类中定义 readResolve 方法。反序列化时,JVM 会自动调用这个方法,我们可以返回已有的实例,覆盖掉反序列化出来的新对象。
// 在 SingletonDCL 中添加
private Object readResolve() {
return instance;
}4. 源码中的单例模式
Java JDK 中也有很多单例模式的应用:
java.lang.Runtime: 每个 Java 应用程序都有一个Runtime类实例,使应用程序能够与其运行的环境进行接口。javapublic class Runtime { private static final Runtime currentRuntime = new Runtime(); public static Runtime getRuntime() { return currentRuntime; } private Runtime() {} }这是一个典型的饿汉式单例。
Spring Bean: Spring 容器中的 Bean 默认都是单例的(Scope="singleton")。但这属于容器级别的单例,Spring 通过
HashMap缓存了这些对象,保证在同一个容器中只有一个实例。它不是通过私有构造函数实现的,而是通过注册表模式(Registry)实现的。Log4j / Slf4j: 日志框架通常也是单例的,保证日志输出配置的统一性。
5. 优缺点与适用场景总结
优点
- 内存节省:在内存中只有一个实例,减少了内存开销,特别是频繁创建和销毁实例时。
- 全局访问:提供了一个全局访问点,方便共享资源(如配置、连接池)。
- 避免冲突:避免对资源的多重占用(如文件写操作)。
缺点
- 扩展困难:单例类没有抽象层,很难扩展。
- 职责过重:单例类往往充当了“管家”的角色,违背了“单一职责原则”,容易变成垃圾桶。
- 测试困难:Mock 单例对象比较麻烦,因为它持有全局状态。
- 并发隐患:如果单例持有可变状态(如
List),在多线程环境下需要非常小心地处理同步问题。
适用场景
- 需要频繁实例化然后销毁的对象。
- 创建对象时耗时过多或者耗资源过多,但又经常用到的对象。
- 有状态的工具类对象。
- 频繁访问数据库或文件的对象。
- 要求只有一个对象的场景(如生成唯一的序列号)。
6. 结语
单例模式虽然简单,但“七种写法”的演进过程体现了多线程编程的核心思想。在实际开发中:
- 如果对懒加载没有强制要求,饿汉式(配合 final)其实是最省事的,性能也很好。
- 如果对性能要求极高且需要懒加载,静态内部类是首选。
- 如果需要防止反射和序列化破坏,枚举是唯一的选择。
7. 实验实操
在 design-patterns-web 项目中,我们提供了一个直观的对比实验。 左侧是非单例模式(糟糕的实践),你可以点击“New Instance”按钮。你会发现,每次点击都会创建一个全新的数据库连接对象(用不同颜色的图标表示),这模拟了资源被重复创建的过程。如果这是真实的数据库连接,系统资源很快就会被耗尽。 右侧是单例模式(推荐实践),当你点击“Get Instance”按钮时。第一次点击会创建一个蓝色的实例,并在日志中显示“Instance Created”。随后无论你点击多少次,返回的始终是同一个蓝色实例,日志显示“Returning Existing Instance”。这完美展示了单例模式“全局唯一”的特性。 截图建议:分别点击左右两侧按钮多次,截取左侧出现多个不同颜色图标、右侧仅有一个蓝色图标且日志显示重复获取的画面。
- 如果你要防范反射攻击,枚举是唯一的选择。
- 尽量避免使用双重检查锁,虽然它看起来很酷,但写对不容易(别忘了
volatile)。