跳到主要内容

单例模式

单例模式限制对象的创建,并提供统一的获取入口,让调用者在约定范围内复用同一个实例。这里最需要说清楚的是“约定范围”:同一个进程、同一个 Java 运行时类、同一个容器,并不是同一件事。

单例题也不只是把构造函数藏起来。初始化是否完成、并发调用是否会重复创建、初始化失败后怎么办、谁关闭共享资源,都会影响这个对象能否正确使用。实例只有一个,不代表它的业务操作天然安全。

1. 面试核心​

先记住这几条,再读正文
  • 单例的“唯一”有作用域,进程内的一个实例不能保证整个集群只有一个实例。Java 静态字段和 Spring singleton 还要分别考虑运行时类与容器的边界。卡 · 唯一实例的作用域
  • 实现单例要同时保证受控创建与完成初始化后的安全发布。只有互斥地创建对象,却让其他调用方绕过同步读取,仍可能出错。卡 · 创建时机与安全发布
  • 提前初始化与延迟初始化的区别是何时承担创建成本、何时暴露失败,并不决定对象后续操作是否线程安全。
  • JavaScript 可以用闭包保留实例变量,让同一个获取函数返回同一对象。这个范围不跨页面或 Worker,也不自动解决异步初始化竞争。卡 · JavaScript 闭包单例
  • Go 的同一个 sync.Once 协调一次初始化,它不根据业务错误自动重试,panic 后也不会重新执行。恢复 panic 不等于拿到了完整对象。卡 · 初始化失败
  • 单例使对象被共享,后续可变状态仍需独立的同步和生命周期规则。测试可通过显式传入依赖获得隔离,不必并发重置全局实例。卡 · 共享状态、生命周期与测试

2. 唯一实例的作用域​

单例管理的是一组调用者如何获取同一个对象,而不是为所有机器建立全局约束。两个独立启动的服务进程各有自己的内存;即使它们运行完全相同的代码,也会各自初始化实例。

实现或使用方式需要说明的范围
JavaScript 闭包中保存实例同一个闭包实例的获取入口;重新执行外层工厂、不同页面或 Worker 可以有独立实例
Go 包中保存实例,并通过同一个获取函数访问当前程序进程内的这组包变量与入口;另一个进程有独立状态
Java 类的静态字段保存实例同一个运行时类;类的二进制名称与定义类加载器共同决定类型身份
Spring singleton Bean同一个容器中的同一个 Bean 定义;同一类型可以有多个 Bean 定义
多个服务副本都声明单例每个副本仍可以各有一个实例,不提供分布式互斥或唯一性

Java 中,两个类加载器分别定义同名类时,可以各自拥有静态实例;多个加载器如果都委派给同一个定义加载器,则可以复用同一运行时类。区别见双亲委派模型。不能仅按“创建了几个类加载器”推断实例数量。

Spring 的范围由容器管理,详细区别见IoC 容器。如果目标是“整个集群同一时刻只有一个节点执行任务”,应另外讨论选主、租约与失效后的旧执行者,不能靠本地单例完成。

此外,统一入口只约束遵守该入口的调用者。Java 私有构造器限制普通代码直接创建,但不能被当作安全隔离措施;Go 也没有“某类型永远不能再构造第二个值”的单例关键字。实现需要明确公开 API、包内约定和允许的创建路径。

3. 创建时机与安全发布​

提前初始化在所属初始化阶段创建对象,使用时直接取已有结果。延迟初始化把创建推迟到第一次真正获取时。

选择好处需要承担的代价
提前初始化获取路径简单,依赖与错误可以较早暴露即使之后不用,也会创建;初始化可能拖慢启动
延迟初始化未使用时不创建,推迟占用资源首次调用承担延迟;并发首次访问、失败和等待需要协议

“提前”相对于所属初始化阶段而言。Go 包级变量在包初始化时建立;Java 静态字段初始化发生在类初始化时,不应直接说成“程序刚启动就一定创建”。如果启动代码主动创建并校验依赖,就能明确决定何时暴露错误。

安全发布保证调用者拿到对象时,也能看到规定的初始化写入。“只创建一次”与“初始化完成后才能使用”是两个需要同时满足的条件。

一个有问题的做法是:先把共享标记设成“完成”,再去填充配置。其他调用者看到标记后直接返回,会读到还没有准备好的对象。另一个问题是仅在创建者一侧加锁,却让读取者在锁外直接检查普通共享变量;没有相应的同步关系,读取仍然不安全。

Java 的双重检查锁定需要正确使用 volatile,静态持有者则利用类初始化的同步规则。代码和内存模型解释统一放在安全发布及其静态持有者章节。这里不再维护第二套实现。

3.1 JavaScript 闭包单例​

闭包让函数继续访问创建它时所在的词法环境。可以把实例变量留在外层函数中,只向调用者提供获取函数;这个获取函数的每次调用访问的都是同一份变量。

下面的外层立即执行函数只运行一次,返回 getSettings。对象在第一次调用 getSettings() 时创建:

const getSettings = (() => {
let instance;
return function getSettings() {
if (instance === undefined) {
instance = { theme: 'light' };
}
return instance;
};
})();

const first = getSettings();
const second = getSettings();
console.log(first === second); // true
first.theme = 'dark';
console.log(second.theme); // dark

instance 不在调用者的直接作用域中,但返回的对象仍然可变。first 和 second 引用同一个对象,所以修改 first.theme 后,second.theme 也变成 dark。闭包隐藏变量不等于冻结对象,后续共享状态仍要按业务管理。

这个例子的初始化是同步的对象字面量构造,没有 await,也不会回调外部代码。不能直接把它改成“如果为空就 await 创建”:两个调用都可能在等待期间看到尚未赋值,从而分别创建对象。异步初始化通常要先保存同一个进行中的 Promise,并明确失败后保留错误还是允许重试;涉及递归获取时,也要另外处理重入。

单例的范围是这个外层函数创建的一份环境。若每次获取时都重新执行外层工厂,就会创建新的实例变量;不同页面、Worker 或独立加载的模块副本也不自动共享该对象。模块导出可以提供统一入口,但仍需明确模块实例和运行环境的边界。词法环境与状态保留见作用域与闭包。

4. Go 的单例实现​

sync.Once 适合只需要执行一次的延迟初始化。下例以 Go 1.22 为基线,把配置保存在包变量中,由同一个 DefaultConfig 入口返回。

package main

import (
"fmt"
"sync"
)

type config struct {
endpoint string
}

func (c *config) Endpoint() string {
return c.endpoint
}

var configOnce sync.Once
var defaultConfig *config

func DefaultConfig() *config {
configOnce.Do(func() {
defaultConfig = &config{endpoint: "https://example.test"}
})
return defaultConfig
}

func main() {
first, second := DefaultConfig(), DefaultConfig()
fmt.Println(first == second, first.Endpoint())
}

输出 true https://example.test,两个调用返回相同的指针。config 不是零大小类型,这里的指针比较能够检查是否返回了同一个配置对象。示例只保存字符串,没有向该地址发起网络请求。

并发调用时,所有获取者都经过同一个 configOnce.Do。一次初始化正常结束后,对 defaultConfig 及其字段的写入能够被这些调用者看到。Once 的等待、发布与内部完成标记详见 Go 同步原语。

这段代码还有几个明确约定:

  • configOnce 必须是共享的那一个,不能在每次调用 DefaultConfig 时新建,也不能复制已经使用过的 Once。
  • 调用者通过 DefaultConfig 获取结果,不能在其他路径无同步地读取或替换 defaultConfig。
  • 配置初始化后不再修改。Endpoint 返回字符串,不把可变 map 或 slice 的内部存储直接交出去。
  • config 是包内类型,但 Go 仍允许包内构造其他值,拿到指针的代码也可能复制它指向的值。这里保证的是统一获取入口返回同一实例,不是禁止所有值复制。

4.1 初始化失败​

Once.Do 只负责协调一次执行,不把“执行完毕”解释为“业务成功”。如果初始化函数读取文件失败,可以把结果与错误一起保存在共享状态中,让获取函数返回 (对象, error);后续调用会得到已保存的结果,不会自动再读取一次。

如果初始化发生 panic,Once 也不会在后续调用中重新执行。外层即使恢复 panic,实例仍可能为 nil 或只完成了一部分赋值。调用方必须处理这种失败,不能继续当作初始化成功。

同一个初始化过程直接或间接再次调用自己的获取入口,还可能形成递归等待。具体行为与反例见 Go 同步原语。

需要失败重试、动态换配置或重建连接时,应该设计明确的状态与生命周期,例如“未初始化、初始化中、就绪、失败、关闭”,并规定等待者何时收到错误、谁能重试。把正在被其他 goroutine 使用的 Once 赋成零值不是可靠的重试方案。

Java 静态初始化也不是通用重试机制:同一运行时类初始化失败后会进入错误状态,后续主动使用会失败,详见类加载故障。

5. 共享状态、生命周期与测试​

单例只限制实例获取,不限制实例内部有多少资源。一个共享数据库客户端可以管理多个连接;“一个客户端对象”不等于“只能有一条数据库连接”。

更需要区分的是初始化和后续使用。即使所有请求都拿到同一个对象,并且初始化可见,多个请求同时修改普通计数器仍会发生竞争。可以选择不可变状态、互斥锁、原子操作或合适的并发容器;把请求专属的用户信息写入共享字段,则首先是作用域设计有问题。

5.1 谁负责关闭​

共享资源应有明确的所有者。应用启动时创建客户端,再交给各个业务组件使用,可以由应用在停止接收新任务、等待已有任务结束后统一关闭。

如果每个调用者都在使用后关闭同一个全局客户端,其他调用者可能仍在使用它。反过来,完全依赖进程退出,又可能错过刷新缓冲区、释放远端会话或记录失败的机会。单例模式不会自动决定这些顺序。

5.2 测试如何隔离​

全局单例容易让测试互相影响:前一个测试已经初始化配置,后一个测试即使换了环境变量,也无法让同一个 Once 重新执行;测试修改的共享状态还可能泄漏到下一项。

一种直接的处理方式是把默认实例的选择留在应用装配处,业务组件通过构造参数接收依赖。生产环境可以把同一个实例传给多个组件,测试则传入独立实例或替代实现。这样既能复用资源,也能明确每个测试依赖什么。

确实要测试全局默认入口时,可以使用独立进程获得新的包级状态。不要让并行测试一边使用全局对象,一边把指针和 Once 重置;这同时改变了对象身份和同步状态。

是否使用单例,应根据对象是否需要共享、生命周期是否稳定以及依赖是否清楚来决定。请求数据、不同租户的配置和需要频繁重建的资源,通常需要比“全局一个对象”更具体的作用域。

6 · 面试题卡片​

本页题目与参考回答

6.1 单例模式是什么,“只有一个实例”指什么范围?​

考察重点:受控创建、统一入口、进程与类加载器及容器的作用域。

相关内容:唯一实例的作用域。

参考回答

单例模式让调用者通过统一入口,在约定范围内获取同一个实例。 应同时说明如何限制创建,以及这个范围由谁管理。

本地包变量不跨进程共享;Java 静态实例属于具体运行时类,要结合定义类加载器判断;Spring singleton 则是每个容器、每个 Bean 定义共享一个实例。同一类注册成多个 Bean,或在不同容器中使用,可以有多个实例。

因此,本地单例不能证明集群中只有一个执行者,也不能直接提供分布式互斥。

6.2 如何实现单例模式?​

考察重点:创建时机、并发首次获取、初始化完成与发布关系。

相关内容:创建时机与安全发布、Go 的单例实现。

参考回答

先确定作用域和创建时机,再保证受控创建与安全发布。 不需要延迟创建时,可以在明确的初始化阶段创建并保存;需要延迟时,要协调并发首次获取。

Go 可以使用包级实例和同一个 sync.Once,所有获取者调用 Do 后再返回实例。初始化正常完成后,其他调用者才能通过该同步关系看到完整结果。Java 可以使用静态持有者;采用双重检查锁定时,需要锁内再次检查,并通过 volatile 等正确机制完成发布。

还要说明初始化失败是否重试、对象是否允许后续修改,以及谁负责关闭。不能只展示一个空值判断,就认定并发安全。

6.3 初始化失败后,能否依靠 Once 或静态初始化自动重试?​

考察重点:执行一次与业务成功、panic、失败状态及重试协议。

相关内容:初始化失败。

参考回答

不能把它们当作自动重试机制。 Go Once 的初始化函数正常返回后,无论业务是否成功,后续都不会再执行;panic 后也不会重新调用。恢复 panic 只处理控制流,不保证实例完整。

Java 同一个运行时类初始化失败后会进入错误状态,也不会通过下一次使用重新初始化。需要重试时,应显式管理失败、等待、下一次尝试和资源清理,不并发重置正在使用的同步对象。

6.4 单例初始化安全,为什么业务方法仍可能有数据竞争?​

考察重点:初始化发布与后续修改、共享对象的状态和生命周期。

相关内容:共享状态、生命周期与测试。

参考回答

初始化同步只保证规定的初始化写入可见,不会自动保护之后的每次修改。 多个调用者共享对象后,普通计数器、map 或请求字段仍可能被同时访问。

可以保持状态不可变,或为后续操作选择锁、原子操作与并发容器。请求专属数据应放在请求自己的作用域中。共享资源的关闭也要由明确的所有者协调,避免一个调用者关闭后,其他调用者仍在使用。

6.5 怎样用 JavaScript 闭包实现单例对象?​

考察重点:闭包保留实例变量、对象身份、创建时机与作用域。

相关内容:JavaScript 闭包单例。

参考回答

让外层函数保留实例变量,向外返回一个获取函数,所有调用者复用这个获取函数。 第一次调用时创建对象并保存,后续返回同一个引用;用两次返回值的严格相等比较检查身份。

外层函数不能在每次获取时重新执行,否则保存实例的环境也会重新创建。返回同一对象会共享可变状态,不能把闭包当作不可变性保证。同步对象构造的示例也不能直接推导到异步初始化;有等待过程时,需要先协调进行中的初始化及失败策略。

7. 参考资料​

来源:Coding 101 原文 · 免费阅读,引用请保留出处。内容使用说明