问题
如何用 Java 实现线程安全的单例?不同写法分别适合什么场景?
回答
核心结论
单例模式的目标只有一个:
- 一个类只保留一个实例
- 全局可访问
但“只创建一个对象”不等于“写一个静态变量就完事”,还要考虑:
- 线程安全
- 延迟加载
- 反射破坏
- 序列化破坏
- 是否其实应该交给 Spring 容器管理
常见实现方式对比
| 方式 | 线程安全 | 延迟加载 | 代码复杂度 | 备注 |
|---|---|---|---|---|
| 饿汉式 | 是 | 否 | 低 | 简单直接 |
懒汉式 + synchronized |
是 | 是 | 低 | 但每次调用都要同步 |
| DCL 双重检查锁 | 是 | 是 | 高 | 必须配合 volatile |
| 静态内部类 | 是 | 是 | 低 | 普通 Java 场景很常用 |
| 枚举单例 | 是 | 否 | 低 | 对反射、序列化更稳 |
1. 饿汉式
public class Singleton {
private static final Singleton INSTANCE = new Singleton();
private Singleton() {}
public static Singleton getInstance() {
return INSTANCE;
}
}
特点:
- 类加载时就创建实例
- 实现简单,天然线程安全
- 如果实例很重、但不一定会用到,可能造成提前初始化
2. 懒汉式 + 同步方法
public class Singleton {
private static Singleton instance;
private Singleton() {}
public static synchronized Singleton getInstance() {
if (instance == null) {
instance = new Singleton();
}
return instance;
}
}
优点是好理解,缺点是每次获取都进入同步方法,通常不作为首选。
3. DCL 双重检查锁
public class Singleton {
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
new Singleton() 不是一个不可再分的原子动作。简化理解可以拆成:
- 分配内存
- 初始化对象
- 把引用赋给
instance
如果没有 volatile,就可能出现重排序,让其他线程看到“instance 已经不是 null,但对象还没初始化完”的情况。
评价
- 能用
- 但写法复杂,容易被写错
- 现代 Java 项目中通常不是首选答案
4. 静态内部类
public class Singleton {
private Singleton() {}
private static class Holder {
private static final Singleton INSTANCE = new Singleton();
}
public static Singleton getInstance() {
return Holder.INSTANCE;
}
}
这是很常见、也很优雅的实现方式:
- 线程安全由类加载机制保证
- 只有在调用
getInstance()时才触发内部类加载 - 没有 DCL 那种额外复杂度
5. 枚举单例
public enum Singleton {
INSTANCE;
public void doSomething() {
}
}
它的优点很明确:
- 天然线程安全
- 天然防止反射随意构造
- 天然规避反序列化再生成新对象的问题
但它更适合 生命周期简单、初始化固定 的单例场景。
推荐口径
| 场景 | 更推荐的方案 |
|---|---|
| 纯 Java 普通场景 | 静态内部类 |
| 需要额外防反射/反序列化 | 枚举单例 |
| 明确一启动就要用 | 饿汉式 |
| Spring 项目 | 优先交给容器管理 |
反射和序列化为什么会破坏单例
1. 反射
Constructor<Singleton> c = Singleton.class.getDeclaredConstructor();
c.setAccessible(true);
Singleton another = c.newInstance();
如果类没有专门防护,这段代码可能绕过私有构造器限制,创建第二个实例。
2. 序列化
Singleton s1 = Singleton.getInstance();
ObjectOutputStream out = new ObjectOutputStream(new FileOutputStream("s.obj"));
out.writeObject(s1);
ObjectInputStream in = new ObjectInputStream(new FileInputStream("s.obj"));
Singleton s2 = (Singleton) in.readObject();
如果没有处理 readResolve(),s2 可能不是原来的同一个对象。
如何防护
| 风险 | 常见防护方式 |
|---|---|
| 反射破坏 | 枚举单例,或在构造器中增加防重复创建校验 |
| 序列化破坏 | 实现 readResolve(),或直接使用枚举单例 |
Spring 项目里的真实建议
很多业务代码根本不需要手写单例,因为 Spring 默认就把 Bean 作为单例管理:
@Service
public class UserService {
}
真正需要小心的不是“是不是单例”,而是:
单例对象里是否持有共享的可变状态。
错误示例:
@Service
public class UserService {
private User currentUser;
}
这会让多个请求共享同一份可变数据,风险很高。
一句话总结
手写 Java 单例时,普通场景优先考虑静态内部类;如果还要更稳地防反射和序列化,优先考虑枚举;在 Spring 项目里,通常更应该让容器来管理对象生命周期。
相关问题
- 为什么 DCL 现在没那么常被推荐? → 因为它可用但复杂,静态内部类通常更简洁、更不容易写错。
- 枚举单例是不是只能做无状态工具类? → 不是,它也可以有字段和方法,只是更适合初始化简单、生命周期固定的场景。
- 需要传配置参数时怎么办? → 更常见的做法是工厂、配置对象或依赖注入,而不是为了“单例”强行上 DCL。
技术拓展
判断要不要手写单例的顺序
可以按这个顺序思考:
- 能不能交给框架管理?
- 这个对象真的需要全局唯一吗?
- 是否存在共享可变状态风险?
- 是否要防反射和反序列化?
很多时候,真正的最佳实践不是“把单例写得更花”,而是“尽量少手写单例”。