钩子线程(Hook Thread)简介
在一个 Java 应用程序即将退出时(比如通过正常执行完成或通过用户关闭应用程序),通常需要进行一些清理操作,例如:
- 释放资源(如文件句柄、网络连接)。
- 关闭数据库连接。
- 保存未完成的数据或状态。
我们可以通过钩子线程实现这一点,钩子线程是指在程序结束时,JVM 会自动执行的一类线程。这些线程会被预先“挂钩”在程序退出事件上,一旦 JVM 检测到程序即将退出,就会启动这些线程来执行特定的操作。
钩子线程是通过 Runtime.getRuntime().addShutdownHook(Thread hook) 方法来注册的。当 JVM 检测到应用程序即将退出时,就会运行所有注册的钩子线程。
来看一个示例代码:
public class HookThreadDemo {
private static class HookRunnable implements Runnable {
@Override
public void run() {
try {
System.out.println("Hook " + Thread.currentThread().getName() + " is executing...");
Thread.sleep(2000L);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("Hook " + Thread.currentThread().getName() + " is about to end execution");
}
}
public static void main(String[] args) {
HookRunnable hookRunnable = new HookRunnable();
//add hook thread 0
Runtime.getRuntime().addShutdownHook(new Thread(hookRunnable));
//add hook thread 1
Runtime.getRuntime().addShutdownHook(new Thread(hookRunnable));
System.out.println("The main thread is going to finish executing.");
}
}
//输出:
The main thread is going to finish executing.is going to finish executing.
Hook Thread-0 is executing...
Hook Thread-1 is executing...
Hook Thread-1 is about to end execution
Hook Thread-0 is about to end execution
从输出中可以看到,当主线程执行完毕,也就是JVM进程即将退出的时候,两个注入的Hook线程都会被启动,并且打印出相关日志。
Shutdown Hook 机制的应用场景
利用 Shutdown Hook 机制可以完成一些在程序退出前的清理和后续处理工作,例如:
- 释放资源:在 Hook 中释放文件句柄、数据库连接等资源,避免资源泄漏。
- 关闭服务:在 Hook 中关闭服务器,确保所有请求都已处理完毕,安全地终止服务。
- 发送通知:在 Hook 中发送电子邮件、短信等通知,告知用户或管理员服务已停止。
- 记录日志:在 Hook 中记录系统状态、错误信息等日志,便于事后排查和分析问题。
数据库连接关闭案例
下面简单演示一下如何使用Shutdown Hook机制关闭数据库连接。
public class DataBaseConnectMain {
private static Connection conn;
public static void main(String[] args) {
System.out.println("The main thread starts executing");
// 初始化数据库连接
initConnection();
System.out.println("Do some data querying and processing");
// 注册关闭钩子
Runtime.getRuntime().addShutdownHook(new Thread() {
public void run() {
closeConnection();
}
});
System.out.println("The main thread ends execution.");
}
private static void initConnection() {
try {
Class.forName("com.mysql.cj.jdbc.Driver");
conn = DriverManager.getConnection("jdbc:mysql://localhost:3306/school_info?useSSL=true&", "root", "root");
System.out.println("Database connection successful!");
} catch (ClassNotFoundException e) {
e.printStackTrace();
} catch (SQLException e) {
e.printStackTrace();
}
}
private static void closeConnection() {
try {
conn.close();
System.out.println("Database connection closed!");
} catch (SQLException e) {
e.printStackTrace();
}
}
}
//输出:
The main thread starts executing
Database connection successful!
Do some data querying and processing
The main thread ends execution.
Database connection closed!
上述代码中我们在initConnection()方法中初始化了一个数据库连接,同时在main()
函数中注册了一个 Shutdown Hook,用于在 JVM 关闭时关闭数据库连接。从输出可以看出,进程关闭时,输出"Database connection closed!"
。
Shutdown Hook 机制使用注意事项
- Hook线程只有在正确接收到退出信号的情况下才能正常执行。如果你通过强制方法(例如 kill -9)杀死进程,Hook 线程将不会被执行,因为它们无法应对这种情况。
- 不要在 Hook 线程中执行会导致程序长时间无法退出的耗时操作。
- 尽量避免在 Hook 线程中抛出异常,否则可能导致 Java 虚拟机无法正常退出。
- Shutdown Hooks 的注册顺序非常重要,需要根据它们之间的依赖关系进行合理安排。通常应先注册简单的 Shutdown Hooks,再注册复杂的。
- 尽量不要在 Shutdown Hook 中启动新线程,否则可能导致 JVM 无法正常关闭。
Shutdown Hook机制在开源框架中的使用
1. Spring
2.Tomcat
Shutdown Hook 机制的原理
Java 的 Shutdown Hook 机制依赖于 Java 虚拟机(JVM)中的两个线程:主线程和Shutdown 线程。
当 Java 应用程序启动时,主线程会创建一个 Shutdown 线程,并将所有注册的 Shutdown Hooks 添加到 Shutdown 线程的 Hook 列表中。当 JVM 收到终止信号时,它会首先停止所有用户线程,然后启动 Shutdown 线程。
Shutdown 线程会按照 Hook 列表中的顺序逐一执行每一个 Hook,并等待所有 Hook 执行完毕或超时。如果所有 Hook 都执行完毕,JVM 将正常退出;否则,JVM 将强制退出。
Shutdown Hook 机制源码分析
根据Hook机制的原理介绍,对源码的分析我们主要从3个方面入手:
- 如何注册一个ShutdownHook线程;
- 如何执行 ShutdownHook 线程。
- 当 ShutdownHook 被触发时;
1. ShutdownHook的注册
当我们添加一个 ShutdownHook 时,ApplicationShutdownHooks.add(hook)将被调用;
传入的钩子线程会被添加到 ApplicationShutdownHooks 类的静态变量 private static IdentityHashMap<Thread, Thread> hooks 中,这个变量维护着所有后续需要使用的钩子。
在 ApplicationShutdownHooks 类初始化时,其 hooks 会被添加到 Shutdown 的 hooks 中,并且执行顺序固定为第一位。
Shutdown 类中的 hooks 是系统级的 ShutdownHooks,系统级的 ShutdownHooks 由一个数组组成,最多只能添加 10 个。在这种情况下,我们只需要关注顺序为 1 的钩子,也就是 ApplicationShutdownHooks。
2. ShutdownHook 的执行
Shutdown 类通过调用 runHooks 方法来运行之前注册的系统级 ShutdownHooks。它直接调用线程类的 run 方法(而不是从 start 方法开始)。结合源码可以知道,每个系统级 ShutdownHook 都是同步、有序地执行的。
当系统级钩子运行到序号为 1 的钩子时,ApplicationShutdownHooks 的 runHooks 方法会被执行。
在方法内部,每个钩子在执行时会调用线程类的 start 方法,,所以应用程序级别的Shutdown Hook是异步执行的,但在退出之前会等待所有钩子执行完毕。
3. Shutdown Hook的触发时刻
跟踪 Shutdown 的 runHooks 线程,我们得出了以下调用路径。
重点关注 Shutdown.exit 和 Shutdown.shutdown 的调用。
Shutdown.exit
我们发现 Shutdown.exit 的调用者包括 Runtime.exit 和 Terminator.setup。
- Runtime.exit 是代码中用于主动结束程序的接口。
- Terminator.setup 在 initializeSystemClass 中被调用,在第一个线程初始化时触发。它注册了一个信号监听函数,用于捕获 kill 信号,并通过调用 Shutdown.exit 来结束进程。
这些涵盖了代码中的终止场景,包括进程主动终止和进程被 kill 命令杀死。主动结束进程的过程较为直观,因此这里重点讲解如何实现信号捕获。可以通过在终端输入 kill -l 来查看系统支持的信号。
下面我们简单介绍一下一些常用的信号及含义:
Signal Name Serial No. Meaning
HUP 1 Terminal disconnected1 Terminal disconnected
INT 2 Interrupt (same as Ctrl + C)
QUIT 3 Exit (same as Ctrl + \)
TERM 15 Normal termination
KILL 9 Forced termination
CONT 18 Continue (opposite of STOP, fg/bg command)
STOP 19 Stop(same as Ctrl + Z)
USR1 10 User defined signal 1
USR2 12 User defined signal 2
在 Java 中,我们可以通过编写以下代码来捕获 kill 信号。只需实现 SignalHandler 接口并重写 handle 方法,然后在程序入口处注册相应的信号进行监听即可。
不过,需要注意,并不是所有信号都可以被捕获和处理。
public class SignalHandlerTest implements SignalHandler {
public static void main(String[] args) {
Runtime.getRuntime().addShutdownHook(new Thread(() -> System.out.println("ShutdownHook is running...")));
SignalHandler sh = new SignalHandlerTest();
Signal.handle(new Signal("INT"), sh);
Signal.handle(new Signal("TERM"), sh);
//Signal.handle(new Signal("QUIT"), sh);// This signal cannot be captured
//Signal.handle(new Signal("KILL"), sh);// This signal cannot be captured
while (true) {
System.out.println("Main thread is running...");
try {
Thread.sleep(2000L);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
@Override
public void handle(Signal signal) {
System.out.println("Receive signal: " + signal.getName() + "-" + signal.getNumber());
System.exit(0);
}
}
将上面的代码打成JAR包,然后在命令行中执行,看下图,主要分为五个部分:
- 运行JAR包,启动进程;
- 主线程正在执行,并监听信号;
- 用户输入信号
ctrl + c
,即INT-2
; - 收到信号后,输出信号类型,流程结束;
- 在结束进程之前,执行ShowdonwHook的逻辑。
需要注意的是,一般来说,当我们捕获到信号后,完成个性化处理后,需要主动调用 System.exit,否则进程将不会退出,只能通过 kill -9 强制杀死进程。
此外,由于各个信号的捕获是在不同的线程中进行的,因此它们的执行是异步的。
Shutdown.shutdown
该方法的调用时机可以从代码注释中找到:
在 Java 中,线程分为两种类型:用户线程和守护线程。守护线程是服务于用户线程的,例如垃圾回收线程(GC)。JVM 判断是否可以结束的标志是是否还有用户线程在运行。当最后一个用户线程结束时,Shutdown.shutdown 会被调用。这是 JVM 和虚拟机语言特有的“特权”。关于守护线程的更多细节将在后面的文章中介绍。
因此,通过对 Shutdown.exit 和 Shutdown.shutdown 的分析,我们可以总结出以下结论:
其实,Java 的 ShutdownHook 已经覆盖了大部分终止场景,但有一个情况无法处理,那就是当我们使用 kill -9 强制杀死进程时,由于程序无法捕获和处理这种强制终止信号,进程会被直接杀死,因此 ShutdownHook 无法顺利执行。