观察者模式有哪些线程安全和内存泄漏问题?
简化版
观察者模式的线程安全问题主要来自观察者列表并发注册、移除和通知;内存泄漏问题主要来自观察者注册后没有取消订阅,导致被主题长期引用无法回收。解决思路是使用线程安全集合、通知时快照复制、明确取消订阅、弱引用或生命周期管理。
详细版
观察者模式常见并发问题:
- 通知时另一个线程添加或删除观察者,导致并发修改异常;
- 多线程同时注册造成观察者列表状态不一致;
- 观察者执行慢或阻塞,影响其他观察者;
- 观察者抛异常导致通知链中断;
- 异步观察者共享状态引发竞态。
常见内存泄漏问题:
- Subject 持有 Observer 强引用;
- Observer 生命周期本该结束,但没有取消注册;
- Subject 生命周期更长,导致 Observer 无法被 GC。
解决方式包括:
- 使用
CopyOnWriteArrayList等线程安全集合; - 通知前复制观察者快照;
- 提供并调用
detach/unsubscribe; - 对短生命周期观察者使用弱引用;
- 在组件销毁时统一取消订阅。
完整版教学
一、为什么观察者模式容易出并发问题
观察者模式通常会维护一个观察者列表:
private final List<Observer> observers = new ArrayList<>();
如果多个线程同时操作这个列表,就会有风险。
例如一个线程正在遍历通知:
for (Observer observer : observers) {
observer.update(event);
}
另一个线程同时添加或删除观察者,就可能出现 ConcurrentModificationException,或者出现通知遗漏、重复通知等问题。
二、观察者列表要考虑线程安全
常见处理方式有几种。
第一,使用线程安全集合:
private final List<Observer> observers = new CopyOnWriteArrayList<>();
CopyOnWriteArrayList 适合读多写少的场景。观察者注册和移除不频繁,通知遍历很频繁时,它很合适。
第二,通知前复制快照:
List<Observer> snapshot;
synchronized (this) {
snapshot = new ArrayList<>(observers);
}
for (Observer observer : snapshot) {
observer.update(event);
}
这样通知过程中即使原列表变化,也不会影响当前遍历。
三、观察者异常要隔离
如果一个观察者抛异常,是否要影响其他观察者?这是设计时必须明确的问题。
如果直接这样写:
for (Observer observer : observers) {
observer.update(event);
}
第一个观察者抛异常,后面的观察者可能都不会执行。
更稳的做法是按业务需求隔离异常:
for (Observer observer : observers) {
try {
observer.update(event);
} catch (Exception e) {
log.error("observer failed", e);
}
}
当然,如果某些观察者失败必须阻断主流程,也可以让异常继续抛出。关键是要有明确语义。
四、内存泄漏来自长期强引用
观察者模式的内存泄漏很经典。
假设一个长生命周期的 Subject 持有短生命周期 Observer:
全局事件源 -> 页面组件监听器
页面组件关闭后,如果没有取消监听,全局事件源仍然引用它。垃圾回收器会认为它还可达,于是无法回收。
这在 GUI、移动端、前端事件监听、长生命周期服务中很常见。
解决方法:
- 在组件销毁时取消订阅;
- 使用弱引用保存观察者;
- 由容器统一管理监听器生命周期;
- 避免匿名监听器无法移除。
五、异步观察者还要注意共享状态
如果观察者异步执行,问题会更多。
例如多个观察者共享同一个可变对象,或者同一个观察者被多个事件并发调用,都可能产生竞态条件。
解决方式包括:
- 事件对象尽量不可变;
- 观察者内部不要保存请求级状态;
- 必要时加锁或使用线程安全结构;
- 不同类型任务使用隔离线程池;
- 对重复事件做幂等处理。
六、订阅列表的并发与生命周期
Subject 持有 1000 个监听器,每个连带引用 100 KB 对象图,就可能阻止约 100 MB 内存回收。
register/unregister <-> observer list snapshot -> notify; strong reference -> retained graph
这个推演把模式带来的收益和成本放在同一条调用链上。设计评审时既要确认结果正确,也要确认额外层次没有改变原有契约。
七、边界、代价与验证
| 检查维度 | 应确认的内容 |
|---|---|
| 正确性 | 最可靠的做法是返回可关闭订阅句柄,并由组件生命周期显式注销。 |
| 适用边界 | CopyOnWriteArrayList 适合读多写少,频繁订阅变更时复制成本高;弱引用能缓解滞留但可能让监听器意外消失。 |
| 测试证据 | 覆盖正常路径、异常路径、空值或未知类型,并断言调用次数与顺序 |
| 运行成本 | 记录额外对象、调用层数、延迟、线程或内存开销,不用模式名称代替测量 |
模式落地后要用单元测试验证独立职责,用集成测试验证对象装配和真实调用入口。涉及并发、远程或异步时,还要补充竞态、超时、重复执行与资源释放测试。
易错点:最可靠的做法是返回可关闭订阅句柄,并由组件生命周期显式注销。
八、常见误区与追问
- 误区:把观察者改成弱引用就彻底解决泄漏。 弱引用改变存活语义且清理时机不确定,仍需移除失效引用和管理订阅生命周期。
- 误区:发布事件天然等于异步和最终一致性。 观察者只定义一对多通知关系;同步还是异步、是否跨进程以及一致性语义都由具体实现决定。
- 误区:示例代码能运行,就代表模式边界设计正确。 能运行只证明一条路径,仍要检查异常、并发、顺序、生命周期以及新增实现时是否修改稳定代码。
- 追问:通知期间允许注销监听器吗? 可以用快照迭代或适当并发容器定义清晰语义,避免直接修改正在遍历的普通列表。
- 追问:观察者失败后由谁重试? 模式本身没有答案;同步场景要定义异常传播,异步场景要由执行器、事件总线或消息系统提供重试与死信策略。
- 追问:怎样验证这套设计没有改变原有语义? 先做基线行为测试,再对新增层验证返回值、异常类型、调用次数和顺序;性能敏感处还要比较改造前后的量化指标。
九、加强记忆
观察者模式的线程安全重点在“观察者列表并发修改”和“观察者执行隔离”;内存泄漏重点在“注册后忘记取消订阅”。工程上要用线程安全集合或快照遍历,组件销毁时取消监听,异步场景再补上幂等和状态隔离。