Java 如何实现单例模式?
从饿汉式、静态内部类、双重检查锁和枚举四种实现出发,讲清 Java 单例模式的线程安全、懒加载、安全发布及常见误区。
Java 如何实现单例模式?
面试回答
单例模式用于保证一个类在指定作用域内只有一个实例,并提供统一的访问入口。Java 中常见实现包括饿汉式、静态内部类、双重检查锁和枚举。
饿汉式依靠 JVM 的类初始化机制保证线程安全,实现简单,但不能按需加载;静态内部类同时具备线程安全和懒加载,通常是普通业务代码中的推荐实现;双重检查锁适合需要显式控制懒加载过程的场景,但实例引用必须使用 volatile,防止对象发布与初始化发生不安全的重排序;枚举写法最简洁,还能天然抵御常规反射和反序列化破坏,是实现严格单例的可靠方式。
需要注意,单例的唯一性通常只限定在同一个 ClassLoader 中。分布式系统中的“全局唯一实例”不能只靠 Java 单例模式实现。
一句话总结:
普通懒加载优先使用静态内部类,强调防反射和反序列化时使用枚举;使用双重检查锁时,实例引用必须声明为 volatile。
一张图看懂
详细讲解
一、什么是单例模式
单例模式主要解决两个问题:
- 限制对象创建,保证一个类在指定作用域内只有一个实例;
- 提供统一入口,让其他代码能够获取这个实例。
一个典型的单例类通常包含:
私有构造方法
+ 类内部持有唯一实例
+ 对外提供获取实例的方法
例如配置管理器、无状态工具服务或共享资源协调器,可能只需要一个实例。单例并不等于全局变量,它还负责控制对象的创建过程。
二、饿汉式
饿汉式在类初始化阶段直接创建对象:
public final class Singleton {
private static final Singleton INSTANCE = new Singleton();
private Singleton() {
}
public static Singleton getInstance() {
return INSTANCE;
}
}
它的线程安全来自 JVM 的类初始化机制:一个类的初始化过程由 JVM 加锁并协调,同一个 ClassLoader 下只会执行一次类初始化方法。
优点:
- 实现简单;
- 天然线程安全;
- 实例通过类初始化安全发布,不需要额外加锁。
缺点:
- 类被初始化时就会创建实例;
- 如果实例创建成本较高且最终没有使用,会提前占用资源;
- 构造失败会导致类初始化失败。
适合对象创建成本不高、启动时即可创建的场景。
三、静态内部类
静态内部类可以同时实现线程安全与懒加载:
public final class Singleton {
private Singleton() {
}
private static class Holder {
private static final Singleton INSTANCE = new Singleton();
}
public static Singleton getInstance() {
return Holder.INSTANCE;
}
}
加载外部类 Singleton 时,不会立即初始化 Holder。第一次调用 getInstance() 并访问 Holder.INSTANCE 时,JVM 才初始化 Holder 并创建实例。
加载 Singleton
↓
尚未初始化 Holder,不创建实例
↓
第一次调用 getInstance()
↓
JVM 初始化 Holder
↓
创建并安全发布 INSTANCE
这种写法不需要显式同步,读取实例也没有锁竞争,通常是普通业务代码中实现懒加载单例的优先选择。
四、双重检查锁
双重检查锁也称 DCL,即 Double-Checked Locking:
public final 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;
}
}
1. 为什么检查两次
第一次检查发生在同步块外:
if (result == null)
实例已经创建后,绝大多数调用可以直接返回,不必再竞争锁。
第二次检查发生在同步块内:
synchronized (Singleton.class) {
result = instance;
if (result == null) {
// 创建实例
}
}
假设线程 A 和线程 B 同时通过第一次检查,线程 A 先获得锁并创建对象。线程 B 随后获得锁时,必须再次检查,否则还会创建第二个对象。
2. 为什么必须使用 volatile
创建对象可以概念性地分为:
1. 分配内存
2. 初始化对象
3. 将引用赋给 instance
如果没有适当的内存语义约束,其他线程可能观察到非空引用,却没有可靠地观察到对象完整初始化后的状态。
private static volatile Singleton instance;
volatile 在这里有两个关键作用:
- 禁止对象初始化与引用发布之间发生不安全的重排序;
- 建立 volatile 写与后续 volatile 读之间的 happens-before 关系,使初始化结果对读取线程可见。
五、枚举单例
枚举是更简洁、约束更强的单例实现:
public enum Singleton {
INSTANCE("default-service", 3000);
private final String serviceName;
private final int timeoutMillis;
Singleton(String serviceName, int timeoutMillis) {
this.serviceName = serviceName;
this.timeoutMillis = timeoutMillis;
}
public String getServiceName() {
return serviceName;
}
public int getTimeoutMillis() {
return timeoutMillis;
}
public void execute() {
System.out.println(
serviceName + " timeout=" + timeoutMillis);
}
}
调用方式:
Singleton.INSTANCE.execute();
String serviceName = Singleton.INSTANCE.getServiceName();
int timeoutMillis = Singleton.INSTANCE.getTimeoutMillis();
枚举常量可以拥有构造参数、成员属性和普通方法。上例中的属性使用 final 修饰,实例创建后不再改变,多个线程读取时不需要额外同步。如果枚举单例内部保存的是可变属性,并发访问时仍然需要锁或其他线程安全机制。
枚举单例具有以下特点:
- JVM 保证枚举常量只初始化一次;
- 枚举构造器不能通过常规反射方式创建新实例;
- Java 序列化机制能够保持枚举常量的唯一性;
- 代码简洁,不需要手动编写并发控制。
如果业务能够接受枚举形式,并且特别关注反射与反序列化对单例的破坏,枚举通常是更稳妥的选择。
六、同步方法实现
最直接的懒加载写法是给整个获取方法加锁:
public final class Singleton {
private static Singleton instance;
private Singleton() {
}
public static synchronized Singleton getInstance() {
if (instance == null) {
instance = new Singleton();
}
return instance;
}
}
这种实现是线程安全的,但每次调用 getInstance() 都要进入同步方法。现代 JVM 对无竞争锁已有较多优化,但在能够使用静态内部类的情况下,通常没有必要让每次读取都经过同步。
它的优点是逻辑直观,适合教学或对性能不敏感的简单场景。
七、两种错误实现
1. 没有同步的懒加载
public static Singleton getInstance() {
if (instance == null) {
instance = new Singleton();
}
return instance;
}
多个线程可能同时判断 instance == null,随后分别创建对象,因此不能保证单例。
2. 缺少第二次检查
public static Singleton getInstance() {
if (instance == null) {
synchronized (Singleton.class) {
instance = new Singleton();
}
}
return instance;
}
多个线程可能同时通过外层判断,然后依次进入同步块并各自创建对象。加锁只能保证它们不会同时创建,不能阻止后获得锁的线程再次创建,因此锁内必须重新判断。
八、单例可能被哪些方式破坏
1. 反射
普通单例的私有构造器仍可能被反射调用:
Constructor<Singleton> constructor =
Singleton.class.getDeclaredConstructor();
constructor.setAccessible(true);
Singleton another = constructor.newInstance();
可以在构造器中增加防御性检查,但这种方式需要谨慎处理初始化顺序,也不能替代可靠的访问控制。枚举单例能够从语言与 JVM 层面阻止这种常规反射创建。
2. 反序列化
普通单例实现 Serializable 后,反序列化默认可能创建新对象。可以定义:
private Object readResolve() {
return INSTANCE;
}
让反序列化结果返回已有实例。枚举单例由序列化机制直接保证唯一性。
3. 克隆
如果单例支持 Cloneable 并允许调用 clone(),也可能产生新对象。单例类通常不应实现 Cloneable,或者应拒绝克隆。
4. 多个 ClassLoader
静态字段属于其对应的类,而类的身份由“类名 + 加载它的 ClassLoader”共同决定。同一个类被不同 ClassLoader 分别加载时,每个 ClassLoader 都可以拥有自己的单例实例。
因此 Java 单例通常保证的是:
同一个 JVM、同一个 ClassLoader 范围内只有一个实例。
它不能保证多个 JVM、多个容器实例或分布式集群中只有一个对象。
九、不同实现如何选择
| 实现方式 | 线程安全 | 懒加载 | 主要特点 | 推荐场景 |
|---|---|---|---|---|
| 饿汉式 | 是 | 否 | 简单,依靠类初始化 | 创建成本低、启动时即可创建 |
| 静态内部类 | 是 | 是 | 无显式锁,读取开销低 | 普通业务中的懒加载单例 |
| 双重检查锁 | 是 | 是 | 必须正确使用 volatile | 需要显式控制初始化过程 |
| 枚举 | 是 | 通常不作为延迟加载方案 | 防常规反射和反序列化破坏 | 强调实现简洁和唯一性 |
| 同步方法 | 是 | 是 | 简单但每次获取都同步 | 教学或低频简单场景 |
十、单例不等于无条件线程安全
单例模式只保证实例数量,不会自动保证实例内部状态的线程安全。
public final class CounterSingleton {
private int count;
public void increment() {
count++;
}
}
即使 CounterSingleton 只有一个实例,多个线程并发执行 count++ 仍然可能丢失更新。可变状态仍然需要使用锁、原子类、不可变对象或其他并发控制方式保护。
在 Spring 中,默认的单例 Bean 也只是表示一个 ApplicationContext 中通常只有一个实例。Spring 不会自动保证这个 Bean 的可变字段线程安全。
十一、需要记住的边界
- 单例保证实例数量,不保证内部状态线程安全;
- Java 单例通常只在同一个 ClassLoader 范围内成立;
- Spring Singleton 的作用域是一个
ApplicationContext; - 多 JVM、多个服务实例之间的全局唯一,需要借助数据库唯一约束、分布式锁、协调服务或主节点选举等机制;
- 单例适合承载无状态逻辑,或者经过并发保护的共享状态;
- 不应把“当前用户”“本次请求参数”或普通可变集合等请求级数据保存在单例字段中。所有线程和测试用例会共享这些数据,容易造成请求间数据串扰、并发修改错误以及测试互相影响。
评论与讨论
回复 :
留下你的想法