Linux SIGTERM信号-实验
- 实验环境
- 实验代码
-
- ShutdownHookLogExample.java
- 启动脚本
-
- [使用 exec 执行的启动脚本 entrypoint.sh](#使用 exec 执行的启动脚本 entrypoint.sh)
- [直接运行的java 的启动脚本 entrypoint_no_exec.sh](#直接运行的java 的启动脚本 entrypoint_no_exec.sh)
- 区别
-
- [使用 `exec` 时(entrypoint.sh)](#使用
exec时(entrypoint.sh)) - [不使用 `exec` 时(entrypoint_no_exec.sh)](#不使用
exec时(entrypoint_no_exec.sh))
- [使用 `exec` 时(entrypoint.sh)](#使用
- Dockerfile
- Dockerfile_no_exec
- 代码结构
- 实验过程
-
- 在本地运行
- [使用 docker 运行](#使用 docker 运行)
- 实验总结
实验环境
- 操作系统
bash
$ cat /etc/os-release
PRETTY_NAME="Ubuntu 24.04.4 LTS"
NAME="Ubuntu"
VERSION_ID="24.04"
VERSION="24.04.4 LTS (Noble Numbat)"
VERSION_CODENAME=noble
ID=ubuntu
ID_LIKE=debian
HOME_URL="https://www.ubuntu.com/"
SUPPORT_URL="https://help.ubuntu.com/"
BUG_REPORT_URL="https://bugs.launchpad.net/ubuntu/"
PRIVACY_POLICY_URL="https://www.ubuntu.com/legal/terms-and-policies/privacy-policy"
UBUNTU_CODENAME=noble
LOGO=ubuntu-logo
- jdk版本
bash
$ java --version
openjdk 21.0.12 2026-07-21
OpenJDK Runtime Environment (build 21.0.12+8-1-24.04-Ubuntu)
OpenJDK 64-Bit Server VM (build 21.0.12+8-1-24.04-Ubuntu, mixed mode, sharing)
- docker 版本
bash
# docker -v
Docker version 29.7.2, build a7dcaa6
实验代码
ShutdownHookLogExample.java
java
import java.io.BufferedWriter;
import java.io.FileWriter;
import java.io.IOException;
import java.io.PrintWriter;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
public class ShutdownHookLogExample {
private static final String LOG_FILE = "shutdown.log";
private static final DateTimeFormatter FORMATTER =
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.SSS");
public static void main(String[] args) {
// 注册 ShutdownHook
Runtime.getRuntime().addShutdownHook(new Thread(() -> {
// 使用 try-with-resources 确保自动关闭
try (PrintWriter writer = new PrintWriter(new BufferedWriter(new FileWriter(LOG_FILE, true)))) { // true 表示追加
String timestamp = LocalDateTime.now().format(FORMATTER);
writer.println(timestamp + " - 收到 SIGTERM,开始优雅停机...");
// 模拟执行清理工作
// 注意:清理工作本身也可能需要记录日志
writer.println(timestamp + " - 正在释放数据库连接...");
// 实际清理代码...
Thread.sleep(1000); // 模拟耗时操作
writer.println(timestamp + " - 清理完成,JVM 即将退出。");
// 注意:PrintWriter 会自动刷新,但保险起见显式刷新
writer.flush();
} catch (IOException | InterruptedException e) {
// 如果无法写入文件,至少输出到标准错误(可能被重定向)
System.err.println("ShutdownHook 写日志失败: " + e.getMessage());
// 也可以尝试使用 System.out 作为备选
e.printStackTrace(System.err);
}
}));
// 主程序逻辑
System.out.println("应用程序启动,PID: " + ProcessHandle.current().pid());
while (true) {
System.out.println("程序运行输出");
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
addShutdownHook 方法详细解析
Runtime.getRuntime().addShutdownHook(Thread hook) 是 JVM 提供的一个优雅停机机制。它允许我们在 JVM 即将退出前注册一个或多个钩子线程,用于执行资源清理、日志记录、状态保存等收尾工作。
方法签名与注册时机
java
public void addShutdownHook(Thread hook)
- 参数 :一个尚未启动的
Thread实例。如果传入的线程已经启动,会抛出IllegalArgumentException。 - 注册时机 :必须在 JVM 启动后、真正开始关闭流程前调用。通常在
main方法开头注册,确保后续任何退出路径都能触发钩子。 - 重复注册 :同一个线程实例只能注册一次,重复注册会抛出
IllegalStateException。
触发场景
ShutdownHook 在以下场景会被触发:
- 收到 SIGTERM 信号 :如
kill <pid>(不带-9)、docker stop、systemctl stop等。 - 调用
System.exit():程序主动退出。 - 最后一个非守护线程结束 :
main方法正常返回。 - 用户按下 Ctrl+C:前台运行时发送 SIGINT 信号。
不会触发的场景
以下情况 ShutdownHook 不会执行:
kill -9 <pid>:SIGKILL 信号直接由内核终止进程,JVM 没有机会执行任何清理。Runtime.halt():直接强制终止 JVM。- 操作系统断电或崩溃:进程被内核直接回收。
钩子线程的执行特点
- 并发执行 :多个 ShutdownHook 会并发执行,JVM 不保证执行顺序。如果清理逻辑有依赖关系,需要自行协调。
- 执行超时:JVM 会等待所有钩子执行完毕,但不会无限等待。如果钩子线程长时间不结束,JVM 会强制退出。
- 不能中断:钩子线程启动后,JVM 不会主动中断它,需要钩子自身保证尽快完成。
- 不能再注册 :JVM 进入关闭流程后,
addShutdownHook会抛出IllegalStateException。
本实验中的用法解读
在本实验中,钩子线程做了三件事:
- 打开日志文件 :使用
try-with-resources确保PrintWriter自动关闭,避免资源泄漏。 - 记录时间戳 :用
LocalDateTime和DateTimeFormatter生成精确到毫秒的时间戳,方便后续排查停机时间点。 - 模拟清理工作 :
Thread.sleep(1000)模拟释放数据库连接等耗时操作,验证钩子确实在 JVM 退出前执行完毕。
注意事项
- 钩子线程中避免使用复杂锁:如果主线程持有锁而钩子线程等待该锁,可能造成死锁,导致 JVM 无法正常退出。
- 日志写入要尽快完成:钩子执行时间不宜过长,否则可能被 JVM 强制终止。
- 捕获所有异常:钩子线程中的异常不会影响 JVM 退出,但会导致清理逻辑中断,因此务必捕获并记录。
编译代码得到 .class 文件
bash
$ javac ShutdownHookLogExample.java
启动脚本
使用 exec 执行的启动脚本 entrypoint.sh
bash
$ cat entrypoint.sh
#!/bin/sh
exec java ShutdownHookLogExample
直接运行的java 的启动脚本 entrypoint_no_exec.sh
bash
$ cat entrypoint_no_exec.sh
#!/bin/sh
java ShutdownHookLogExample
区别
exec 是 shell 的一个内建命令,它的核心作用是用新进程替换当前 shell 进程,而不是像普通命令那样作为子进程启动。这一差异在容器场景下至关重要,直接影响信号能否被正确传递。
使用 exec 时(entrypoint.sh)
bash
#!/bin/sh
exec java ShutdownHookLogExample
- 进程替换 :
exec会用 Java 进程替换当前的 shell 进程。最终容器内 PID 为 1 的进程就是 Java 进程本身。 - 信号直达 :当
docker stop发送 SIGTERM 时,信号直接发送给 Java 进程,JVM 能立即收到并触发 ShutdownHook,实现优雅停机。 - 进程树简洁:容器内只有一个 Java 进程,没有多余的 shell 中间层,资源占用更少,进程管理更清晰。
不使用 exec 时(entrypoint_no_exec.sh)
bash
#!/bin/sh
java ShutdownHookLogExample
- 子进程方式 :shell 会先启动,然后以子进程方式运行 Java。此时容器内 PID 为 1 的是 shell 进程,Java 是它的子进程。
- 信号被拦截 :
docker stop发送的 SIGTERM 首先到达 PID 为 1 的 shell 进程。shell 默认不会把信号转发给子进程,导致 Java 进程收不到 SIGTERM,ShutdownHook 无法触发。 - 超时强杀 :由于 Java 进程没有在宽限期内退出,Docker 会在默认 10 秒后发送 SIGKILL 强制终止,造成非优雅停机,日志文件中的清理记录可能缺失。
Dockerfile
bash
$ cat Dockerfile
FROM eclipse-temurin:21-jre-jammy
WORKDIR /app
COPY entrypoint.sh .
RUN chmod +x entrypoint.sh
COPY ShutdownHookLogExample.class ShutdownHookLogExample.class
ENTRYPOINT ["./entrypoint.sh"]
Dockerfile_no_exec
bash
$ cat Dockerfile_no_exec
FROM eclipse-temurin:21-jre-jammy
WORKDIR /app
COPY entrypoint_no_exec.sh .
RUN mv entrypoint_no_exec.sh entrypoint.sh
RUN chmod +x entrypoint.sh
COPY ShutdownHookLogExample.class ShutdownHookLogExample.class
ENTRYPOINT ["./entrypoint.sh"]
代码结构
bash
$ ls -l
total 48
-rw-rw-r-- 1 scd scd 194 Sep 6 17:12 Dockerfile
-rw-rw-r-- 1 scd scd 244 Sep 6 17:13 Dockerfile_no_exec
-rw-rw-r-- 1 scd scd 39 Sep 6 15:54 entrypoint_no_exec.sh
-rw-rw-r-- 1 scd scd 128 Sep 6 15:42 entrypoint.sh
-rw-r--r-- 1 root root 183 Sep 6 17:24 linux-signal-java.log
-rw-rw-r-- 1 scd scd 14445 Sep 6 18:33 README.md
-rw-rw-r-- 1 scd scd 3262 Sep 6 18:24 ShutdownHookLogExample.class
-rw-rw-r-- 1 scd scd 2152 Sep 6 13:55 ShutdownHookLogExample.java
-rw-rw-r-- 1 scd scd 915 Sep 6 16:23 shutdown.log
实验过程
在本地运行
- 启动脚本无 exec 命令的情况
执行启动脚本
bash
$ sh entrypoint_no_exec.sh
查看进程树
bash
$ ps -ef|grep 'entry'
scd 28405 9651 0 15:56 pts/3 00:00:00 sh entrypoint_no_exec.sh
scd 28443 17777 0 15:57 pts/5 00:00:00 grep --color=auto entry
$ pstree -ps 9651
systemd(1)───systemd(2629)───gnome-terminal-(5161)───bash(9651)───sh(28405)───java(28406)─┬─{java}(28407)
├─{java}(28408)
├─{java}(28409)
├─{java}(28410)
├─{java}(28411)
├─{java}(28412)
├─{java}(28413)
├─{java}(28414)
├─{java}(28415)
├─{java}(28416)
├─{java}(28417)
├─{java}(28418)
├─{java}(28419)
├─{java}(28420)
├─{java}(28421)
├─{java}(28422)
└─{java}(28423)
发送 SIGTERM 信号到入口进程
bash
$ kill -SIGTERM 28405
查看进程是否终止
bash
$ ps -ef|grep 'entry'
scd 28691 17777 0 16:01 pts/5 00:00:00 grep --color=auto entry
入口进程 28405确实终止了,但是程序还在输出,查询java进程是否还存在
bash
$ ps -f -p 28406
UID PID PPID C STIME TTY TIME CMD
scd 28406 2629 0 15:56 pts/3 00:00:01 java ShutdownHookLogExample
终止该java进程
bash
$ kill -SIGTERM 28406
$ ps -f -p 28406
UID PID PPID C STIME TTY TIME CMD
kill 命令终止信号的 -SIGTERM 的可以省略,默认情况下发送的是 SIGTERM 信号
- 启动脚本有 exec 命令情况
执行启动脚本
bash
$ sh entrypoint.sh
查看进程树
bash
$ ps -ef|grep "entry"
scd 29425 17777 0 16:17 pts/5 00:00:00 grep --color=auto entry
没有搜索到sh 入口进程,再次搜索java进程
bash
$ ps -ef|grep "java"
scd 29375 9651 0 16:16 pts/3 00:00:00 java ShutdownHookLogExample
scd 29457 17777 0 16:19 pts/5 00:00:00 grep --color=auto java
查看进程对应信息
bash
$ ps -f -p 29375
UID PID PPID C STIME TTY TIME CMD
scd 29375 9651 0 16:16 pts/3 00:00:00 java ShutdownHookLogExample
查看进程树
bash
$ pstree -ps 9651
systemd(1)───systemd(2629)───gnome-terminal-(5161)───bash(9651)───java(29375)─┬─{java}(29376)
├─{java}(29377)
├─{java}(29378)
├─{java}(29379)
├─{java}(29380)
├─{java}(29381)
├─{java}(29382)
├─{java}(29383)
├─{java}(29384)
├─{java}(29385)
├─{java}(29386)
├─{java}(29387)
├─{java}(29388)
├─{java}(29389)
├─{java}(29390)
├─{java}(29391)
└─{java}(29392)
终止该java 进程
bash
$ kill 29375
发现终端没有输出了,查看进程信息
bash
$ ps -f -p 29375
UID PID PPID C STIME TTY TIME CMD
- 当启动脚本无 exec 时,java 在运行时会 fork 一个新的进程
- 当启动脚本有 exec 时,java 在运行时会覆盖当前的shell进程,在进程树中就没有看到sh进程了
使用 docker 运行
- 构建镜像
exec 入口的镜像 tag linux-signal-java:latest
bash
# docker build -t linux-signal-java:latest .
无 exec 入口的镜像 tag
bash
# docker build -f Dockerfile_no_exec -t linux-signal-java-no-exec:latest .
查看已生成的镜像
bash
# docker images|grep "linux-signal"
linux-signal-java-no-exec:latest c6525b964346 403MB 99MB
linux-signal-java:latest c9472b5b37ac 403MB 99MB
- 验证SIGTERM信号是否被容器中的Java进程接收
linux-signal-java:latest
bash
# docker run -d --name linux-signal-java linux-signal-java:latest
查看运行日志
bash
# docker logs -f linux-signal-java
应用程序启动,PID: 1
程序运行输出
程序运行输出
进入容器查看进程信息
bash
# docker exec -it linux-signal-java /bin/bash
bash
# ps -ef
UID PID PPID C STIME TTY TIME CMD
root 1 0 0 09:16 ? 00:00:00 java ShutdownHookLogExample
...
docker stop停止容器运行
text
docker stop命令用于优雅地停止一个或多个正在运行的容器
核心机制:优雅停止(SIGTERM -> SIGKILL)
1.发送SIGTERM信号:Docker向容器内PID为1的进程发送SIGTERM(终止信号)。进程收到后,可以执行清理操作(如保存文件、关闭数据库链接、释放锁)。
2.等待宽限期(Grace Period):Docker 会等待一段时间(默认 10秒),让进程完成收尾工作。
3.发送 SIGKILL 信号:如果宽限期结束,进程还未退出,Docker 会立即发送 SIGKILL(强制杀死信号),系统会直接终止该进程,进程没有机会再做任何响应。
bash
# docker stop -t 30 linux-signal-java
查看容器中的 shutdown.log 日志
bash
# docker cp linux-signal-java:/app/shutdown.log ./linux-signal-java.log
Successfully copied 183B (transferred 2.05kB) to /java/linux-signal/linux-signal-java.log
查看日志内容
bash
$ cat linux-signal-java.log
2026-09-06 09:24:33.494 - 收到 SIGTERM,开始优雅停机...
2026-09-06 09:24:33.494 - 正在释放数据库连接...
2026-09-06 09:24:33.494 - 清理完成,JVM 即将退出。
通过日志可以发现,容器中的java 进程成功接收到了SIGTERM信号,成功执行了优雅停机的代码
linux-signal-java-no-exec
bash
# docker run -d --name linux-signal-java-no-exec linux-signal-java-no-exec:latest
查看运行日志
bash
# docker logs -f linux-signal-java-no-exec
应用程序启动,PID: 7
程序运行输出
进入容器查看进程信息
bash
# docker exec -it linux-signal-java-no-exec /bin/bash
bash
# ps -ef
UID PID PPID C STIME TTY TIME CMD
root 1 0 0 09:40 ? 00:00:00 /bin/sh ./entrypoint.sh
root 7 1 0 09:40 ? 00:00:00 java ShutdownHookLogExample
...
发现启动脚本如果没有使用exec运行的话, java 进程的pid为7, 父进程的pid为1, 运行过程从进程1 fork出子进程 7
docker stop停止容器运行
bash
# docker stop -t 30 linux-signal-java-no-exec
执行停止命令,等待30s 容器才停止,触发了 SIGKILL 信号
查看容器中的 shutdown.log 日志
bash
# docker cp linux-signal-java-no-exec:/app/shutdown.log ./linux-signal-java-no-exec.log
Error response from daemon: Could not find the file /app/shutdown.log in container linux-signal-java-no-exec
报错了,容器中不存在shutdown.log, java进程未接收到SIGTERM信号
也可以使用 docker events 查看详细的信号事件
bash
# docker events --filter container=linux-signal-java-no-exec
另一个终端运行容器
bash
# docker start linux-signal-java-no-exec
再次运行停止容器
bash
# docker stop -t 30 linux-signal-java-no-exec
详细的事件日志如下:
bash
# docker events --filter container=linux-signal-java-no-exec
2026-09-06T18:02:36.507763058+08:00 container start a401f401fcc7bdf2c89306c244b5689605df3fd963cf022020a93af3e938fa68 (image=linux-signal-java-no-exec:latest, name=linux-signal-java-no-exec, org.opencontainers.image.version=22.04)
2026-09-06T18:02:49.029498926+08:00 container kill a401f401fcc7bdf2c89306c244b5689605df3fd963cf022020a93af3e938fa68 (image=linux-signal-java-no-exec:latest, name=linux-signal-java-no-exec, org.opencontainers.image.version=22.04, signal=15)
2026-09-06T18:03:19.046159706+08:00 container kill a401f401fcc7bdf2c89306c244b5689605df3fd963cf022020a93af3e938fa68 (image=linux-signal-java-no-exec:latest, name=linux-signal-java-no-exec, org.opencontainers.image.version=22.04, signal=9)
2026-09-06T18:03:19.185350117+08:00 container stop a401f401fcc7bdf2c89306c244b5689605df3fd963cf022020a93af3e938fa68 (image=linux-signal-java-no-exec:latest, name=linux-signal-java-no-exec, org.opencontainers.image.version=22.04)
2026-09-06T18:03:19.188687261+08:00 container die a401f401fcc7bdf2c89306c244b5689605df3fd963cf022020a93af3e938fa68 (execDuration=42, exitCode=137, image=linux-signal-java-no-exec:latest, name=linux-signal-java-no-exec, org.opencontainers.image.version=22.04)
从事件日志可以很清晰看出,执行 docker stop之后 先发送了 signal=15 SIGTERM 信号,但是java进程的pid=7 未接收到,pid=1的进程退出了,java进程在继续运行。等待30s之后,再次发送signal=9 SIGKILL信号
实验总结
本次实验围绕 Linux SIGTERM 信号与 JVM ShutdownHook 机制展开,从本地进程和 Docker 容器两个维度,验证了「启动脚本是否使用 exec」对信号传递和优雅停机的决定性影响。
核心结论
- JVM 的
addShutdownHook能在进程收到 SIGTERM 时执行资源清理、日志记录等收尾逻辑,是实现优雅停机的关键手段。 - SIGTERM 信号能否真正到达 JVM,取决于进程的启动与托管方式;信号发给了哪个 PID、该进程是否转发信号,决定了最终行为。
- 在 Docker 容器中,
ENTRYPOINT脚本必须使用exec启动 Java 进程,否则 SIGTERM 无法直达 JVM,优雅停机形同虚设。
本地运行对比
| 启动方式 | 进程关系 | 发送 SIGTERM 到入口进程后 |
|---|---|---|
sh entrypoint_no_exec.sh |
shell 为父进程,Java 为子进程 | shell 终止,Java 继续运行,需要再次 kill Java |
sh entrypoint.sh(exec) |
Java 直接替换 shell 进程 | Java 收到 SIGTERM,ShutdownHook 执行,进程正常退出 |
Docker 运行对比
| 启动方式 | PID 1 进程 | SIGTERM 是否到达 JVM | ShutdownHook | 停机结果 |
|---|---|---|---|---|
| 使用 exec | Java 进程 | ✅ 直达 JVM | ✅ 正常执行,写入 shutdown.log | 优雅停机,日志完整 |
| 不使用 exec | shell 进程(Java 为 PID 7 子进程) | ❌ 被 shell 拦截 | ❌ 未执行,容器内无 shutdown.log | 宽限期后被 SIGKILL 强杀 |
docker events 日志进一步印证:无 exec 场景下,docker stop 先向 PID 1 的 shell 发送 signal=15(SIGTERM),shell 退出但 Java 子进程仍继续运行;30 秒后 Docker 再发送 signal=9(SIGKILL)强制终止,容器 exitCode 为 137。
实践建议
- 容器启动脚本务必使用
exec:让 Java 进程成为 PID 1,从而接收 Docker 发送的 SIGTERM,保证 ShutdownHook 有机会运行。 - 钩子逻辑要短小、健壮:ShutdownHook 中避免复杂锁与耗时操作,并完整捕获异常,防止清理中断或 JVM 无法正常退出。
- 验证优雅停机 :可通过
docker stop -t 30后检查日志文件,或使用docker events观察 signal=15 / signal=9 事件,确认信号是否真正到达应用进程。