观察者模式真正的价值,不在于面试里回答一句“对象间一对多依赖”,而在于当你的业务代码被一次次“加通知”加成一团乱麻时,它能不能帮你把变化重新收敛起来。我在一次消息推送系统改造里亲历过:一个订单状态变更方法,后面挂了十几个 if 分支和零零散散的方法调用,每次需求变更,都要在核心类里小心翼翼地再插一脚。后来把这段逻辑抽成观察者,新增一种通知只是追加一个订阅者,老代码一行都不用动。这种变化,比看十遍定义都深刻。这篇文章我会用 Java 的视角来拆观察者模式,包括问题背景、角色分工、JDK 与 Spring 事件等实现方式,以及真实业务里特别容易踩的内存泄漏和回调顺序坑。新手可以跟着代码跑一遍,老手可以直接跳到第四章看事故复盘。
1. 观察者模式到底在解决什么问题:一段疯涨的“通知代码”复盘
1.1 轮询、硬编码通知与越长越长的 if 分支
很多设计模式教程上来就甩定义,但我始终觉得,观察者模式这种“看起来太简单”的模式,不结合具体场景根本看不出价值。我见过太多团队把它背下来应付面试,代码里却一直用最硬编码的方式处理状态通知。
假设我们有一个订单服务,最初只有两件事:改状态、保存。用户打开订单页,前端轮询接口看到新状态。轮询的缺点很快暴露:拉取周期中间的延迟摆在那里,用户量上来以后,大量请求其实什么都没拉到。于是产品要求:状态一变化就主动通知用户、刷新商家后台、发短信给买家。
初级项目会直接把通知逻辑塞进核心方法:
public void changeOrderStatus(Order order, OrderStatus newStatus) { order.setStatus(newStatus); orderRepository.save(order); userNotifier.notify(order.getUserId(), "订单已更新为 " + newStatus); dashboardService.refresh(order.getStoreId()); smsService.send(order.getUserName(), "您的订单状态发生变化"); reportService.update(order); }第一次加还好,第二次加第三个状态,这个方法的行数就失控了。等业务再复杂一点,还会出现“哪些通知组合发送”的判断,于是核心业务类里全是 if 嵌套和调用列表。这段代码有几个致命的问题:
- 新增通知必须改核心方法,核心类慢慢知道所有业务模块的细节。
- 通知执行顺序被写死,多个通知之间天然有了隐式耦合。
- 测试困难,单测订单服务的时候还得模拟一堆无关模块。
- 最关键的是,你永远不知道再给这段方法加一个通知,会碰断哪个老功能。
1.2 观察者模式为什么能拆掉这堆胶水代码
观察者模式解决的就是这种“一个状态变化,周围一堆东西要跟着动”的耦合问题。我习惯把它理解成“插座与插头”:订单状态类是墙上的插座面板,它只需要提供一个插孔(订阅方法),任何新设备来了自己插上,插座面板不需要知道设备是什么。状态变化时,面板广播事件,所有插着的设备各自干活。
用这种方式,核心业务代码从“主动打电话给每个模块”变成“发布一条广播”。新增通知时,核心代码不动,只增一个新监听器。这个差异,在需求频繁变动的项目里就是天壤之别:一个是每次都要心惊胆战地改核心类,一个是写个新类再挂上去。
这里也有一个经常被误解的点:观察者模式不是用来“提速”的。从 CPU 角度看,直接调用比通过观察者分发更快,毕竟多了循环遍历、方法分派,还可能夹带同步控制和异常捕捉。它的价值不是降低单次请求延迟,而是降低系统后续的变更成本。我见过一个团队把观察者模式当性能优化方案引入支付回调,结果事件分发器成了新热点,方向完全错了。性能优化应该用流水线或异步队列,而不是这种一对多广播。
同样,观察者模式也解决不了“业务要 100% 事务一致”的问题。如果在事务提交前发布事件,一个观察者失败可能把主流程一起带崩;如果在事务提交后再发布,观察者读到新状态时可能已经晚于并发操作。这个时机问题很关键,后面踩坑部分会详细展开。
2. 核心角色与接口设计:别把 Subject 写成上帝对象
2.1 三个角色的职责边界
观察者模式有三个天然的角色:
- Subject(被观察者/主题):持有并维护观察者列表,状态变化时负责广播事件。它要做的事其实很窄,订阅、退订、通知,仅此而已。
- Observer(观察者/监听器):实现统一的回调接口,每个具体监听器自己决定收到事件后干什么。
- Event(事件对象):描述刚才发生了什么。通常是一个不可变的数据载荷,包含变化来源、变化键值和时间等信息。
我在 code review 里最常见的毛病,是有人把 Subject 当成“大管家”,所有业务规则、状态判断、通知条件全塞进去。Observable 本来只应该解决通知链路,它不需要知道每个观察者内部在干嘛,更不应该代替它们做业务判断。业务判断应该在服务层完成,状态变化后调用 Subject 的广播方法就够了。
同样的毛病也发生在事件设计上。很多初学实现的监听接口是void update(String message),然后消息里塞一串拼好的字符串。表面看挺灵活,实际上监听器做不到类型安全,解析字符串本身就是新耦合。我倾向于每个业务场景定义一个事件类,哪怕字段多几个,可读性和扩展性都比“万能字符串”好得多。
2.2 接口定义的三个关键选择
第一个选择是回调方法命名。JDK 老接口叫update(Observable o, Object arg),语义含糊;后来的框架更多用onEvent、handle或accept。只要团队统一,哪个都行,但我推荐onEvent,看到名字就知道是“事件来了”。
第二个选择是事件参数要不要带整个 Subject 引用。不推荐。观察者拿到 Subject 后很容易反向修改业务状态,引发第二次广播,形成循环通知。如果确实需要当前对象信息,把所需字段复制进 Event 对象,而不是把整个大对象引用丢出去。
第三个选择是函数式接口与自定义接口的取舍。Java 8 之后,直接使用Consumer<T>就能当观察者回调:
subject.subscribe(event -> System.out.println("收到事件:" + event));简单场景没问题,但一个监听器如果包含多个方法相(比如 onBefore、onAfter)或者需要上下文清理,自定义接口更清晰。我的建议是:观察者列表规模小、逻辑简单时用函数式;业务回调复杂时改专用接口。
下面是三个角色的骨架:
public interface EventListener<E> { void onEvent(E event); } public class SimpleSubject<E> { private final List<EventListener<E>> listeners = new CopyOnWriteArrayList<>(); public void subscribe(EventListener<E> listener) { listeners.add(listener); } public void unsubscribe(EventListener<E> listener) { listeners.remove(listener); } public void publish(E event) { for (EventListener<E> listener : listeners) { listener.onEvent(event); } } }这个版本已经能跑通基础流程,但注意,publish里目前没有任何异常处理。这不是小问题,后面踩坑部分会专门提,在同步观察者链路里,异常处理几乎是第一批要补的东西。
3. Java 实现观察者模式的三种常见形态:JDK、手写、Spring 事件
3.1 JDK 自带方案:Observer 与 Observable 为什么该被放弃
Java 很早就内置了观察者模式支持:java.util.Observer接口和java.util.Observable类。用起来很简单,继承Observable,调用setChanged()标记状态变化,然后notifyObservers(arg)触发所有 Observer 的update方法。
但它在 Java 9 开始被标记为 deprecated,官方明确建议不要在新代码中使用。原因从接口设计就能看出来:
Observable是一个类,Java 是单继承,你的业务类如果已经继承了别的类,就无法再继承它,这是最致命的硬伤。- 接口没有泛型,
Observer.update(Observable o, Object arg)里的 arg 是 Object,不同模块传参全靠强转,类型安全为零。 - 不提供结构化的事件对象,只有
(Observable, Object)二元组,事件表达力很弱。 - 没有同步控制策略,多线程场景需要自己额外处理。
- 底层用 Vector 存观察者,虽然是线程安全列表,但性能早已落后于现代集合。
所以我在新项目里不会考虑 JDK 自带方案,它适合当学习源码的标本,不适合生产环境。
3.2 手写实现:从“能跑”到“能在生产环境跑”
不引入第三方框架时,手写一个类似SimpleSubject的类完全够用。但“够用”要补几个工程细节,否则生产环境会出幺蛾子。
第一点是列表选择。骨架用了CopyOnWriteArrayList,为什么不用ArrayList?因为遍历可能持续一段时间,期间另一个线程执行subscribe或unsubscribe,会抛出ConcurrentModificationException。CopyOnWriteArrayList在每次修改时复制底层数组,遍历用的都是快照,读多写少的场景非常适合观察者广播。代价是写入成本高,而观察者注册和退订相对低频,这个代价完全可接受。
第二点是异常隔离。事件分发循环里如果某个监听器抛出RuntimeException,默认会中断整个遍历,异常冒泡到发布方主流程。很多业务事故就是这么来的:支付成功回调里,一个发短信的监听器空指针异常,导致订单状态更新流程整体回滚。所以生产代码里几乎总要在循环里做异常捕获,记录错误日志后继续分发:
public void publish(E event) { for (EventListener<E> listener : listeners) { try { listener.onEvent(event); } catch (Exception ex) { log.error("事件 {} 分发到监听器时异常", event, ex); } } }至于要不要吞掉异常,取决于系统对“通知可靠性”的要求。某个观察者如果必须“尽力而为”,捕获后继续没问题;如果某个观察者失败会影响主流程,那它就不该做成同步观察者,应该放进事务性消息或异步任务里。
第三点是执行顺序。基础的遍历按注册顺序依次调用,但这不应该是业务依赖的依据。线程调度、异步线程池、容器排序都可能影响最终顺序。如果有强顺序依赖,比如“先做风控检查再发短信”,建议把这种依赖放进同一个观察者内部,不要期待两个观察者之间维持神秘顺序。
3.3 Spring 事件:实际项目里更常见的“观察者增强版”
Spring 框架提供了ApplicationEvent和ApplicationEventPublisher,对外是事件发布器,内部用观察者模式维护监听器。使用流程很简单:注入发布器,调用publishEvent;在监听方法上加@EventListener注解:
@Service public class OrderService { private final ApplicationEventPublisher publisher; public void changeStatus(Long orderId, OrderStatus newStatus) { orderRepository.updateStatus(orderId, newStatus); publisher.publishEvent(new OrderStatusChangedEvent(orderId, newStatus)); } } @Component public class SmsListener { @EventListener public void onOrderStatusChanged(OrderStatusChangedEvent event) { smsClient.send(event.getOrderId(), event.getNewStatus()); } }Spring 把“发布者只知道发布事件,不知道谁在听”这件事做到了极致,业务模块之间没有直接依赖,新增监听器也无需改已有代码。它还提供了@TransactionalEventListener(phase = AFTER_COMMIT),可以在事务提交后才触发事件,避免“观察者读到还没提交的数据”这种尴尬。
它的代价是接受“隐式调用”:读代码时只看到publishEvent,看不到具体执行逻辑,必须通过事件名去全局搜索监听器。搜索工具对这种隐式调用还算友好,但和显式调用相比仍是认知负担。要不要用 Spring 事件,我的判断标准是:如果模块间本来就有清晰调用关系,用普通方法调用更直观;如果要解耦业务域(订单域到消息域),用事件更合理。
4. 踩坑实录:内存泄漏、异常污染与“幽灵通知”
4.1 事件消息泄漏:注册了却忘了销毁
观察者模式最经典的一类 bug 是“注册后不解除”。它的危害不只是内存泄漏,更麻烦的是“幽灵通知”:一个已经离开页面、离开业务流程的对象,依然在接收事件并执行不该执行的动作。
举个移动端的例子。Android 页面在onCreate里注册了一个全局订单监听器,用来刷新页面上的订单状态。如果onDestroy里没有unsubscribe,页面对象就会被事件分发器一直持有。用户反复进入离开,页面实例越积越多,最终内存溢出。就算没溢出,下一次订单状态变化时,所有残留页面都会触发刷新,界面出现明显卡顿和重复请求。
修复思路有几种。最直接的是手动unsubscribe,在生命周期销毁点统一解绑。第二种是用弱引用,但弱引用带来不确定性,GC 时机不好预测,不建议作为唯一手段。第三种是借助生命周期感知组件,比如 Android 里把注册动作和Lifecycle绑定,销毁时自动清理。
服务端也有对应场景。长连接网关、缓存组件、线程池任务里注册了静态单例监听器,如果不清理,监听器会越积越多。所以凡是订阅,一定要考虑“什么时候退订”。我 reviewing 代码时看到subscribe,第一个问题永远是:对应的unsubscribe在哪里?
4.2 异常污染:一个观察者抛异常,后面全趴窝
前面提到过,JDK 自带的notifyObservers和很多入门教程的手写实现,都没有对分发循环做异常保护。一旦某个观察者抛出RuntimeException,循环立刻中断,后面的观察者收不到通知,异常还会冒泡到发布方。
我经历过一次线上事故:订单支付成功后,订单服务调用发布器,监听器里有发短信、发邮件、更新统计报表三个观察者。某个观察者对订单号解析时碰到一条特殊历史数据的空指针,异常一路冒到支付回调入口,导致那条订单实际支付成功,但本地状态回滚成“未支付”。用户下单页面一直显示待支付,银行却扣了款。
复盘之后,问题不只是空指针本身,更是“观察者异常不应该阻断主流程”的架构原则没落地。解决方法双向:发布方在分发循环里捕获并记录异常,不中断后续监听器;观察者内部也尽量自洽,对边界情况做好校验,不把空数据当正常业务继续。
不过这里有一条非常重要的边界:如果监听器和主流程强相关,比如“更新订单状态”和“发送支付凭证”必须严格绑定,那么捕获异常会掩盖业务失败。这种情况应该把事件放入消息队列,用可靠投递加重试来保证,而不是在同步观察者里做尽力而为。
4.3 顺序依赖与循环通知
观察者模式里,观察者之间默认没有顺序约束。手写实现按注册顺序执行,Spring 事件也有自己的排序规则,但这都是实现细节,不是契约。如果两个观察者间真的有先后依赖,正确做法是合并成一个观察者,或者给事件对象增加阶段字段,让一个观察者内部依次处理。
循环通知则是另一个隐蔽问题。观察者 A 收到事件后修改了 Subject 状态,这个修改再次触发广播;另一个观察者 B 又改状态,又一次广播。如果没有终止条件,事件风暴就会出现。常见场景是“订单状态变更引起库存变更,库存变更又触发订单状态重算”。
规避循环通知,工程上可以做三件事:
- 事件对象只保留必要数据,不让观察者随手拿到目标对象引用。
- 在回调开头判断“当前业务状态是否已满足条件”,不满足直接返回。
- 在发布器内部对同一次状态变更增加深度计数或事件去重,超过阈值告警并停止分发。
至少前两种应该做,因为从根源减少“回头路”比事后中断更可靠。
4.4 “模式用多了会变成蜘蛛网”
观察者模式的核心是解耦,但解耦不是免费的。事件一多,项目里到处都是publishEvent和@EventListener,新人读代码根本不知道一次操作会触发多少隐藏逻辑。调试时只能靠打断点去观察,遍历代码找不到全部调用链。
我自己的经验是有意识地限制使用范围:只对“一个状态变化需要通知不确定多方”的场景使用,而不是把所有方法调用都改成事件。如果两个模块之间存在清晰的一对一调用关系,直接调用比事件更好读、更好调试。架构设计的核心不是多用模式,而是在合适的地方用合适的模式,观察者模式同样逃不过这个原则。
5. 观察者模式和发布订阅的关系:一字之差,分工完全不同
5.1 观察者模式:目标对象直接拉观察者的手
传统观察者模式里,Subject 直接维护 Observer 列表,发布事件相当于遍历列表逐个调用。两者之间有明确的依赖:Observer 知道 Subject 有订阅接口,Subject 知道 Observer 实现了回调。这种结构适合进程内、同步、轻量通知,实现简单,出问题时也能快速定位谁在听。
5.2 发布订阅:中间人 Channel 接手
发布订阅模式在观察者和发布者之间插入了一个“频道”或“事件总线”。发布方只把事件放进频道,订阅方向频道注册,双方互不见面。它允许跨线程、跨进程,通过消息中间件还能支持分布式。Spring 事件实际上已经很接近发布订阅,因为发布方只依赖ApplicationEventPublisher,完全不知道具体监听器是谁;但它仍在同一 JVM 内分发,可靠性依赖 Spring 容器管理。
跨服务的订单状态同步就是典型的发布订阅场景:订单服务把“订单已支付”事件写进消息队列,库存服务、积分服务、物流服务各自订阅。订单服务不需要知道这些服务存不存在,也不需要关心它们是否处理成功,重试和补偿由消息队列的投递机制负责。这种可靠性保障,观察者模式本身给不了。
5.3 选型的时候怎么判断
下面这张表格是我做技术选型时常用的对照:
| 维度 | 观察者模式 | 发布订阅模式 |
|---|---|---|
| 通信方式 | 目标对象直接回调观察者 | 通过中间频道或事件总线间接通信 |
| 发布方与订阅方 | 订阅方知道目标对象 | 互不可见 |
| 线程模型 | 默认同步 | 可同步、可异步,常搭配消息队列 |
| 可靠性 | 无内置重试与补偿 | 可加持久化、重试、死信 |
| 适合场景 | 进程内轻量通知 | 跨模块、跨线程、跨服务解耦 |
| 调试成本 | 相对可读 | 隐式调用,需搜索事件名 |
一句话总结:观察者是“我知道你是谁,我通知你”;发布订阅是“我把消息放到柜台上,谁要谁拿”。业务里如果只是几个模块间解耦,观察者模式够用;如果要做异步解耦、削峰填谷、跨服务通信,直接上消息队列或事件总线。
6. 拿订单状态通知练手:从硬编码改成观察者的完整步骤
6.1 原始痛点与重构目标
假设原始代码是:订单支付成功后,需要发送邮件、发送短信、更新商家后台、记录审计日志。当前版本直接把这一串调用写在changeStatus里。现在新需求来了:支付成功后还要触发风控积分检查。按原有方式改,就是在核心方法里再加一行。如果后面再来一个优惠券核销、再来一个物流比价,这个方法就完蛋了。
重构目标定得很清楚:changeStatus方法里不再出现具体通知模块,所有“支付成功后的反应”通过观察者追加。
6.2 定义事件与发布器
先定义事件对象。注意它是不可变的,只携带和这次变化相关的数据:
public record OrderStatusEvent( Long orderId, OrderStatus oldStatus, OrderStatus newStatus, Instant occurredAt ) {}Java 16+ 的 record 很适合做事件对象,天然不可变。如果项目还在用老版本 Java,就老老实实写 private final 字段加构造函数和 getter。
6.3 实现监听器并注册
定义两个监听器,一个发短信,一个写审计日志:
public class SmsOnOrderStatusChanged implements EventListener<OrderStatusEvent> { private final SmsClient smsClient; public SmsOnOrderStatusChanged(SmsClient smsClient) { this.smsClient = smsClient; } @Override public void onEvent(OrderStatusEvent event) { smsClient.send(event.orderId(), event.newStatus()); } } public class AuditLogOnOrderStatusChanged implements EventListener<OrderStatusEvent> { private static final Logger log = LoggerFactory.getLogger(AuditLogOnOrderStatusChanged.class); @Override public void onEvent(OrderStatusEvent event) { log.info("订单状态变更审计: orderId={}, old={}, new={}", event.orderId(), event.oldStatus(), event.newStatus()); } }在应用装配处注册:
EventPublisher<OrderStatusEvent> publisher = new EventPublisher<>(); publisher.subscribe(new SmsOnOrderStatusChanged(smsClient)); publisher.subscribe(new AuditLogOnOrderStatusChanged());现在OrderService.changeStatus只需要三件事:改状态、存库、发布事件:
public void changeStatus(Long orderId, OrderStatus newStatus) { Order order = orderRepository.findById(orderId); OrderStatus oldStatus = order.getStatus(); order.setStatus(newStatus); orderRepository.save(order); publisher.publish(new OrderStatusEvent(orderId, oldStatus, newStatus, Instant.now())); }有人会觉得这多了一道“中间商”,代码文件也多了。但换来的是,接下来每新增一个“支付后要做的事”,都只需要新增一个监听器类并注册,订单服务完全封闭。这才是观察者模式最核心的收益:把变化从核心行为中分离出去,让扩展发生在旁路。
6.4 测试与收尾建议
单测至少要覆盖三块:发布器遍历逻辑、订单服务状态变更与事件发布、每个监听器自己的业务。监听器自身的业务测试不要只断言“事件对象不为空”,一定要针对具体状态去验证效果。
集成测试建议把事件缓冲到内存缓冲区,模拟真实发布路径,然后批量断言。如果用了异步分发,记得加条件等待,不要固定 sleep,否则很容易出现“测试偶尔过、偶尔不过”的情况。
如果用 Spring,重构就变成publishEvent加@EventListener,订单服务代码更干净,但要注意监听类是否被 Spring 扫描到,还要注意泛型事件在父子类继承时的分发差异。这些都容易在项目里埋下隐性 bug,测试时建议补一个“发布子类事件时父类监听器是否被调用”的用例。
最后分享一条我判断是否使用观察者模式的简单标准:如果这段业务以后会频繁新增“状态变化后的动作”,硬编码通知迟早会失控,观察者模式是刚需;如果这类通知很固定,三五年也不会变,那直接调用反而更简洁。观察者模式给你的是扩展的自由,同时也把隐式调用的成本转移给了后来的维护者,到底怎么选,取决于你更怕哪一种成本。