设计模式 E04:模式不证明线程安全
Singleton、Flyweight、Observer、State 都可能和共享状态放在一起。模式名称只能说明对象如何协作,不能说明多个线程同时进入时会发生什么。并发正确性要看发布、原子性、可见性和生命周期,而不是看类名里有没有 Registry、Cache 或 Bus。
用屏障制造可复现交错
JDK 21 的 CyclicBarrier 文档说明,它允许一组线程互相等待,直到所有线程都到达公共屏障点。labs/E04 用两个屏障控制两条线程:先一起进入 registry.get("shop"),再一起停在工厂内部。这样未同步 HashMap 注册表会看到两次“缓存为空”,从而为同一个租户创建两个对象。
1 | |
unsynchronizedRegistryCanCreateTwoObjectsForTheSameKey 断言创建次数为 2,并且两个返回对象不是同一身份。这里没有 sleep,也不依赖机器快慢;失败来自明确控制的 interleaving。
并发实验不要用睡眠赌时序。用屏障、闩锁或可控执行器固定交错,断言共享状态的最终结果。
并发集合解决的是具体边界
AfterRegistry 使用 ConcurrentHashMap.computeIfAbsent。Oracle 文档说明,ConcurrentHashMap 支持高并发更新;对给定 key 的更新与读取之间有 happens-before 关系。测试让两个线程同时请求同一个 key,最终只创建一个对象,两个调用拿到同一身份。
这并不意味着 ConcurrentHashMap 能让所有逻辑自动线程安全。映射函数仍然不应依赖不受保护的外部可变状态;跨多个 key 的不变量也不会因为换了集合就变成原子事务。本例只证明“同一个 key 的惰性注册”这个边界被保护住了。
Observer、State 和生命周期要分别证明
ObserverBus 用 CopyOnWriteArrayList 保存订阅者,退订后不会收到后续事件,但这不等于事件内容安全。observerUnsubscribeDoesNotProtectSharedPayload 让第一个订阅者修改 Payload.tags(),第二个订阅者随后看到同一个 payload 已被改过。Observer 只说明发布者和订阅者如何协作,不能替共享数据做防御性复制。
NonAtomicOrderState 更直接:两条线程都先读到草稿态,再同时写入已确认态,结果两次确认都返回成功。SynchronizedOrderState 的对照断言只有第一次确认成功。State 模式表达状态迁移的结构;迁移是否原子,要看锁、CAS 或单线程化执行边界。
EventBus 用单线程执行器提交事件,close 后拒绝新事件。eventBusLetsInFlightEventFinishDuringCloseAndRejectsNewEvents 让事件先进入执行体,再在事件释放前调用 close:新事件被拒绝,已经在途的事件仍能完成。本章证明的是“关闭后不接新任务、在途任务可排空”的合同,不证明取消、超时等待或线程池泄露检测。
sequenceDiagram
participant T as 测试线程
participant B as EventBus
participant E as 单线程执行器
T->>B: publish(event)
B->>E: 提交在途事件
E-->>T: eventStarted屏障
T->>B: close()
B->>E: shutdown()
T->>B: publish(newEvent)
B-->>T: IllegalStateException("closed")
T-->>E: releaseEvent屏障
E-->>T: 在途事件完成
| 问题 | 错误直觉 | 可验证边界 |
|---|---|---|
| 同 key 缓存 | “注册表天然只会有一个” | 创建次数和对象身份 |
| Observer 事件 | “退订就安全了” | 后续派发停止;payload 仍可能共享 |
| State 迁移 | “State 管状态” | 迁移是否原子、是否可见 |
| 事件发布 | “close 就都停了” | 关闭后拒绝;在途事件是否完成 |
模式说明结构,JDK 并发类说明内存和原子性边界。两者要同时成立,代码才有线程安全结论。
验证与练习
运行 ./mvnw -B -ntp -pl labs/E04 -am test;ConcurrentPatternTest 通过 5 个测试,全部使用 CyclicBarrier、Future.get() 或直接退订句柄控制时序。原始日志在 examples/design-patterns/evidence/E04/targeted.stdout.txt。
- 把
Payload改成构造时复制、访问时返回不可变视图,先保留退订测试,再新增“第一个订阅者不能污染第二个订阅者”的回归。 - 把 registry 扩展为“同租户下有两个相关对象必须一起创建”。写出跨 key 不变量的失败测试,再判断
ConcurrentHashMap是否足够,还是需要更粗的锁或单 key 聚合对象。
参考资料:
- Oracle Java SE 21 API:CyclicBarrier。
- Oracle Java SE 21 API:ConcurrentHashMap。
- Oracle Java SE 21 API:ExecutorService。
上一节:E03 默认行为和规则组合;下一节:E05 依赖注入容器、动态代理与 AOP。




