单例在谁的范围里唯一:作用域装饰器与缓存所有权
测试里为了隔离,每个用例都从同一份配置新建一个容器。结果一个用例改了时钟组件的状态,另一个用例也看到了。两个容器明明是分别创建的,「单例」却只有一个。
容器支持单例作用域时,常见的做法是给负责创建对象的 Provider 套一层装饰器:第一次调用时创建,之后返回缓存的实例。这个装饰器很短,却决定了几件不那么显眼的事:缓存归谁所有、两个名字是否指向同一个实例、并发时创建几次、创建失败或返回 null 时怎么办、依赖元数据是否还看得见。「单例」这个词本身不回答这些问题,它只有在说清楚「在什么范围内唯一」之后才有意义。
本文用一组极简的 Provider 与装饰器逐个验证这几件事。实验见文末。
一、职责拆分:怎样创建,与何时复用
先把两件事分开:
interface ComponentProvider<T> {
T create(Context ctx); // 怎样造一个对象
default List<String> deps() { ... } // 声明依赖哪些键,供配置期检查
}
class Singleton<T> implements ComponentProvider<T> { // 何时复用:装饰器
final ComponentProvider<T> inner; T instance;
public T create(Context ctx) { if (instance == null) instance = inner.create(ctx); return instance; }
public List<String> deps() { return inner.deps(); }
}创建逻辑(选构造器、注入依赖)和生命周期逻辑(缓存、复用)各自独立变化:新增一种作用域只需要新增一种装饰器,不改创建代码。这是装饰器模式的典型用途,相关的取舍见包装器:适配器、装饰器与代理。问题出在装饰器这个对象被谁持有。
二、缓存跟着 Provider 对象走
如果配置对象里直接存放装饰好的 Provider,构建容器时只复制这张表:
同一份配置浅复制出两个容器:容器一的 clock 是 #1,容器二的 clock 是 #1(两个容器共享一个缓存)表是新的,表里的 Provider 对象却是同一批,缓存就在这些对象里。开头的测试串扰就是这样发生的:每个容器「分别创建」,但单例的范围实际上是「这份配置对象」。
让每个容器拥有自己的缓存,就要在构建容器时按配方重新创建装饰器:
每个容器按配方重建 Provider:容器一 #2、再取一次 #2,容器二 #3这时单例的范围才是「一个容器」。两种都可以是合理的设计,关键是选一种并写进契约:配置是「绑定的描述」,还是「带状态的对象集合」。前者可以安全地构建多个容器;后者构建出的容器之间共享状态。
三、两个名字,一个实例还是两个
同一个实现用两个限定符登记时,是否共享实例取决于它们是否指向同一个装饰器:
两个限定符绑定同一个装饰器:@utc #4、@default #4;另一个装饰器 @local #5「别名」和「两个独立的单例」都是合理需求,但必须由登记 API 明确表达。如果登记一次多个限定符的写法在内部为每个限定符各包一层装饰器,使用者以为得到了别名,实际得到了两个实例。测试要分别断言「别名共享」与「独立绑定不共享」。
四、并发:判断与赋值之间的空隙
if (instance == null) instance = create() 不是原子的。两个线程同时第一次取值,都看到 null,都去创建:
两个线程同时第一次取值,不加锁:创建了 2 次,两个线程拿到不同的对象
加锁:创建了 1 次,两个线程拿到同一个对象实验用闸门让两个线程都进入创建过程,才稳定复现;真实环境里这个窗口很小,但只要创建过程慢一点(读配置、建连接)就会出现。后果不只是多创建一个对象:两个调用方拿到不同的「单例」,各自持有的状态从此分叉。
加锁之后还要注意两件事:锁的范围是单个装饰器,不要用容器级的全局锁把所有组件的创建串行化;创建过程中又去取另一个单例时,锁的顺序可能导致死锁,这正是为什么循环依赖要在配置期发现,而不是靠运行时碰运气。Java 里也可以用 ConcurrentHashMap.computeIfAbsent 或静态内部类持有者实现线程安全的延迟初始化,各有适用范围。
五、null 与失败:缓存什么
用 instance == null 表示「还没创建」,隐含了一个假设:创建结果不会是 null。
工厂合法地返回 null,取 3 次:用 instance==null 判断时工厂被调用 3 次;用独立的 created 标志时 1 次如果工厂可能合法地返回 null(例如可选的组件),就要用独立的标志区分「未创建」与「创建结果为 null」;否则每次取值都会重新创建,单例名不副实。反过来,如果容器规定不允许 null,就应该在创建后立即报错,而不是悄悄重试。
创建失败是另一个要决定的问题:
第一次创建抛异常(失败:配置未就绪),第二次取值得到 ready,工厂共被调用 2 次(失败没有被缓存)不缓存失败,意味着依赖暂时不可用时下次还能恢复,但每个调用方都会触发一次昂贵的失败;缓存失败则相反。两种策略都要在契约里写明,并为它们各写一个测试。
六、装饰器必须转发依赖元数据
配置期的环检查靠每个 Provider 的 deps()。装饰器如果没有把这个方法转发给被包装的对象:
装饰器不转发依赖元数据,检查 a ↔ b 的环:通过
装饰器转发依赖元数据:循环依赖:a → b → a被包装之后,组件在依赖图里「消失」了,环检查漏掉了真实的环,问题推迟到运行时才暴露。这是装饰器的一个通用要求:除了它要改变的行为,其余契约都要原样透传。依赖图怎样用统一的身份做检查,见依赖注入容器里,「同一个组件」由什么决定。
七、写进契约的几件事
| 问题 | 需要明确的选择 |
|---|---|
| 单例在什么范围内唯一 | 配置对象、容器,还是进程 |
| 多个名字登记同一实现 | 别名共享,还是各自独立 |
| 并发首次取值 | 加锁粒度,是否允许重复创建 |
| 创建结果为 null | 允许并缓存,还是立即报错 |
| 创建失败 | 缓存失败,还是下次重试 |
| 装饰之后的元数据 | 依赖声明、类型信息是否透传 |
对象创建与生命周期讨论了「单例在哪个范围内唯一」的一般问题;Spring 的单例由容器持有,原型作用域只管创建不管销毁,见 Spring Bean 生命周期。
八、检查清单
- 说清楚单例的范围;需要多个隔离容器时,每个容器按配方创建自己的作用域对象。
- 别名与独立绑定分别有测试。
- 延迟初始化要么加锁,要么用语言提供的线程安全构造。
- 区分「未创建」与「创建结果为 null」;决定是否缓存失败。
- 装饰器透传依赖元数据,配置期检查仍能看到被包装的组件。
配套实验
- codesphere-labs/design/scope-cache-ownership:JDK 25.0.4.1:浅复制配置共享缓存、按配方重建、别名、无锁并发双重创建、null 歧义、失败不缓存、装饰器转发依赖元数据(验证记录)
参考资料
- Jakarta Dependency Injection 2.0 规范:Scope 与 Singleton
- Javadoc:ConcurrentHashMap#computeIfAbsent
- Erich Gamma 等,《Design Patterns》,Decorator 一章