《Java 并发编程·线程与内存模型》6.3 shutdown hook 与释放顺序

shutdown hook 是 JVM 退出前的最后一程,不是优雅停机的全部。本文用 50 次运行证明多个 hook 的启动顺序完全随机,实测异常不改变退出码、JVM 对 hook 没有超时、hook 里调用 System.exit 会卡死,并对比 SIGTERM、SIGKILL 与 Runtime.halt 的路径,最后给出 libraryd-conc 的单 hook 有序释放写法。

承接的问题

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 正常返回
把关键落盘只放在 hookSIGKILL 时数据丢失业务事务内落盘,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 的实现与内存泄漏

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《Java 消息中间件·事件驱动架构》附录 B 架构决策记录模板
  2. 《Java 消息中间件·事件驱动架构》附录 A 术语表
  3. 《Java 消息中间件·事件驱动架构》15.3 全书验收