什么是单例模式?它适合哪些场景?
简化版
单例模式保证一个类在约定的作用域内只有一个实例,并提供统一的获取入口。它适合确实需要共享同一份状态或资源的对象,但不应把单例当成方便访问的全局变量。
详细版
单例模式通常包含三个要点:
- 构造方法私有化,限制外部直接创建对象;
- 类内部保存唯一实例;
- 对外提供获取实例的方法。
常见场景包括配置中心客户端、进程内注册表、ID 生成器和需要集中协调的资源管理器。使用前要确认“唯一”的边界:传统 Java 单例通常是每个定义它的 ClassLoader 一份,而不是天然覆盖整个分布式系统。
单例的优点是避免重复创建、集中管理共享资源;缺点是隐藏依赖、共享可变状态可能引发并发问题,而且全局生命周期会增加测试隔离和资源释放的难度。无状态服务更适合交给依赖注入容器管理,业务数据通常不适合放进单例。
完整版教学
一、单例解决的核心问题
单例解决的不是“少写几次 new”,而是对象身份必须统一的问题。如果两个组件必须访问同一个注册表、同一个进程级协调器或同一份缓存索引,就需要明确谁创建它、谁持有它以及其他代码如何获取它。
典型结构如下:
public final class AppRegistry {
private static final AppRegistry INSTANCE = new AppRegistry();
private AppRegistry() {}
public static AppRegistry getInstance() {
return INSTANCE;
}
}
private 构造方法控制创建入口,static 字段保存共享实例,getInstance() 提供访问点。具体实现还要继续考虑初始化时机、线程安全、序列化和反射等问题。
二、“只有一个”的边界必须说清楚
单例并不自动等于全世界唯一:
- 同一 Java 类被不同
ClassLoader定义时,可以各自拥有静态字段和实例; - 同一应用启动两个进程时,每个进程都有自己的单例;
- 分布式部署多个节点时,每个节点也会有独立实例;
- Spring 的默认单例是每个容器、每个 Bean 定义一个实例。
如果业务要求跨进程唯一,例如全局递增序号或分布式主节点,就需要数据库唯一约束、分布式锁或一致性协议,不能只靠进程内单例。
三、适用与不适用场景
适用的前提是对象确实代表共享资源或协调角色,例如昂贵且可复用的客户端、应用级配置快照、进程内注册中心。仅仅因为“很多地方都要调用”并不足以使用单例,依赖注入同样可以方便地共享实例,同时让依赖关系更清晰。
下面几类对象通常不适合做单例:
- 保存用户、请求或会话状态的对象;
- 生命周期需要精确释放且难以跟随进程结束的资源;
- 为了省事而塞入大量可变业务数据的“万能管理器”;
- 测试中需要频繁替换或并行隔离的依赖。
四、单例不等于线程安全
安全地创建出唯一实例,只解决了初始化问题。如果实例内部有可变字段,多个线程仍可能同时修改它。实现时还要采用不可变状态、线程安全集合、锁或其他同步机制保护业务操作。
“实例唯一”和“实例内所有方法线程安全”是两件事,面试中应主动区分。
五、先用资源身份判断是否真的需要单例
假设进程内有 20 个业务组件都要向同一个注册表登记处理器。如果每个组件各建一个注册表,登记结果彼此不可见;单例可以统一身份。但若对象只是无状态格式化器,容器共享实例就够了,不必把静态全局入口写进类。
20 个组件启动
所有组件依赖 Registry 抽象
组合根创建/取得一个进程内 Registry 实例
把同一引用注入 20 个组件
组件 A 注册 handler-x
组件 B 查询时能看到 handler-x
跨进程部署后,每个进程仍有自己的 Registry
这条时序必须同时检查“谁负责创建”和“其他线程或调用方从哪里取得实例”。只看到最终引用相同还不够;若构造副作用执行了两次、发布了半初始化状态,或者作用域边界之外又创建了一份,就已经违反了本题讨论的保证。
六、保证范围与失效边界
| 评审维度 | 本题结论 |
|---|---|
| 唯一性/正确性保证 | 在约定作用域内控制创建入口并提供同一对象身份。 |
| 作用域边界 | 传统 Java 静态单例通常是每个定义 ClassLoader 一份;容器单例则按容器与定义计算。 |
| 仍未解决的问题 | 隐藏依赖、全局可变状态、测试污染、资源释放和跨节点一致性都不会自动解决。 |
| 更合适的替代方案 | 优先显式依赖注入;确有固定语言级身份且生命周期简单时再使用静态或枚举单例。 |
设计模式的保证必须带作用域。把“某个类初始化边界只创建一次”说成“整个系统永远只有一个”,或把“安全发布引用”说成“对象所有方法都线程安全”,都会把不同层次的问题混在一起。
七、工程落地时的验证方法
- 为构造过程增加可观察计数,使用 32 个并发任务同时获取实例,构造计数应符合本题保证。
- 比较引用身份时使用
==,不要让重写后的equals()掩盖实际存在的多个对象。 - 若涉及类加载器,记录
instance.getClass().getClassLoader(),同时比较类对象和加载器身份。 - 若涉及序列化,至少执行一次“序列化 → 反序列化”,并核对返回引用及外部副作用。
- 若涉及 Spring,分别检查 Bean 名称、
ApplicationContext身份和作用域,而不只检查 Java 类名。 - 对共享可变字段另做并发测试;实例创建正确并不能替代业务状态的原子性测试。
- 对初始化失败、重复调用和资源关闭分别设计用例,确认生命周期语义可解释。
记忆钩子:单例解决的是“必须共享同一个身份”,不是“到处调用更方便”。
八、常见误区与追问
- 误区:只要对象创建成本高就必须做单例。 对象池、容器作用域或缓存也能复用,需结合并发和生命周期选择。
- 误区:单例就是全局变量的无害包装。 静态访问仍会隐藏依赖并放大共享状态影响面。
- 误区:单例天然覆盖整个分布式系统。 每个进程都有独立内存和静态字段。
- 追问:单例与静态工具类有什么区别? 单例是对象,可实现接口、持有生命周期并被注入;静态工具主要提供类级无状态函数。
- 追问:为什么单例会降低测试隔离? 全局实例状态可能跨测试残留,且静态入口难替换。
- 追问:哪些对象明显不适合单例? 用户会话、请求上下文和包含未同步可变业务状态的对象通常不适合。
九、加强记忆
理解单例要抓住“受控创建、统一入口、明确边界”三个关键词。先判断对象是否真的需要共享身份,再讨论懒加载和线程安全;只要边界扩展到多类加载器、多进程或多节点,就必须引入比单例更强的协调机制。