深入 Spring 13:启停顺序、层级查找与失败后的资源终态
Bean 已经创建,工作线程是否已经可用
订单服务依赖一个后台通知 Worker。Worker 对象构造成功,并不意味着线程池已经启动;close 日志出现,也不意味着任务线程已经结束。对象存在、组件运行、资源终止是三个状态,需要各自的入口和证据。
Spring 的初始化回调处理对象准备,Lifecycle 提供显式 start/stop,SmartLifecycle 再增加自动启动、phase 和带完成回调的停止契约。把业务启动放在哪一层,会改变失败时点和依赖约束。本文用真实 ExecutorService,而不是只打印“启动/停止”的空方法,检查容器启动与关闭对资源的影响。生命周期官方契约
实验固定 Spring Framework 6.2.11、JDK 21,源码提交 4c134254642d88e058aa004bdaf44168e1be7bb2。完整代码位于 examples/spring-framework-lab/src/main/java/blog/spring/Chapter13.java。每个 Worker 持有独立线程池,start 提交一次任务并限时等待,stop 关闭并等待终止。没有数据库连接,资源释放结论限于这些实际线程池。
| 状态 | 观察依据 |
|---|---|
| 对象已创建 | 已登记并实例化的 Worker 引用 |
| 组件已启动 | start 任务完成后 running=true |
| 停止协议完成 | stop 完成资源关闭后调用 callback |
| 对象已销毁 | DisposableBean.destroy 标记 |
| 线程池已结束 | ExecutorService.isTerminated 为 true |
SmartLifecycle 在 refresh 的哪个位置启动
AbstractApplicationContext.finishRefresh() 初始化 LifecycleProcessor,并调用其 onRefresh。默认处理器的 onRefresh 调用 startBeans(true),只选择自动启动候选;成功后处理器才标记 running。此时常规非 lazy singleton 已创建,但业务生命周期的启动仍可能失败。Context 完成刷新入口
DefaultLifecycleProcessor.startBeans() 收集 Lifecycle Bean,按 phase 放入升序分组,依次启动。SmartLifecycle 的自动启动语义使它能在 refresh 中运行;普通 Lifecycle 不能直接套用相同的自动启动结论。实现中还会检查 isRunning,防止把已运行组件当成尚未启动组件重复启动。默认生命周期处理器
实验给 early 设置 phase=-10,late 设置 phase=10。refresh 后 trace 精确为 start:early、start:late,两个 Worker 的 running 都为 true。start 中的 Future.get 带五秒截止,这使“可用”具有本地判据:至少成功执行过一次提交到自身池中的任务。这个判据没有证明远端服务健康,也没有用一个字段代替所有业务就绪检查。
SmartLifecycle.start 没有返回业务 Future 的统一接口。如果实现只是提交异步初始化并马上返回,容器看到的启动结束也会早于真正业务准备完成。需要保留哪一级就绪保证,必须由 start 实现或额外 readiness 协议明确。实验选择有界等待,避免把这种差异隐藏在日志里。
停止 phase 反向执行,销毁发生在后面
正常关闭调用 LifecycleProcessor.onClose,停止运行组件,再进入 singleton 销毁。停止阶段按 phase 从高到低分组,所以 late 先停、early 后停。实验检查 trace 中 stop:late 在 stop:early 前面,也检查最后一个 stop 先于销毁回调。
对于 SmartLifecycle,stop(Runnable callback) 的 callback 表示这次组件停止已经完成。异步 stop 可以先发停止请求,完成后再调用 callback。若在资源尚未释放时提前调用 callback,容器等待的是一个错误的完成声明;若永不调用,则处理器只能依赖超时策略继续关闭。
实验的 Worker 用同步实现 stop:shutdownNow、awaitTermination 成功后设置 running=false,再调用 callback。destroy 记录销毁状态,并对仍处于 running 的组件补做停止。最终还直接断言两个 executor.isTerminated。这些断言分别覆盖停止协议、销毁回调和真实线程池终态,不能合并成一条“close 执行过”。
1 | |
destroy 作为兜底并不意味着任意失败都能自动恢复。对象构造过程中打开资源后直接抛错,容器可能从未获得可供销毁的完整实例;start 在把资源保存到字段前失败,也需要方法自身清理。实验的 start 捕获提交或等待异常后立即 shutdownNow,成功路径才标记 running,责任在获取资源的同一方法中闭合。
模式提炼:用终态验证停止协议
资源管理可以写成 requestStop → awaitStopped → destroyOwner。线程池看终止状态,连接看关闭或池归还,网络服务器看接受连接是否停止及活动请求是否排空。callback 是协议中的信号,最终资源状态是信号是否真实的证据。两个检查同时存在,才能发现“回调很快,但后台线程仍活着”的实现错误。
dependsOn 可以覆盖 phase 的表面顺序
第二组实验故意设置 dependent 的 phase=-10,dependency 的 phase=10,再给 dependent 的定义设置 dependsOn(“dependency”)。若只按数字排序,dependent 应先启动;实际 trace 为 start:dependency、start:dependent。
原因在 doStart():准备启动某个 Bean 时先读取它登记的依赖,递归启动依赖,再启动自身。停止路径相反,先停止依赖于当前对象的组件,再停止当前对象。phase 为分组提供顺序,显式依赖关系给出更强的约束。启动和停止的依赖递归
关闭后,实验确认 stop:dependent 先于 stop:dependency,且两个池都终止。两个方向必须一起检查;只验证启动顺序,仍可能在关闭时让上游组件访问已经结束的依赖。
上游 DefaultLifecycleProcessorTests 包含 dependencyStartedFirstEvenIfItsPhaseIsHigher 和 dependentShutdownFirstEvenIfItsPhaseIsLower,固定了这个优先关系。phase 的数值约定应表达组件分组,而不是要求业务在多个类中记住一套偶然的注册顺序。生命周期上游测试
父子容器按名称查找时的方向
父容器有 route=“parent” 和 parentOnly=42,子容器有 route=“child” 与 childOnly=7。子容器查 route 返回 child,父容器查 route 仍返回 parent。子定义遮蔽同名父定义,没有修改父容器保存的实例。
AbstractBeanFactory.doGetBean() 在本地无法找到相应定义时才考虑委托父工厂。查找沿 child 到 parent 方向前进;parent 不知道某个 child 的专属定义。因此 child 能取得 parentOnly,并与父查询结果保持引用相同,parent 查询 childOnly 却抛 NoSuchBeanDefinitionException。按名称查找与父工厂委托
containsBean 会考虑层级可见性,containsLocalBean 只检查本地。实验对 parentOnly 同时得到 true 与 false,两个 API 回答的问题不同。排查重复对象时应把所属容器一起记录,不能只看 Bean 名称相同就当成同一个定义被覆盖。
本文验证的是按名称获取。按类型列举、依赖候选合并、事件向父容器传播都有自己的实现入口,不能从这个例子直接推出所有层级 API 都采用完全相同遍历方式。尤其父 Bean 的内部依赖通常在父工厂创建时已经解析,子容器同名 Bean 不会反向改写父 Bean 的字段。
子容器先 close 后,父容器仍 active,route 仍可查询。父子关系提供查找路径,不意味着 child 拥有 parent 的生命周期。实际 Web 应用中需要明确根容器与子上下文分别由谁创建、谁关闭,避免把一个局部服务的关闭扩散到共享父容器。
启动失败后,必须确认已启动资源已结束
最后一组实验先启动 phase=0 的 survivor,再启动 phase=100 的 Failing。Failing.start 抛受控 IllegalStateException,DefaultLifecycleProcessor 将失败传播为 ApplicationContextException。onRefresh 捕获该异常时先 stopBeans,尝试停止已启动的组件,然后重新抛出。
外层 refresh 捕获运行时异常后 destroyBeans,取消 active 状态并继续抛出。实验因此能同时观察到:refresh 失败、Context 不再 active、survivor.running 为 false、线程池已终止、destroy 标记为 true。失败后的 trace 为 start:survivor、stop:survivor、destroy:survivor。
上游 singleSmartLifecycleAutoStartupWithFailingLifecycleBean 检查先前启动对象在 refresh 失败后不再运行。本文把这个契约扩展成真实线程池终止断言,避免实现仅把 running 字段改成 false 却遗留线程。上游源码与测试已阅读,本地运行的是独立实验,不等于执行过上游构建。
这条证据不覆盖强制杀进程、永不响应中断的任务或外部服务不可达。shutdownNow 发出中断请求,真正终止仍取决于任务合作;awaitTermination 的返回值必须检查。本文任务为空操作,适合观察框架调用顺序,不能用它证明任意生产任务都能在五秒内关闭。
运行、反例题与练习
在实验工程中使用 JDK 21:
1 | |
输出在 evidence/13/local-20261002/run.txt,17 条 CHECK PASS,退出码为 0。正常 phase、dependsOn、父子容器和故障启动分别使用独立上下文,全部由 try-with-resources 管理。成功、依赖覆盖与失败三组线程池均检查终态。
反例题:一个 Worker.stop 把 running 改为 false 并立即调用 callback,但没有调用 executor.shutdown。LifecycleProcessor.isRunning 或 trace 能否证明线程池已释放?不能。框架只能观察组件声明,必须用 executor.isTerminated 等资源状态揭示错误。
可执行练习:在副本中令 Failing 的 phase 小于 survivor,重跑并记录 survivor 是否曾启动。相应调整断言时应允许 executor 尚未创建,而不能强行检查不存在的线程池。再给 dependent 增加一个依赖,画出启动拓扑与反向停止顺序,用 trace 验证依赖约束是否覆盖数字排序。
| 故障现象 | 证据入口 | 需要避免的误判 |
|---|---|---|
| refresh 返回却不可服务 | start 的真实就绪保证 | 构造成功代表业务就绪 |
| phase 顺序与预期不同 | dependsOn 与运行状态 | 数字排序是唯一规则 |
| 子容器对象重复 | 容器身份与本地定义 | 同名就表示同一实例 |
| 启动失败后线程仍在 | stop、destroy 与资源终态 | 有关闭日志就无泄漏 |
参考资料
- SmartLifecycle 接口契约。
- DefaultSingletonBeanRegistry 销毁实现。
- Java 21 ExecutorService,shutdown 请求与终止等待是不同操作。
