今天我们来深入讲解设计模式中最基础、最常用,也最容易出问题的一个模式——单例模式。
不知道大家有没有这样的感觉:提到设计模式,23种里平时真正用得多的就那么几个,而单例模式绝对是其中的“人气王”。为什么?因为它解决的是一个非常朴素的场景——如何确保一个类在全局只有一个实例,并且这个实例要能方便地被各处访问。听起来简单,但真要把它写好、用对,背后涉及线程安全、性能、JVM类加载机制、反射攻击等不少知识点。今天我们就带着几个问题,一起把它吃透。
一、为什么要用单例模式?——两个真实场景带你理解
先问大家一个问题:如果你们开发一个电商系统,产品经理要求整个系统每秒最多只能处理100次请求(为了保护下游支付服务),你会怎么实现?
很多同学第一反应:写一个限流器 RateLimiter,然后在每个 Controller 里 new 一个。代码可能像这样:
public class RateLimiter {
private int permitsPerSecond;
private int tokens;
// ... 限流逻辑
}
// UserController
private RateLimiter limiter = new RateLimiter(100);
// OrderController
private RateLimiter limiter = new RateLimiter(100);
思考一下:这有什么问题?
每个 Controller 都有自己的限流器对象,相当于每个模块独立限流100次,加起来系统QPS就变成200+了,限流完全失效!而且在高并发场景下,多个微服务一叠加,后果更严重。
有同学说:那我在 tryAcquire 方法上加锁行不行?
- 如果加
synchronized(this),锁的是对象实例,不同 Controller 用的不同对象,锁不共享,没用。 - 如果加
synchronized(RateLimiter.class),虽然能限流,但每次请求都要竞争全局锁,性能极差,而且每个 Controller 还是要 new 自己的对象,浪费内存。
最佳方案是什么?
整个系统只允许存在一个限流器实例,所有地方共享同一个令牌桶。这样限流策略就全局生效了,而且只创建一次对象,节省内存,也不需要加锁(因为共享的是同一个对象)。这就是单例模式的价值。
public class RateLimiter {
private static final RateLimiter instance = new RateLimiter(100);
private RateLimiter(int permitsPerSecond) { ... }
public static RateLimiter getInstance() { return instance; }
}
再比如,在电商系统中,订单号必须全局唯一,而且往往有业务格式(日期+序列号)。如果系统中同时存在两个订单号生成器实例,就一定会出现重复订单号,导致支付对账、客服查询全部混乱。这类业务上要求全局唯一的场景,也是单例模式擅长的。
总结一下,单例模式主要解决两类问题:
- 资源竞争冲突:像限流器、日志记录器、数据库连接池等,如果多个实例各自为政,就会破坏全局一致性。
- 业务上必须全局唯一:如订单号生成器、配置管理器等。
二、单例模式怎么实现?——八仙过海,各有千秋
要实现一个正确的单例,必须抓住四个关键点:
- 构造函数私有化:防止外部
new。 - 线程安全:多线程环境下只创建一次实例。
- 延迟加载(懒加载) :是否在第一次使用时才创建(避免启动时加载不必要的资源)。
- 性能:
getInstance()方法的执行效率。
下面我们逐一分析 Java 中主流的实现方式。
1. 饿汉式
public class Singleton {
private static final Singleton INSTANCE = new Singleton();
private Singleton() {}
public static Singleton getInstance() { return INSTANCE; }
}
- 优点:线程安全(JVM类加载保证),性能极高(无锁)。
- 缺点:不支持懒加载,类加载时就创建实例,如果从未使用会造成资源浪费。
- 适用场景:实例创建开销小,且应用启动后一定会用到。
2. 懒汉式(线程不安全)
public class Singleton {
private static Singleton instance;
private Singleton() {}
public static Singleton getInstance() {
if (instance == null) instance = new Singleton();
return instance;
}
}
- 问题:多线程下可能创建多个实例,绝对不能用于生产环境。
3. 懒汉式(线程安全,同步方法)
public static synchronized Singleton getInstance() {
if (instance == null) instance = new Singleton();
return instance;
}
- 优点:线程安全,延迟加载。
- 缺点:每次调用都同步,即使实例已创建也存在锁竞争,高并发下性能差。
4. 双重检查锁(Double-Checked Locking, 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修饰,防止指令重排序导致半初始化对象被其他线程读取。 - 缺点:实现较复杂,需要理解 Java 内存模型;在 JDK 1.5 以下版本有缺陷(现在已修复)。
5. 静态内部类(推荐写法)
public class Singleton {
private Singleton() {}
private static class Holder {
private static final Singleton INSTANCE = new Singleton();
}
public static Singleton getInstance() {
return Holder.INSTANCE;
}
}
- 原理:外部类加载时不会加载内部类,只有调用
getInstance()时才会触发Holder类加载,从而创建实例。JVM 在类加载时会加锁,保证INSTANCE只被初始化一次。 - 优点:懒加载、线程安全、高性能(无锁)、代码简洁。
- 缺点:无法传递参数(可通过静态方法变通),无法防止反射/序列化破坏。
6. 枚举单例
public enum Singleton {
INSTANCE;
public void doSomething() { ... }
}
- 优点:天然线程安全、懒加载、高性能,而且自动防止反射和序列化攻击(反射不能创建枚举实例,序列化也不会产生新对象)。
- 缺点:无法延迟加载(枚举类被引用时就会加载),不能继承其他类(但可以实现接口)。
- 适用场景:最简洁、最安全的单例实现,是 Effective Java 推荐的写法。
7. CAS(AtomicReference)
private static final AtomicReference<Singleton> INSTANCE = new AtomicReference<>();
public static Singleton getInstance() {
for (;;) {
Singleton instance = INSTANCE.get();
if (instance != null) return instance;
INSTANCE.compareAndSet(null, new Singleton());
return INSTANCE.get();
}
}
- 优点:无锁,利用 CAS 实现线程安全。
- 缺点:每次创建实例失败时都会新建一个临时对象,造成浪费;代码可读性一般,不如静态内部类优雅。
三、单例模式有哪些问题?
同学们,任何模式都不是银弹,单例模式也有其局限性和潜在问题:
- 反射攻击:通过
setAccessible(true)可以调用私有构造器,创建多个实例。
解决方案:在构造器中加入判断,若实例已存在则抛出异常;或者使用枚举单例(天然防御)。 - 序列化破坏:实现
Serializable接口后,反序列化会创建新对象。
解决方案:重写readResolve()方法返回已有实例。 - 测试困难:单例的全局状态会让单元测试之间相互影响,难以隔离。
- 违反单一职责:单例类既要管理自己的业务逻辑,又要控制实例的创建,职责过重。
- 扩展性差:子类无法继承单例(私有构造器),不利于面向接口编程。
四、单例和静态类的区别?
很多同学会混淆单例和静态类(全是静态方法的类,如 Math)。它们有本质区别:
| 维度 | 单例 | 静态类 |
| 实例化 | 有且仅有一个实例,可以继承、实现接口 | 不能实例化,完全是工具方法 |
| 生命周期 | 由类控制,可延迟初始化 | 类加载时即初始化 |
| 灵活性 | 可以配置参数、替换实现 | 无法改变行为 |
| 内存占用 | 只占一份对象内存 | 静态方法不占对象内存,但静态变量常驻 |
| 使用场景 | 需要维护状态(如限流器、连接池) | 无状态工具方法(如数学计算) |
一句话:如果你需要维护状态,并且必须全局唯一,用单例;如果只是提供一组无状态工具方法,用静态类。
五、有哪些替代方案?
随着架构演进,单例模式在大型项目中也逐渐暴露出不足,我们有一些更现代的替代思路:
- 依赖注入(DI) :通过 Spring 等容器管理 Bean 的作用域(如
@Singleton、@RequestScoped),既保证唯一性,又便于测试和解耦。 - 工厂模式:由工厂统一管理对象的创建和生命周期,可以在工厂内部实现单例逻辑,但调用方不直接依赖单例类。
- 作用域限定:比如 Web 应用中的请求级别单例(每个请求一个实例),而不是全局单例。
建议:在大型项目或框架中,尽量利用 Spring 的 Bean 作用域来替代手写单例,既能享受容器的生命周期管理,又不会侵入业务代码。
六、总结与课后思考
今天我们围绕单例模式,从“为什么用”到“怎么实现”,再到“有什么坑”和“怎么替代”,系统梳理了一遍。希望能记住几个关键点:
- 单例的核心:确保一个类只有一个实例,并提供全局访问点。
- 实现时务必注意:线程安全、延迟加载、性能。
- 生产环境推荐:静态内部类(懒加载、性能优)或枚举(最安全)。
- 不要滥用单例:它会使代码耦合变重、测试变难,用依赖注入往往是更好的选择。