承接的问题
6.2 讲的是应用内部怎么停一个 Worker:置标志、唤醒、等待。但进程本身也有退出的一天,而且退出方式不止一种:main 正常返回、代码调用 System.exit、运维发 SIGTERM、容器被 SIGKILL。前三种会触发 JVM 的关闭流程,最后一种不会。
Runtime.addShutdownHook 就是挂在这个关闭流程上的回调。它经常被当成「优雅停机」的同义词,实际上它的能力非常有限:
- 它只在部分退出路径上被调用,也不能保证一定执行完(外部可能强杀);
- 它没有超时,JVM 会一直等;
- 多个 hook 之间没有顺序;
- hook 里的异常不会传出去,也不会改变退出码。
这一节先把契约讲清楚,再用真实输出演示顺序、异常、超时和几条退出路径的差异,最后给出一个能安全落地的写法。
addShutdownHook 的契约
最小用法:
public final class HookBasics {
public static void main(String[] args) {
Thread hook = new Thread(() -> {
System.out.println("flushing audit log");
}, "audit-flush");
Runtime.getRuntime().addShutdownHook(hook);
System.out.println("main done");
}
}
main 返回(且没有其它非守护线程存活)后,JVM 启动关闭流程,打印 main done 之后还会打印 flushing audit log。注册和移除的规则如下:
| 操作 | 行为 |
|---|---|
addShutdownHook(thread) | 注册一个尚未启动的线程;JVM 在关闭时启动它 |
| 同一个 Thread 注册两次 | 抛 IllegalArgumentException |
| 关闭流程已开始后再注册 | 抛 IllegalStateException(message 为 Shutdown in progress) |
removeShutdownHook(thread) | 移除成功返回 true,未注册过返回 false |
| 关闭流程已开始后移除 | 抛 IllegalStateException |
本机实测这几个边界:
remove 返回 true
再次 remove 返回 false
MAIN-DONE
HOOK-KEPT-START
HOOK-KEPT-END
重复注册抛 IllegalArgumentException
HOOK-DUP-START
HOOK-DUP-END
MAIN-DONE
HOOK-ADD-REJECTED: Shutdown in progress
注意第二组:重复注册虽然抛了异常,但第一次注册的 hook 仍然正常执行,异常只影响那一次调用。第三组是在 hook 内部尝试再注册一个 hook,被 JVM 明确拒绝。不要在关闭流程里动态添加清理逻辑,该注册的必须在启动阶段注册完。
哪些退出路径会触发 hook,哪些不会:
| 退出方式 | 是否执行 hook | 说明 |
|---|---|---|
main 正常返回 | 是 | 最常见的路径 |
System.exit(n) | 是 | 退出码为 n |
SIGTERM / SIGINT | 是 | JVM 安装的信号处理器会走关闭流程 |
SIGKILL(kill -9) | 否 | 进程被内核直接杀死 |
Runtime.getRuntime().halt(n) | 否 | 明确跳过关闭流程 |
| 进程崩溃 / OOM 被内核杀 | 否 | 没有任何保证 |
halt 的存在说明了一件事:JDK 自己也需要一个「立刻死、不清理」的逃生门。生产环境的容器编排在宽限期结束后发的 SIGKILL,走的就是同一条路。
实测:多个 hook 的顺序完全不可依赖
注册三个 hook,每个打印 START、睡 20ms、打印 END:
Runtime rt = Runtime.getRuntime();
rt.addShutdownHook(hook("A", 20));
rt.addShutdownHook(hook("B", 20));
rt.addShutdownHook(hook("C", 20));
System.out.println("MAIN-DONE");
其中 hook(name, millis) 的定义是:
static Thread hook(String name, long millis) {
return new Thread(() -> {
System.out.println("HOOK-" + name + "-START");
try { Thread.sleep(millis); }
catch (InterruptedException ignored) { }
System.out.println("HOOK-" + name + "-END");
}, "hook-" + name);
}
一次运行的输出:
MAIN-DONE
HOOK-B-START
HOOK-C-START
HOOK-A-START
HOOK-C-END
HOOK-A-END
HOOK-B-END
注册顺序是 A、B、C,启动顺序却是 B、C、A。连续跑 50 次,统计三个 hook 的启动顺序:
19 BAC
13 BCA
7 CAB
6 ABC
3 ACB
2 CBA
六种排列全部出现过,没有一种占绝对多数;50 次运行产生了 25 种不同的完整交错序列。这不是「通常按注册逆序」,而是真正的无序。
原因在实现层面:JVM 把 hook 放在一个基于 IdentityHashMap 的集合里,关闭时遍历集合并逐个 start()。哈希表的迭代顺序本来就不承诺稳定,多个 hook 又是并发启动的,谁先打印取决于线程调度。具体实现属于 JVM 内部,不同版本可能变化,但「不保证顺序」是公开契约。
顺序敏感时,合并成一个 hook
如果你的清理有依赖——必须先 flush 审计日志,再关闭存储——不要注册两个 hook 然后祈祷顺序。把步骤写进同一个 hook,用普通的顺序代码表达依赖:
Runtime.getRuntime().addShutdownHook(new Thread(() -> {
try {
audit.flush(Duration.ofSeconds(5)); // 先落盘
} catch (RuntimeException e) {
System.err.println("flush failed: " + e);
} finally {
audit.close(); // 再关资源
}
}, "libraryd-shutdown"));
一个 hook 内部的语句是顺序执行的,这才是有保证的顺序。多个 hook 只适合彼此完全独立、谁先谁后都无所谓的清理动作。
异常、超时与 halt
异常不会传出去
让中间那个 hook 抛异常:
rt.addShutdownHook(hook("FIRST", 0));
rt.addShutdownHook(new Thread(() -> {
throw new IllegalStateException("hook failed");
}, "hook-boom"));
rt.addShutdownHook(hook("LAST", 50));
System.exit(7);
本机 stdout 与 stderr 分开捕获:
--stdout--
MAIN-DONE
HOOK-BOOM-START
HOOK-LAST-START
HOOK-FIRST-START
HOOK-FIRST-END
HOOK-LAST-END
--stderr--
Exception in thread "hook-boom" java.lang.IllegalStateException: hook failed
at HookDemo.lambda$throwing$0(HookDemo.java:40)
at java.base/java.lang.Thread.run(Thread.java:1474)
exit=7
三件事同时成立:
- 另外两个 hook 照常执行,没有被异常打断;
- 异常只由该线程的默认处理器打到 stderr,没有任何调用方接收;
- 退出码仍然是
System.exit(7)指定的 7,异常没有把它改成 1。
所以「异常被吞」的准确含义是:它不是被静默丢弃,而是被降级成一行栈,既不影响其他 hook,也不影响退出码。 对运维来说,进程「正常退出 7」和「hook 清理失败」看起来可能没有区别,除非你把失败写进外部可观测的地方。
JVM 对 hook 没有超时
注册一个睡 5 秒的 hook,主线程保持存活,1 秒后发 SIGTERM:
java -cp out HookDemo stay &
pid=$!
sleep 1
kill -TERM $pid
wait $pid
本机结果:
signal-to-exit=5.15s rc=143
MAIN-ALIVE
HOOK-BLOCK-START
HOOK-BLOCK-END
从发信号到进程退出等了 5.15 秒,正好是 hook 的睡眠时间。JVM 会等所有 hook 线程结束,没有内置超时。如果 hook 里有一个永远不返回的调用,进程就永远不退出,直到外部 SIGKILL。
这意味着 hook 里的每一个阻塞操作都必须自带截止时间:网络请求设 connect/read timeout,队列排空设上限,重试次数写死。不能把「JVM 会兜底」当成假设。
hook 里调用 System.exit 会卡死
这是最反直觉的一条。在 hook 里再调用 System.exit(0):
rt.addShutdownHook(new Thread(() -> {
System.out.println("HOOK-EXIT-START");
System.exit(0);
System.out.println("HOOK-EXIT-UNREACHABLE");
}, "hook-exit"));
本机等 2 秒后检查:
alive-after-2s=true
rc=137
MAIN-DONE
HOOK-EXIT-START
进程没有退出,第二行也没有打印,最后只能 kill -9。原因是 System.exit 在关闭流程中会等待关闭流程完成,而关闭流程又在等当前 hook 返回——自己等自己。hook 里永远不要调用 System.exit,也不要等待另一个 hook 完成。
halt 与 SIGKILL:逃生门
MAIN-DONE, calling halt(9)
exit=9
Runtime.halt(9) 直接以 9 退出,注册的 hook 一行都没打印。SIGKILL 同理:
rc=137
MAIN-ALIVE
主线程还活着,hook 已注册,但进程被 SIGKILL 杀死,HOOK-BLOCK-START 没有出现。这两条路径上,任何「退出前一定执行」的假设都不成立。
| 手段 | hook | 退出码 | 适用 |
|---|---|---|---|
System.exit(n) | 执行 | n | 业务主动退出 |
SIGTERM | 执行 | 143 | 容器/运维的标准停止信号 |
SIGINT | 执行 | 130 | 终端 Ctrl+C |
Runtime.halt(n) | 跳过 | n | 卡死后的最后手段 |
SIGKILL | 跳过 | 137 | 宽限期结束,内核强杀 |
哪些资源必须在这里释放
hook 的定位是「最后一程」,适合做短、幂等、有上限的收尾。典型清单:
| 资源 | 动作 | 顺序 |
|---|---|---|
| 审计/日志缓冲区 | flush 到磁盘或远端 | 先于关闭文件 |
| 内存队列中的待处理事件 | 排空或落盘 | 先于关闭存储 |
| 临时文件、PID 文件、锁文件 | 删除 | 最后 |
| 服务注册中心 | 注销实例 | 越早越好,减少新流量 |
| 指标缓冲区 | 最后一次上报 | 关闭前 |
| 数据库连接池 / 文件句柄 | 关闭 | 最后 |
顺序原则只有一条:先停止产生新数据,再排空已有数据,最后关闭承载数据的资源。 反过来的话,flush 会写进一个已经关闭的通道。
但有些东西不应该只靠 hook:
- 持久化保证。hook 可能因为
SIGKILL不执行,也可能执行到一半被杀。关键数据必须在业务事务里落盘,hook 只做补充 flush。 - 长事务/长请求的排空。等几十秒的排空应该由应用的生命周期管理(停止接收新请求、等待在途请求),hook 只负责最后几秒。
- 分布式一致性。hook 不是事务协调者,跨服务补偿要靠 outbox 或对账。
一句话:hook 适合「尽力而为的收尾」,不适合「必须成功的承诺」。
在 libraryd-conc 里落地
借阅服务的退出清理有两件事:把审计缓冲区刷出去,然后关闭它。写成一个复合 hook,并用 AtomicBoolean 保证正常关闭和 hook 关闭不会重复执行:
public final class LoanShutdown implements AutoCloseable {
private final AuditLog audit;
private final AtomicBoolean released = new AtomicBoolean();
private final Thread hook;
public LoanShutdown(AuditLog audit) {
this.audit = audit;
this.hook = new Thread(this::releaseAll, "libraryd-shutdown");
Runtime.getRuntime().addShutdownHook(hook);
}
private void releaseAll() {
if (!released.compareAndSet(false, true)) {
return;
}
try {
audit.flush(Duration.ofSeconds(5));
} catch (RuntimeException e) {
System.err.println("audit flush failed: " + e);
} finally {
audit.close();
}
}
@Override
public void close() {
try {
Runtime.getRuntime().removeShutdownHook(hook);
} catch (IllegalStateException ignored) {
// JVM 已在 shutdown 中,hook 由 JVM 负责
}
releaseAll();
}
}
四个设计点:
- 只有一个 hook,内部按顺序执行 flush → close。不去赌多个 hook 的先后。
AtomicBoolean保证幂等。正常关闭时close()先移除 hook 再执行;如果移除失败(已经在 shutdown 中),hook 稍后执行时会被 CAS 挡住,不会刷两遍。removeShutdownHook捕获IllegalStateException。关闭流程已开始时移除会抛异常,这不是错误,忽略即可。- flush 有 5 秒上限。
AuditLog.flush(Duration)自己负责在截止时间内返回,hook 不依赖 JVM 提供超时。
测试里要特别注意:单元测试如果直接触发 System.exit,会结束整个测试 JVM。正确做法是调用 LoanShutdown.close(),把 hook 路径留给集成测试或独立的进程级测试验证。
常见错误与边界
| 错误 | 后果 | 修法 |
|---|---|---|
| 依赖多个 hook 的执行顺序 | 清理顺序随机,偶发 ClosedChannelException | 合并成一个 hook,内部顺序执行 |
| hook 里做无限等待/重试 | JVM 永不退出 | 每个阻塞都设截止时间 |
hook 里调用 System.exit | 关闭流程自锁 | 让 hook 正常返回 |
| 把关键落盘只放在 hook | SIGKILL 时数据丢失 | 业务事务内落盘,hook 只补充 |
| hook 抛异常后期望退出码变化 | 退出码不变,失败被忽略 | 失败写日志/指标,必要时主动 halt |
| 重复注册同一个线程 | IllegalArgumentException | 启动阶段统一注册一次 |
| 忘记移除 hook | 测试或热部署时旧 hook 持有对象引用 | 正常关闭时 removeShutdownHook |
什么时候不要用 shutdown hook:
- 需要强持久化保证的场景。hook 没有「至少执行一次」的承诺。
- 需要在几秒内完成大量排空的场景。JVM 不设超时,容器却会设宽限期,最后等来的往往是
SIGKILL。 - 需要跨进程协调的场景。hook 只能看见自己进程里的状态。
小结
addShutdownHook只在正常退出、System.exit、SIGTERM/SIGINT上执行;halt和SIGKILL会跳过。- 多个 hook 的启动顺序完全不可依赖:本机 50 次运行里六种排列全部出现,产生了 25 种不同的交错序列。顺序敏感就必须合并成一个 hook。
- hook 里的异常不影响其他 hook,也不改变退出码;JVM 对 hook 没有超时;hook 里调用
System.exit会自己等自己,进程卡死。 - 只把「短、幂等、有截止时间」的收尾放进 hook;持久化、长排空、跨服务一致性必须由业务层保证。
到这里,线程的创建、协作、等待与终止已经闭环。第 7 章转向另一类「看不见的状态」:ThreadLocal 如何把变量绑到线程上,又为什么会因为线程复用而泄漏。
阅读导航:上一节 6.2 两阶段终止与协作式取消 | 下一节 7.1 ThreadLocal 的实现与内存泄漏
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。