Linux SIGTERM信号-实验

Linux SIGTERM信号-实验

实验环境

  1. 操作系统
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
  1. 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)
  1. 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 在以下场景会被触发:

  1. 收到 SIGTERM 信号 :如 kill <pid>(不带 -9)、docker stopsystemctl stop 等。
  2. 调用 System.exit():程序主动退出。
  3. 最后一个非守护线程结束main 方法正常返回。
  4. 用户按下 Ctrl+C:前台运行时发送 SIGINT 信号。
不会触发的场景

以下情况 ShutdownHook 不会执行:

  • kill -9 <pid>:SIGKILL 信号直接由内核终止进程,JVM 没有机会执行任何清理。
  • Runtime.halt():直接强制终止 JVM。
  • 操作系统断电或崩溃:进程被内核直接回收。

钩子线程的执行特点

  • 并发执行 :多个 ShutdownHook 会并发执行,JVM 不保证执行顺序。如果清理逻辑有依赖关系,需要自行协调。
  • 执行超时:JVM 会等待所有钩子执行完毕,但不会无限等待。如果钩子线程长时间不结束,JVM 会强制退出。
  • 不能中断:钩子线程启动后,JVM 不会主动中断它,需要钩子自身保证尽快完成。
  • 不能再注册 :JVM 进入关闭流程后,addShutdownHook 会抛出 IllegalStateException
本实验中的用法解读

在本实验中,钩子线程做了三件事:

  1. 打开日志文件 :使用 try-with-resources 确保 PrintWriter 自动关闭,避免资源泄漏。
  2. 记录时间戳 :用 LocalDateTimeDateTimeFormatter 生成精确到毫秒的时间戳,方便后续排查停机时间点。
  3. 模拟清理工作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

实验过程

在本地运行

  1. 启动脚本无 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 信号

  1. 启动脚本有 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

  1. 当启动脚本无 exec 时,java 在运行时会 fork 一个新的进程
  2. 当启动脚本有 exec 时,java 在运行时会覆盖当前的shell进程,在进程树中就没有看到sh进程了

使用 docker 运行

  1. 构建镜像
    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
  1. 验证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」对信号传递和优雅停机的决定性影响。

核心结论

  1. JVM 的 addShutdownHook 能在进程收到 SIGTERM 时执行资源清理、日志记录等收尾逻辑,是实现优雅停机的关键手段。
  2. SIGTERM 信号能否真正到达 JVM,取决于进程的启动与托管方式;信号发给了哪个 PID、该进程是否转发信号,决定了最终行为。
  3. 在 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。

实践建议

  1. 容器启动脚本务必使用 exec:让 Java 进程成为 PID 1,从而接收 Docker 发送的 SIGTERM,保证 ShutdownHook 有机会运行。
  2. 钩子逻辑要短小、健壮:ShutdownHook 中避免复杂锁与耗时操作,并完整捕获异常,防止清理中断或 JVM 无法正常退出。
  3. 验证优雅停机 :可通过 docker stop -t 30 后检查日志文件,或使用 docker events 观察 signal=15 / signal=9 事件,确认信号是否真正到达应用进程。
相关推荐
云泽8081 小时前
Git 版本控制系统(上):架构原理与操作入门
linux·git·架构
凌云若寒2 小时前
CODESOFT试用流程
linux·运维·网络·数据库·sentinel
吴声子夜歌2 小时前
Linux命令——学习Shell(三)
linux·chrome·学习
小猪佩奇TONY2 小时前
Linux 内核学习(18) --- linux dma_buf 机制
linux·运维·学习
Dream Cosmos3 小时前
自旋锁:从忙等待、原子操作到线程同步
linux
kaixin_啊啊3 小时前
PandaWiki 本地 AI 知识库实战:文档导入、智能问答与远程访问
linux·服务器·人工智能·windows·ai
handler013 小时前
【Linux】进程退出、等待与程序替换
linux·运维·exec·进程·进程等待·进程退出·程序替换
Linux-lucky3 小时前
33-Linux学习之旅之MySQL用户管理
java·linux·运维·学习·mysql·ubuntu
像风一样的男人@3 小时前
linux --安装openGL,EGL/OSMesa
linux·运维·服务器