两个不相关的订单测试都要读货币配置。如果把可写的调用次数计数器塞进进程内的“唯一配置”,第二个调用者会读到第一个调用者留下的值;代码里看不见依赖注入,也难以隔离测试。需求究竟是“在一个明确范围内共用只读配置”,还是“一切场景全局共享可写状态”?

用身份断言限定结论

Before.INSTANCE 存可写 calls。原有反例在一个测试方法内观察计数留存;TestStatePollutionTest 进一步用明确的 JUnit 顺序执行两个测试方法:第一个记录基线并递增,第二个断言读到了前一个留下的值。这组有意依赖顺序的测试用于揭示污染,不作为普通业务测试的组织方式。显式传入两份配置值时不存在这条共享身份路径。

After.instance() 改用静态 Holder,实例保存不可变 CNY 字符串。20 次普通任务验证相同引用与币种;冷启动验证另起一个 JVM,避免前面的测试已经初始化实例。八个任务先在 CyclicBarrier 会合,再同时调用 instance(),结果为构造次数从 0 到 1,八个引用一致。没有使用 sleep 猜测线程交错。

1
2
Scenario.After first = Scenario.After.instance();
Scenario.After second = Scenario.After.instance();

Alternative 是显式创建的配置值,两次 new Alternative("CNY") 相等却不是同一引用。需要按请求、测试或租户切换币种时,直接传入这样的值更便于划清生命周期。Singleton 适用于严格确定作用域并且确实要求该范围一个实例的场景;若唯一性只是“在当前 DI 容器里每个服务一份”,不要偷换为 JVM、跨类加载器、跨进程甚至全局唯一。缓存也不因有唯一入口而自动获得并发安全。

方案 身份与状态 代价
Before 进程内此类加载器下共享可写计数 可能污染不相关调用者
After 此类加载器下共享只读配置 无法为不同调用者各配一份币种
Alternative 调用者显式传入每份配置 需要传递依赖,范围由调用者管理

初始化安全与可变状态是两项合同

Holder 首次被主动使用时才执行类初始化。JLS 21 的类初始化过程规定初始化锁及其他线程的等待规则;本例用这项语言机制发布实例,而不是自行编写双重检查锁。构造计数是教学观测点,不应成为生产单例的全局监控接口。

初始化完成只说明调用者得到了已经构造的对象。如果把计数器放回实例,再执行 calls++,对象身份唯一也不会把读、加、写自动变成原子操作。跨测试共享状态和多线程丢失更新是两个问题;本章完成前者与首次初始化实验,后者在 E04 用受控交错验证。

flowchart LR
    Threads[8 个冷启动调用方] --> Instance[After.instance]
    Instance --> Holder[Holder 类初始化]
    Holder --> One[不可变 After 实例]
    Tests[有序测试] --> Global[Before 可写全局 calls]

验证与练习

运行 ./mvnw -B -ntp -pl labs/20 -am test 和累计 ./mvnw -B -ntp verify;环境、退出码及原始输出在 examples/design-patterns/evidence/20/RUN.md。没有测跨类加载器复制、跨 JVM 唯一性、容器 scope 或并发计数正确性,这些均 NOT_RUN。

  1. 新增“两个租户分别为 CNY、USD”的变化;先写隔离断言,核对旧 CNY 调用仍成立,再选择显式传配置的方案并复跑合同。
  2. 为可写计数器增加并发递增需求,先编写可失败的数量断言,再说明仅验证实例身份为何不够;不要从一次通过推出线性一致性。

本次补全增加的实验在 examples/design-patterns/evidence/local-completion-20261003/RUN.md 留存本地命令与原始输出;各章原云端日志保留其历史版本边界。

参考资料

上一节:19 复制订单时共享了什么;下一节:21 接口转换如何保留语义。