← 返回题目列表

Spring 单例与 GoF 单例有什么区别?

高频 中等 第 13 / 26 题 更新于 2026/07/28
单例模式SpringBean作用域依赖注入

简化版

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 类注册 primaryClientbackupClient 两个 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/静态单例。

设计模式的保证必须带作用域。把“某个类初始化边界只创建一次”说成“整个系统永远只有一个”,或把“安全发布引用”说成“对象所有方法都线程安全”,都会把不同层次的问题混在一起。

八、工程落地时的验证方法

  1. 为构造过程增加可观察计数,使用 32 个并发任务同时获取实例,构造计数应符合本题保证。
  2. 比较引用身份时使用 ==,不要让重写后的 equals() 掩盖实际存在的多个对象。
  3. 若涉及类加载器,记录 instance.getClass().getClassLoader(),同时比较类对象和加载器身份。
  4. 若涉及序列化,至少执行一次“序列化 → 反序列化”,并核对返回引用及外部副作用。
  5. 若涉及 Spring,分别检查 Bean 名称、ApplicationContext 身份和作用域,而不只检查 Java 类名。
  6. 对共享可变字段另做并发测试;实例创建正确并不能替代业务状态的原子性测试。
  7. 对初始化失败、重复调用和资源关闭分别设计用例,确认生命周期语义可解释。

记忆钩子:GoF 问“这个类允许几次创建”,Spring 问“这个容器对这个 Bean 定义缓存几份”。

九、常见误区与追问

  • 误区:Spring singleton 表示同一个类在 JVM 中只能 new 一次。 代码可绕过容器创建,多个定义和多个容器也能各有实例。
  • 误区:默认单例 Bean 的方法自动串行执行。 多个请求线程可以并发调用同一实例。
  • 误区:Bean 名不同但类相同仍会自动合并。 它们是不同 BeanDefinition,通常对应不同实例。
  • 追问:父子容器中单例如何计算? 每个容器有自己的定义和缓存;子容器查找规则与是否覆盖父定义有关。
  • 追问:prototype 注入 singleton 有什么坑? 直接构造注入通常只在 singleton 创建时解析一次,需要 Provider 等方式按需获取。
  • 追问:为什么容器单例更易测试? 调用方依赖可通过构造器显式注入,可替换为测试实现而非硬编码静态入口。

十、加强记忆

GoF 单例是“类自己保证每个类加载边界一个”,Spring 单例是“容器保证每个 Bean 定义一个”。两者都不等于跨 JVM 唯一,也都不自动让可变业务状态线程安全。