Java并发编程实战 08 | 彻底理解Shutdown Hook

钩子线程(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 机制可以完成一些在程序退出前的清理和后续处理工作,例如:

  1. 释放资源:在 Hook 中释放文件句柄、数据库连接等资源,避免资源泄漏。
  2. 关闭服务:在 Hook 中关闭服务器,确保所有请求都已处理完毕,安全地终止服务。
  3. 发送通知:在 Hook 中发送电子邮件、短信等通知,告知用户或管理员服务已停止。
  4. 记录日志:在 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 机制使用注意事项

  1. Hook线程只有在正确接收到退出信号的情况下才能正常执行。如果你通过强制方法(例如 kill -9)杀死进程,Hook 线程将不会被执行,因为它们无法应对这种情况。
  2. 不要在 Hook 线程中执行会导致程序长时间无法退出的耗时操作。
  3. 尽量避免在 Hook 线程中抛出异常,否则可能导致 Java 虚拟机无法正常退出。
  4. Shutdown Hooks 的注册顺序非常重要,需要根据它们之间的依赖关系进行合理安排。通常应先注册简单的 Shutdown Hooks,再注册复杂的。
  5. 尽量不要在 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个方面入手:

  1. 如何注册一个ShutdownHook线程;
  2. 如何执行 ShutdownHook 线程。
  3. 当 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包,然后在命令行中执行,看下图,主要分为五个部分:

  1. 运行JAR包,启动进程;
  2. 主线程正在执行,并监听信号;
  3. 用户输入信号ctrl + c,即INT-2
  4. 收到信号后,输出信号类型,流程结束;
  5. 在结束进程之前,执行ShowdonwHook的逻辑。

需要注意的是,一般来说,当我们捕获到信号后,完成个性化处理后,需要主动调用 System.exit,否则进程将不会退出,只能通过 kill -9 强制杀死进程。

此外,由于各个信号的捕获是在不同的线程中进行的,因此它们的执行是异步的。

Shutdown.shutdown

该方法的调用时机可以从代码注释中找到:

在 Java 中,线程分为两种类型:用户线程和守护线程。守护线程是服务于用户线程的,例如垃圾回收线程(GC)。JVM 判断是否可以结束的标志是是否还有用户线程在运行。当最后一个用户线程结束时,Shutdown.shutdown 会被调用。这是 JVM 和虚拟机语言特有的“特权”。关于守护线程的更多细节将在后面的文章中介绍。

因此,通过对 Shutdown.exit 和 Shutdown.shutdown 的分析,我们可以总结出以下结论:

其实,Java 的 ShutdownHook 已经覆盖了大部分终止场景,但有一个情况无法处理,那就是当我们使用 kill -9 强制杀死进程时,由于程序无法捕获和处理这种强制终止信号,进程会被直接杀死,因此 ShutdownHook 无法顺利执行。

  • 20
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值