Skip to content

单例在谁的范围里唯一:作用域装饰器与缓存所有权 ​

测试里为了隔离,每个用例都从同一份配置新建一个容器。结果一个用例改了时钟组件的状态,另一个用例也看到了。两个容器明明是分别创建的,「单例」却只有一个。

容器支持单例作用域时,常见的做法是给负责创建对象的 Provider 套一层装饰器:第一次调用时创建,之后返回缓存的实例。这个装饰器很短,却决定了几件不那么显眼的事:缓存归谁所有、两个名字是否指向同一个实例、并发时创建几次、创建失败或返回 null 时怎么办、依赖元数据是否还看得见。「单例」这个词本身不回答这些问题,它只有在说清楚「在什么范围内唯一」之后才有意义。

本文用一组极简的 Provider 与装饰器逐个验证这几件事。实验见文末。

浅复制配置按配方重建配置装饰器 clock容器一容器二两个容器都拿到 clock #1配方new Singleton(...)容器一:装饰器 A容器二:装饰器 B容器一 #2、#2;容器二 #3不加锁并发创建 2 次null 当未创建工厂调用 3 次创建失败不缓存,下次重试不转发 deps()环检查漏报写进契约:单例在什么范围唯一、别名是否共享、并发首次取值、null 与失败是否缓存、元数据是否透传装饰器只改变「何时复用」,其余契约原样透传
图 1 · 实测(JDK 25.0.4.1):缓存在装饰器对象里。配置浅复制出的两个容器拿到同一批装饰器,单例的范围其实是那份配置;按配方重建才让每个容器拥有自己的缓存。不加锁时两个线程同时第一次取值会创建两次;用 null 表示未创建,返回 null 的工厂每次都被调用;装饰器不转发依赖元数据,环检查就看不到被包装的组件。

一、职责拆分:怎样创建,与何时复用 ​

先把两件事分开:

java
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,构建容器时只复制这张表:

text
同一份配置浅复制出两个容器:容器一的 clock 是 #1,容器二的 clock 是 #1(两个容器共享一个缓存)

表是新的,表里的 Provider 对象却是同一批,缓存就在这些对象里。开头的测试串扰就是这样发生的:每个容器「分别创建」,但单例的范围实际上是「这份配置对象」。

让每个容器拥有自己的缓存,就要在构建容器时按配方重新创建装饰器:

text
每个容器按配方重建 Provider:容器一 #2、再取一次 #2,容器二 #3

这时单例的范围才是「一个容器」。两种都可以是合理的设计,关键是选一种并写进契约:配置是「绑定的描述」,还是「带状态的对象集合」。前者可以安全地构建多个容器;后者构建出的容器之间共享状态。

三、两个名字,一个实例还是两个 ​

同一个实现用两个限定符登记时,是否共享实例取决于它们是否指向同一个装饰器:

text
两个限定符绑定同一个装饰器:@utc #4、@default #4;另一个装饰器 @local #5

「别名」和「两个独立的单例」都是合理需求,但必须由登记 API 明确表达。如果登记一次多个限定符的写法在内部为每个限定符各包一层装饰器,使用者以为得到了别名,实际得到了两个实例。测试要分别断言「别名共享」与「独立绑定不共享」。

四、并发:判断与赋值之间的空隙 ​

if (instance == null) instance = create() 不是原子的。两个线程同时第一次取值,都看到 null,都去创建:

text
两个线程同时第一次取值,不加锁:创建了 2 次,两个线程拿到不同的对象
加锁:创建了 1 次,两个线程拿到同一个对象

实验用闸门让两个线程都进入创建过程,才稳定复现;真实环境里这个窗口很小,但只要创建过程慢一点(读配置、建连接)就会出现。后果不只是多创建一个对象:两个调用方拿到不同的「单例」,各自持有的状态从此分叉。

加锁之后还要注意两件事:锁的范围是单个装饰器,不要用容器级的全局锁把所有组件的创建串行化;创建过程中又去取另一个单例时,锁的顺序可能导致死锁,这正是为什么循环依赖要在配置期发现,而不是靠运行时碰运气。Java 里也可以用 ConcurrentHashMap.computeIfAbsent 或静态内部类持有者实现线程安全的延迟初始化,各有适用范围。

五、null 与失败:缓存什么 ​

用 instance == null 表示「还没创建」,隐含了一个假设:创建结果不会是 null。

text
工厂合法地返回 null,取 3 次:用 instance==null 判断时工厂被调用 3 次;用独立的 created 标志时 1 次

如果工厂可能合法地返回 null(例如可选的组件),就要用独立的标志区分「未创建」与「创建结果为 null」;否则每次取值都会重新创建,单例名不副实。反过来,如果容器规定不允许 null,就应该在创建后立即报错,而不是悄悄重试。

创建失败是另一个要决定的问题:

text
第一次创建抛异常(失败:配置未就绪),第二次取值得到 ready,工厂共被调用 2 次(失败没有被缓存)

不缓存失败,意味着依赖暂时不可用时下次还能恢复,但每个调用方都会触发一次昂贵的失败;缓存失败则相反。两种策略都要在契约里写明,并为它们各写一个测试。

六、装饰器必须转发依赖元数据 ​

配置期的环检查靠每个 Provider 的 deps()。装饰器如果没有把这个方法转发给被包装的对象:

text
装饰器不转发依赖元数据,检查 a ↔ b 的环:通过
装饰器转发依赖元数据:循环依赖:a → b → a

被包装之后,组件在依赖图里「消失」了,环检查漏掉了真实的环,问题推迟到运行时才暴露。这是装饰器的一个通用要求:除了它要改变的行为,其余契约都要原样透传。依赖图怎样用统一的身份做检查,见依赖注入容器里,「同一个组件」由什么决定。

七、写进契约的几件事 ​

问题需要明确的选择
单例在什么范围内唯一配置对象、容器,还是进程
多个名字登记同一实现别名共享,还是各自独立
并发首次取值加锁粒度,是否允许重复创建
创建结果为 null允许并缓存,还是立即报错
创建失败缓存失败,还是下次重试
装饰之后的元数据依赖声明、类型信息是否透传

对象创建与生命周期讨论了「单例在哪个范围内唯一」的一般问题;Spring 的单例由容器持有,原型作用域只管创建不管销毁,见 Spring Bean 生命周期。

八、检查清单 ​

  • 说清楚单例的范围;需要多个隔离容器时,每个容器按配方创建自己的作用域对象。
  • 别名与独立绑定分别有测试。
  • 延迟初始化要么加锁,要么用语言提供的线程安全构造。
  • 区分「未创建」与「创建结果为 null」;决定是否缓存失败。
  • 装饰器透传依赖元数据,配置期检查仍能看到被包装的组件。

配套实验

参考资料

文章以 CC BY-NC-SA 4.0 授权 · 代码片段以 MIT 授权