单例模式(Singleton Pattern)是 Java 中最基础的设计模式,其核心目的是确保一个类只有一个实例,并提供一个全局访问点。
虽然代码写起来简单,但要实现一个完美的单例,需要应对多线程、序列化、反射等多种底层机制的挑战。
一、 什么样的单例才是“理想”的?
一个完美的单例模式实现,应当同时满足以下 4 个苛刻条件:
- 懒加载 (Lazy Loading) :资源应按需分配,只有真正用到时才加载,节约系统资源。
- 线程安全 (Thread Safety) :在多线程高并发环境下,必须保证只创建一个实例。
- 防反序列化攻击:防止通过 IO 流写入再读取的方式“克隆”出新对象。
- 防反射破坏:防止通过反射 API 强行调用私有构造函数创建新对象。
二、 单例模式面临的三大安全挑战
在深入代码之前,我们需要理解为什么普通的单例实现是不安全的。
1. 线程安全问题
在多线程环境下,如果两个线程同时判断 instance == null,它们会同时执行实例化代码,导致内存中产生两个不同的对象,违背单例原则。
2. 反序列化攻击
- 原理:普通的类在序列化时,会将整个类的状态信息写入文件或内存缓冲区。在反序列化时,JVM 不会调用构造函数,而是利用底层的字节码技术根据这些数据凭空生成一个新的对象。
- 后果:序列化前后的对象不是同一个,破坏了单例。
- 特例:枚举类。枚举在序列化时,只传输枚举实例的名称(Name)。反序列化时,JVM 通过
valueOf()方法根据名字去内存中查找已存在的枚举对象,绝不会新建对象。
3. 反射破坏
- 原理:反射是 Java 的动态特性。攻击者可以通过
Class.getDeclaredConstructor()获取私有构造器,然后调用setAccessible(true)强行获得访问权限,最后调用newInstance()创建新对象。 - 后果:普通的
private构造函数形同虚设。 - 特例:枚举类。JDK 的反射源码(
Constructor.newInstance)中硬编码了检查逻辑:如果反射操作的目标是枚举类,直接抛出IllegalArgumentException异常,从底层封死了这条路。
三、 常见的单例实现方式
1. 枚举模式 (Enum) —— 最优解
这是《Effective Java》作者 Joshua Bloch 极力推崇的方式,也是目前最安全的实现。
public enum SingletonEnum {
INSTANCE; // 这一行代码就代表了一个单例
public void doSomething() {
System.out.println("执行业务逻辑");
}
}
核心优势(满足所有 4 点):
- 懒加载:遵循类加载机制,只有在使用
SingletonEnum.INSTANCE时才会触发类加载和初始化。 - 线程安全:枚举实例的创建发生在类加载的初始化阶段(
<clinit>),由 JVM 保证线程安全。 - 防反序列化:JVM 层面保证枚举常量的唯一性。
- 防反射:JVM 源码级禁止反射创建枚举实例。
2. 饿汉式 (Eager Initialization)
这是最简单、最直观的实现。
public class EagerSingleton {
// 1. 私有化构造函数,防止外部 new
private EagerSingleton() {}
// 2. static 变量在类加载的“初始化”阶段执行,JVM 保证线程安全
private static final EagerSingleton INSTANCE = new EagerSingleton();
// 3. 对外暴露获取实例的方法
public static EagerSingleton getInstance() {
return INSTANCE;
}
}
原理与局限:
- 类加载机制:虽然类加载本身是按需的(用到类才加载),但饿汉式将单例作为类的静态属性。
- 初始化边界不够细致:只要你访问该类的任何静态成员(哪怕是一个无关的
public static int FLAG = 1),都会触发整个类的初始化,导致INSTANCE被创建。 - 评价:线程安全(JVM 保证),防不住反射和序列化。如果确定该类一定会被用到,这是一种不错的选择。
3. 懒汉式 —— 极致的按需加载
为了解决饿汉式“粒度太粗”的问题,我们通常采用以下两种高级写法。
3.1 静态内部类 (Static Inner Class)
利用 Java 类嵌套的特性,将单例的持有者从“主类”转移到“内部类”。
public class InnerClassSingleton {
private InnerClassSingleton() {}
// 静态内部类:只有在被显式调用时才会加载
private static class Holder {
private static final InnerClassSingleton INSTANCE = new InnerClassSingleton();
}
public static InnerClassSingleton getInstance() {
// 只有这里调用了 Holder,才会触发 Holder 的类加载和 INSTANCE 的初始化
return Holder.INSTANCE;
}
}
关键区别:
- 饿汉式:单例是主类的属性,访问主类即初始化。
- 静态内部类:单例是内部类的属性。访问主类(如调用其他静态方法)不会触发内部类的加载。只有调用
getInstance()时,JVM 才会去加载Holder类。 - 评价:实现了最纯粹的懒加载,且无锁(性能好)。但依然防不住反射和序列化。
3.2 双重检查锁 (Double-Checked Locking, DCL)
在代码层面手动控制并发,是很多面试题的考点。
public class DclSingleton {
// 必须加 volatile,防止指令重排序
private volatile static DclSingleton uniqueInstance;
private DclSingleton() {}
public static DclSingleton getInstance() {
// 第一层检查:如果已经初始化,直接返回,避免进入 synchronized 块带来的性能消耗
if (uniqueInstance == null) {
synchronized (DclSingleton.class) {
// 第二层检查:防止多线程并发时重复创建
if (uniqueInstance == null) {
// 这行代码不是原子性的,volatile 也就是为了保护这里
uniqueInstance = new DclSingleton();
}
}
}
return uniqueInstance;
}
}
核心细节:
-
为什么需要
volatile?new DclSingleton()在底层分为三步:1. 分配内存 -> 2. 初始化对象 -> 3. 引用赋值。如果没有
volatile,指令可能重排为 1->3->2。此时其他线程在第一层 check 时会拿到一个**“半成品对象”**(不为 null,但未初始化)。volatile也就是为了禁止这种重排序。
四、 总结与对比
| 实现方式 | 懒加载 | 线程安全 | 防反射 | 防反序列化 | 核心原理 |
|---|---|---|---|---|---|
| 枚举 (Enum) | ✅ | ✅ | ✅ | ✅ | JVM 类加载 + 语言规范强制保护 |
| 饿汉式 | ❌ (相对) | ✅ | ❌ | ❌ | 类加载时初始化静态属性 |
| 静态内部类 | ✅ | ✅ | ❌ | ❌ | 内部类的独立加载时机 |
| 双重检查锁 | ✅ | ✅ | ❌ | ❌ | volatile 内存屏障 + synchronized 锁 |
最终建议:
- 在大多数生产环境下,推荐使用 枚举,因为它最安全、最简单。
- 如果必须通过继承等方式无法使用枚举,或者对资源控制极度敏感,静态内部类是最佳替代方案。