Spring 单例与 GoF 单例有什么区别?
简化版
GoF 单例由类自身控制创建和全局访问,通常是每个 ClassLoader 一份;Spring 的 singleton 是每个 IoC 容器、每个 Bean 定义维护一个共享实例。Spring 单例不要求私有构造方法,也不意味着同一个类在整个 JVM 中只能有一个对象,更不自动保证线程安全。
详细版
两者的控制者和边界不同:
| 维度 | GoF 单例 | Spring singleton Bean |
|---|---|---|
| 创建控制 | 类的私有构造与静态字段 | IoC 容器与 BeanDefinition |
| 获取方式 | 常见为静态 getInstance() | 注入或 getBean() |
| 典型边界 | 每个定义 ClassLoader | 每个容器、每个 Bean 定义 |
| 可替换性 | 容易形成硬编码依赖 | 可通过接口和配置替换 |
| 构造方法 | 通常私有 | 可以是普通构造方法 |
同一个类可以注册为两个不同名称的 Bean,从而在同一容器中得到两个实例;父子容器也可能分别持有实例。反过来,一个默认单例 Bean 会被多个调用方共享,所以可变字段仍需遵守并发安全规则。
完整版教学
一、GoF 单例把规则写进类
传统单例通过私有构造方法、静态字段和静态获取方法,让类自己掌握实例创建。这种方式无需容器,但调用方直接依赖静态入口,替换实现和测试隔离比较困难。
AuditService.getInstance().record(event);
依赖隐藏在方法内部,构造函数无法直接说明当前对象需要 AuditService。
二、Spring 把规则放在容器
Spring 把类视为创建 Bean 的配方,默认 singleton 作用域让同一个容器对同一个 Bean 定义返回同一对象。调用方通常通过构造器注入:
@Service
public class OrderService {
private final AuditService auditService;
public OrderService(AuditService auditService) {
this.auditService = auditService;
}
}
AuditService 本身不必实现静态 getInstance(),可以保持普通 Java 类,从而更容易替换成测试实现。
三、为什么不是整个 JVM 唯一
Spring 官方对默认单例的准确描述是“每个容器、每个 Bean”。以下情况都可能产生多个对象:
- 同一个类声明两个不同的 Bean 定义;
- 应用存在父子
ApplicationContext; - 测试或多应用分别启动容器;
- 某个 Bean 被声明为
prototype等其他作用域; - 代码绕过容器自行执行
new。
因此不能通过 obj1.getClass() == obj2.getClass() 推断它们必然是同一个 Spring Bean。
四、容器单例也要处理并发
Spring 只保证从对应 Bean 定义取得同一个实例,不会自动串行化方法调用。如果默认单例 Bean 保存请求级可变字段,多个请求线程会共享并竞争这些字段。
常见做法是让服务 Bean 保持无状态,把请求数据放在方法局部变量中;必须共享状态时,再使用不可变对象、线程安全组件或显式同步。
五、如何选择
在使用依赖注入框架的业务系统里,容器单例通常更利于生命周期管理、依赖显式化和测试。枚举或静态单例仍适合非常小、无依赖且确实需要语言级固定实例的工具或值对象角色,但不要与容器重复维护同一资源。
选择标准不是“哪种单例更纯正”,而是谁最适合拥有创建权和生命周期。只要业务组件已经依赖 Spring,把它再做成静态单例通常会形成容器和类自身两套实例管理,既难测试也容易重复创建资源。
六、同一个类在一个容器里也可能有两个实例
Spring 的单位是 BeanDefinition,不是 Java 类。若同一个 Client 类注册 primaryClient 和 backupClient 两个 Bean 定义,默认 singleton 只保证每个定义反复获取返回同一对象,并不把两个定义合并。
ApplicationContext C 启动
BeanDefinition primaryClient -> Client实例 P
BeanDefinition backupClient -> Client实例 B
C.getBean("primaryClient") 两次都返回 P
C.getBean("backupClient") 两次都返回 B
P != B,虽然 P.getClass() == B.getClass()
再启动 Context D,还可创建自己的实例
这条时序必须同时检查“谁负责创建”和“其他线程或调用方从哪里取得实例”。只看到最终引用相同还不够;若构造副作用执行了两次、发布了半初始化状态,或者作用域边界之外又创建了一份,就已经违反了本题讨论的保证。
七、保证范围与失效边界
| 评审维度 | 本题结论 |
|---|---|
| 唯一性/正确性保证 | 每个 ApplicationContext 中每个 singleton BeanDefinition 缓存一个共享实例。 |
| 作用域边界 | 容器、Bean 定义和作用域共同决定边界;不是类加载器或 JVM 全局唯一。 |
| 仍未解决的问题 | 容器只管理身份和生命周期,不会自动让 Bean 的可变字段线程安全。 |
| 更合适的替代方案 | 业务依赖优先容器单例;极小且无依赖的语言级常量服务才考虑 enum/静态单例。 |
设计模式的保证必须带作用域。把“某个类初始化边界只创建一次”说成“整个系统永远只有一个”,或把“安全发布引用”说成“对象所有方法都线程安全”,都会把不同层次的问题混在一起。
八、工程落地时的验证方法
- 为构造过程增加可观察计数,使用 32 个并发任务同时获取实例,构造计数应符合本题保证。
- 比较引用身份时使用
==,不要让重写后的equals()掩盖实际存在的多个对象。 - 若涉及类加载器,记录
instance.getClass().getClassLoader(),同时比较类对象和加载器身份。 - 若涉及序列化,至少执行一次“序列化 → 反序列化”,并核对返回引用及外部副作用。
- 若涉及 Spring,分别检查 Bean 名称、
ApplicationContext身份和作用域,而不只检查 Java 类名。 - 对共享可变字段另做并发测试;实例创建正确并不能替代业务状态的原子性测试。
- 对初始化失败、重复调用和资源关闭分别设计用例,确认生命周期语义可解释。
记忆钩子:GoF 问“这个类允许几次创建”,Spring 问“这个容器对这个 Bean 定义缓存几份”。
九、常见误区与追问
- 误区:Spring singleton 表示同一个类在 JVM 中只能 new 一次。 代码可绕过容器创建,多个定义和多个容器也能各有实例。
- 误区:默认单例 Bean 的方法自动串行执行。 多个请求线程可以并发调用同一实例。
- 误区:Bean 名不同但类相同仍会自动合并。 它们是不同 BeanDefinition,通常对应不同实例。
- 追问:父子容器中单例如何计算? 每个容器有自己的定义和缓存;子容器查找规则与是否覆盖父定义有关。
- 追问:prototype 注入 singleton 有什么坑? 直接构造注入通常只在 singleton 创建时解析一次,需要 Provider 等方式按需获取。
- 追问:为什么容器单例更易测试? 调用方依赖可通过构造器显式注入,可替换为测试实现而非硬编码静态入口。
十、加强记忆
GoF 单例是“类自己保证每个类加载边界一个”,Spring 单例是“容器保证每个 Bean 定义一个”。两者都不等于跨 JVM 唯一,也都不自动让可变业务状态线程安全。