封面来源:碧蓝航线 静海惊雷 活动CG

本文涉及的代码:springboot-study/extension-point/src/main/java/indi/mofan/shutdown

参考链接:

本文基于 SpringBoot 3.1.x

SpringBoot-Shutdown-Hook思维导图

1. 不同的线程名

SpringBoot 常用拓展点 一文中介绍过,当容器关闭时会触发两个拓展点,分别是 DisposableBeanSmartLifecycle

在使用这些拓展点时,打印出了执行它们的线程,比如以 DisposableBean 为例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Configuration
public class MyShutDownConfig {
@Bean
public MyDisposableBean myDisposableBean() {
return new MyDisposableBean();
}

static class MyDisposableBean implements DisposableBean {
@Override
public void destroy() {
// 不同的关闭方式,导致不同的线程来执行
System.out.printf("[%s]: 执行 Bean 的销毁方法", Thread.currentThread().getName());
}
}
}

提供 shutdown() 方法,获取一个 [0, 9] 之间的随机数,当这个随机数是偶数时,使用 context.close() 关闭容器,否则使用 System.exit(0) 退出程序:

1
2
3
4
5
6
7
8
9
10
11
12
public class ShutdownUtil {
public static void shutdown(ConfigurableApplicationContext context) {
int randomInt = new Random().nextInt(0, 10);
if ((randomInt & 0b1) == 0) {
System.out.printf("[%s]: 即将执行 context.close() 关闭容器\n", Thread.currentThread().getName());
context.close();
} else {
System.out.printf("[%s]: 即将执行 System.exit(0) 退出程序\n", Thread.currentThread().getName());
System.exit(0);
}
}
}
1
2
3
4
5
6
7
8
@SpringBootApplication
public class SpringBootShutdownHook {
public static void main(String[] args) {
ConfigurableApplicationContext context =
SpringApplication.run(SpringBootShutdownHook.class);
ShutdownUtil.shutdown(context);
}
}

多次运行程序,可以得到不同的输出,比如:

[main]: 即将执行 context.close() 关闭容器
[main]: 执行 Bean 的销毁方法

再比如:

[main]: 即将执行 System.exit(0) 退出程序
[SpringApplicationShutdownHook]: 执行 Bean 的销毁方法

可以发现:

  • 使用 ConfigurableApplicationContext#close() 关闭容器时,销毁方法在主(main)线程中被执行;
  • 使用 System.exit(0) 退出程序时,销毁方法会在名为 SpringApplicationShutdownHook 的线程中被执行。

为什么会这样呢?问题可能就在 System.exit() 方法。

2. System.exit()

2.1 使用方式

这是 System 类中的一个静态方法:

1
2
3
public static void exit(int status) {
Runtime.getRuntime().exit(status);
}

System 类还是非常熟悉的,学习 Java 遇到的第一个类就是 System,因为要用 System.out.println() 打印 Hello World 🤣, 除此之外,还可以使用 System.getProperty() 获取一些环境变量,比如:

1
2
// 获取 JDK 版本
System.getProperty("java.version")

言归正传,System.exit() 方法有什么用呢?它的方法注释是这么说的:

Terminates the currently running Java Virtual Machine. The
argument serves as a status code; by convention, a nonzero status
code indicates abnormal termination.

This method calls the exit method in class Runtime. This method never returns normally.

The call System.exit(n) is effectively equivalent to the call: Runtime.getRuntime().exit(n)

翻译下:System.exit() 方法是用来终止当前正在运行的 Java 虚拟机。方法参数作为状态码,按照惯例,非零状态码表示异常终止。System.exit() 方法调用了 Runtimeexit() 方法。 这个方法从不正常返回。 调用 System.exit(n) 等价于调用 Runtime.getRuntime().exit(n)

2.2 从不正常返回

在面试题中经常会看到 try-catch-finally 搭配 return,并要求算出最终返回的值。比如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
private static int tryCatchFinallyQuestion(int param) {
try {
if (param > 10) {
throw new RuntimeException();
}
System.out.println("try param: " + param);
return param;
} catch (Exception e) {
param++;
System.out.println("catch param: " + param);
return param;
} finally {
param++;
System.out.println("finally param: " + param);
if (param > 20 || param < 0) {
return param;
}
}
}

public static void main(String[] args) {
int result = tryCatchFinallyQuestion(2);
System.out.println("result: " + result);
System.out.println("------------------------");
result = tryCatchFinallyQuestion(-1);
System.out.println("result: " + result);
System.out.println("------------------------");
result = tryCatchFinallyQuestion(11);
System.out.println("result: " + result);
System.out.println("------------------------");
result = tryCatchFinallyQuestion(21);
System.out.println("result: " + result);
}

输出是什么?

try param: 2
finally param: 3
result: 2
------------------------
try param: -1
finally param: 0
result: -1
------------------------
catch param: 12
finally param: 13
result: 12
------------------------
catch param: 22
finally param: 23
result: 23

问题不难,主要是想说明就算 return 了,finally 块中的代码也依旧会执行,如果 finally 块中也 return 了,甚至会以 finally 块中的 return 为主。

那么这个方法会输出什么呢?

1
2
3
4
5
6
7
8
9
10
private static void neverReturnsNormally() {
try {
System.out.println("try block");
System.exit(0);
} catch (Exception e) {
System.out.println("catch block");
} finally {
System.out.println("finally block");
}
}

finally 块中的代码还会执行吗?

try block

答案是否定的,只执行了 try 块中的代码,这就是 System.exit() 方法从不正常返回的含义。

这其实很好理解,System.exit() 方法会终止正在运行的 JVM,JVM 都被终止了,finally 块中的代码还怎么执行?

2.3 源码剖析

System.exit() 方法其实是调用了 Runtime 类中的 exit() 方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class Runtime {
private static final Runtime currentRuntime = new Runtime();

private static Version version;

public static Runtime getRuntime() {
return currentRuntime;
}

// --snip--

public void exit(int status) {
@SuppressWarnings("removal")
SecurityManager security = System.getSecurityManager();
if (security != null) {
security.checkExit(status);
}
Shutdown.exit(status);
}

// --snip--
}

然后又调用了 Shutdown 中的 exit() 方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
static void exit(int status) {
synchronized (lock) {
if (status != 0 && VM.isShutdown()) {
/* Halt immediately on nonzero status */
// 非零状态立即停止
halt(status);
}
}
synchronized (Shutdown.class) {
/* Synchronize on the class object, causing any other thread
* that attempts to initiate shutdown to stall indefinitely
*/
beforeHalt();
runHooks();
halt(status);
}
}

主要方法有三个,其中的 beforeHalt() 是一个本地方法,而 halt() 方法最终也是去调用的本地方法,略过它们,重点放在 java.lang.Shutdown#runHooks() 方法上:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
private static void runHooks() {
synchronized (lock) {
/* Guard against the possibility of a daemon thread invoking exit
* after DestroyJavaVM initiates the shutdown sequence
*/
if (VM.isShutdown()) return;
}

for (int i=0; i < MAX_SYSTEM_HOOKS; i++) {
try {
Runnable hook;
// 这个锁是什么意思?hook 不是局部变量吗?
synchronized (lock) {
// acquire the lock to make sure the hook registered during
// shutdown is visible here.
currentRunningHook = i;
hook = hooks[i];
}
if (hook != null) hook.run();
} catch (Throwable t) {
if (t instanceof ThreadDeath td) {
throw td;
}
}
}

// set shutdown state
VM.shutdown();
}

主要逻辑是循环 MAX_SYSTEM_HOOKS,然后从 hooks 数组中取出 hook 并一一执行。

MAX_SYSTEM_HOOKS 是一个整型常量,数值为 10,难道 Java 最多只能让用户定义 10 个 Shutdown Hook?

1
2
3
4
5
6
7
8
// The system shutdown hooks are registered with a predefined slot.
// The list of shutdown hooks is as follows:
// (0) Console restore hook
// (1) ApplicationShutdownHooks that invokes all registered application
// shutdown hooks and waits until they finish
// (2) DeleteOnExit hook
private static final int MAX_SYSTEM_HOOKS = 10;
private static final Runnable[] hooks = new Runnable[MAX_SYSTEM_HOOKS];

原来这里的 hooks 并不是用户自定义的 Shutdown Hook,而是预定义的系统 Shutdown Hook,在 JDK17 中按以下顺序预定了三个:

  1. Console restore hook
  2. ApplicationShutdownHooks
  3. DeleteOnExit hook

其中的 ApplicationShutdownHooks 才是用户自定义的 Shutdown Hook。

ApplicationShutdownHooks 又是怎么被定义的呢?

先看 java.lang.Shutdown#hooks 常量在哪里被赋值:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
static void add(int slot, boolean registerShutdownInProgress, Runnable hook) {
if (slot < 0 || slot >= MAX_SYSTEM_HOOKS) {
throw new IllegalArgumentException("Invalid slot: " + slot);
}
synchronized (lock) {
if (hooks[slot] != null)
throw new InternalError("Shutdown hook at slot " + slot + " already registered");

if (!registerShutdownInProgress) {
if (currentRunningHook >= 0)
throw new IllegalStateException("Shutdown in progress");
} else {
if (VM.isShutdown() || slot <= currentRunningHook)
throw new IllegalStateException("Shutdown in progress");
}

hooks[slot] = hook;
}
}

java.lang.Shutdown#add() 方法中存在赋值逻辑,再看 add() 方法会在哪被调用,其中一处是在 ApplicationShutdownHooks 类中,光看这类名就感觉不简单:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class ApplicationShutdownHooks {
/* The set of registered hooks */
private static IdentityHashMap<Thread, Thread> hooks;
static {
try {
Shutdown.add(1 /* shutdown hook invocation order */,
false /* not registered if shutdown in progress */,
new Runnable() {
public void run() {
runHooks();
}
}
);
hooks = new IdentityHashMap<>();
} catch (IllegalStateException e) {
// application shutdown hooks cannot be added if
// shutdown is in progress.
hooks = null;
}
}

// --snip--
}

ApplicationShutdownHooks 类初始化时会执行 static 块,在这里面注册了 ApplicationShutdownHooks。其中的 runHooks() 方法负责执行用户所有自定义的 Shutdown Hook:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
static void runHooks() {
Collection<Thread> threads;
synchronized(ApplicationShutdownHooks.class) {
threads = hooks.keySet();
hooks = null;
}

// 每一个 Shutdown Hook 对应一个 Thread
for (Thread hook : threads) {
hook.start();
}
for (Thread hook : threads) {
while (true) {
try {
// 死等每个 Hook 执行完
hook.join();
break;
} catch (InterruptedException ignored) {
}
}
}
}

ApplicationShutdownHooks 类中的 hooks 才是用户自定义的 Shutdown Hook,并且每一个 Hook 对应一个 Thread 对象,在执行时,循环调用它们的 start() 方法开启线程,这说明用户自定义的 Shutdown Hook 是并发无序执行的。

然后又对注册的每个 Hook 进行遍历,采用死循环并调用 join() 方法死等每个 Hook 执行完,也就是说, 如果有某个 Hook 在执行时内部发生死锁或死循环,调用的 System.exit() 方法会一直执行,始终无法正常退出程序。

用户自定义 Shutdown Hook 的执行解决了,那要怎么注册呢?

查看 java.lang.ApplicationShutdownHooks#hooks 变量会在哪被使用,或者说在哪被初始化?

java.lang.ApplicationShutdownHooks#add() 方法中调用了 hooks.put() 方法:

1
2
3
4
5
6
7
8
9
10
11
12
static synchronized void add(Thread hook) {
if(hooks == null)
throw new IllegalStateException("Shutdown in progress");

if (hook.isAlive())
throw new IllegalArgumentException("Hook already running");

if (hooks.containsKey(hook))
throw new IllegalArgumentException("Hook previously registered");

hooks.put(hook, hook);
}

但这个方法并不是外部可见的,它又会在哪被调用呢?

它只会在 java.lang.Runtime#addShutdownHook() 方法中被调用:

1
2
3
4
5
6
7
8
public void addShutdownHook(Thread hook) {
@SuppressWarnings("removal")
SecurityManager sm = System.getSecurityManager();
if (sm != null) {
sm.checkPermission(new RuntimePermission("shutdownHooks"));
}
ApplicationShutdownHooks.add(hook);
}

java.lang.Runtime#addShutdownHook() 是外部可见的,用户可以使用该方法来注册自定义的 Shutdown Hook。

2.4 给局部变量赋值加锁?

System.exit() 的源码中有个细节,它在 java.lang.Shutdown#runHooks() 方法中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
private static void runHooks() {
// --snip--

for (int i=0; i < MAX_SYSTEM_HOOKS; i++) {
try {
Runnable hook;
synchronized (lock) {
currentRunningHook = i;
// hook 是局部变量,加个锁是啥意思?
hook = hooks[i];
}
// --snip--
} catch (Throwable t) {
// --snip--
}
}
// --snip--
}

hook 是一个局部变量,为它的赋值加个锁是什么意思?局部变量又不会出现线程安全问题。

这里使用了 Happens-Before 原则,通常认为 Happens-Before 原则共有 8 条,其中一条名为 Monitor Lock Rule,其含义是:

An unlock on a monitor happens-before every subsequent lock on that monitor

一个锁的解锁总是发生在后续对这个锁的加锁之前。 比如以 synchronized 关键词为例:

1
2
3
4
5
6
7
8
9
10
11
public class MonitorLockRule {
private int x = 0;

public void changeX() {
synchronized (this) {
if (this.x < 10) {
this.x = 10;
}
}
}
}

在进入 synchronized 块之前,会自动加锁,代码块执行完毕后,又会自动解锁,加锁与解锁操作由编译器完成。

x 的初始值是 0,当线程 A 执行完 changeX() 方法,后续线程 B 执行 changeX() 方法时,线程 B 对能够感知到 x 变成了 10,保证了 x 的内存可见性,这个操作也是符合自觉的。

回到对局部变量 hook 的赋值,单从这里看确实有点迷,此处是对成员变量 hooks 的读操作,结合它的写操作来看看,即 java.lang.Shutdown#add() 方法:

1
2
3
4
5
6
7
8
static void add(int slot, boolean registerShutdownInProgress, Runnable hook) {
// --snip--
synchronized (lock) {
// --snip--

hooks[slot] = hook;
}
}

在写操作时,也进行了加锁操作,这样与读操作的加锁操作结合,就能满足 Happens-Before 原则。

如果局部变量 hook 的赋值 没有加锁

假设 hooks 现在前三个位置有值,也就是系统预定义的 Shutdown Hook,记为 A、B、C。

再假设执行到 B 时,在另一个线程中修改 hooks[2],将其由 C 修改为 D,整个修改操作比较慢,当 B 都执行完了,准备执行下一个 Shutdown Hook 时,修改操作都还没有完成。

由于局部变量 hook 的赋值没有加锁,此时读取到的下一个 Shutdown Hook 仍然是 C,而不是 D。

如果局部变量 hook 的赋值 加了锁

还是上述的场景,执行 B 没有加锁操作,因此在另一个线程将 C 修改为 D 时能够直接拿到锁,整个操作还是很慢,当 B 执行完了后,准备执行下一个 Shutdown Hook 时,需要先进行局部变量 hook 的赋值,此时需要一个锁,但锁仍被另一个线程持有,等到将 C 成功修改为 D 之后释放了锁,才能进行 hook 的赋值操作。

此时 C 已经被修改成了 D,再去读取下一个 Shutdown Hook 时,读取到的就是 D 而不是 C。

2.5 Java Shutdown Hook

触发时机

触发时机可以分为两种,分别是 程序正常退出程序响应外部事件

  1. 程序正常退出:
    • 最后一个非守护线程执行完毕时退出
    • System.exit() 方法被调用时
  2. 程序响应外部事件
    • 响应用户输入,比如使用 Ctrl + c
    • 捕获 kill 信号,比如 kill -1 (重新加载进程)、kill -2 (中断进程,与 Ctrl + c 类似)、kill -15 (正常停止一个进程),但熟悉的 kill -9 并不会触发 Shutdown Hook,因为它是直接杀死进程
    • 响应系统事件,比如用户注销、系统关机

存在的意义

Java Shutdown Hook 的存在是为了实现优雅停机,比如在停机时实现资源的关闭、保证业务的连续性。

以保证业务连续性为例,在一个系统中存在生产者和消费者,当该系统被停机时,生产者与消费者依次下线,还需要:

  • 后下线的消费者不能请求到已下线的生产者
  • 对于已经产生的请求,要求消费者处理完毕后才下线(可以设置一个超时时间,防止消费者迟迟不下线)

以上需求都可以通过 Java Shutdown Hook 来完成。

3. SpringBoot 优雅停机

3.1 注册 Shutdown Hook

在执行 SpringApplication#run(java.lang.String...) 方法时,会调用 refreshContext() 方法:

1
2
3
4
5
6
7
8
9
private void refreshContext(ConfigurableApplicationContext context) {
// 默认值 true,默认注册 Shutdown Hook
if (this.registerShutdownHook) {
// 注册 Shutdown Hook
shutdownHook.registerApplicationContext(context);
}
// 经典的 refresh 方法
refresh(context);
}

默认情况下会注册 Shutdown Hook,执行 SpringApplicationShutdownHook#registerApplicationContext() 方法完成 Shutdown Hook 的注册:

1
2
3
4
5
6
7
8
void registerApplicationContext(ConfigurableApplicationContext context) {
addRuntimeShutdownHookIfNecessary();
synchronized (SpringApplicationShutdownHook.class) {
assertNotInProgress();
context.addApplicationListener(this.contextCloseListener);
this.contexts.add(context);
}
}

重点在其中的 addRuntimeShutdownHookIfNecessary() 方法:

1
2
3
4
5
private void addRuntimeShutdownHookIfNecessary() {
if (this.shutdownHookAdditionEnabled && this.shutdownHookAdded.compareAndSet(false, true)) {
addRuntimeShutdownHook();
}
}

再进入 addRuntimeShutdownHook() 方法:

1
2
3
void addRuntimeShutdownHook() {
Runtime.getRuntime().addShutdownHook(new Thread(this, "SpringApplicationShutdownHook"));
}

调用了前文说到的 java.lang.Runtime#addShutdownHook() 方法完成 Shutdown Hook 的注册。

线程名被定义为 SpringApplicationShutdownHook,与开篇测试时打印的线程名一致。

构造方法的第一个参数传入了 this,这是因为当前类 SpringApplicationShutdownHook 实现了 Runnable 接口,重写后的 run() 方法如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Override
public void run() {
Set<ConfigurableApplicationContext> contexts;
Set<ConfigurableApplicationContext> closedContexts;
Set<Runnable> actions;
synchronized (SpringApplicationShutdownHook.class) {
this.inProgress = true;
contexts = new LinkedHashSet<>(this.contexts);
closedContexts = new LinkedHashSet<>(this.closedContexts);
actions = new LinkedHashSet<>(this.handlers.getActions());
}
contexts.forEach(this::closeAndWait);
closedContexts.forEach(this::closeAndWait);
actions.forEach(Runnable::run);
}

比较简单,重点在 closeAndWait() 方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
private void closeAndWait(ConfigurableApplicationContext context) {
if (!context.isActive()) {
return;
}
// 还是调用了 close() 方法
context.close();
try {
int waited = 0;
while (context.isActive()) {
if (waited > TIMEOUT) {
throw new TimeoutException();
}
Thread.sleep(SLEEP);
waited += SLEEP;
}
}
catch (InterruptedException ex) {
Thread.currentThread().interrupt();
// 省略日志的打印
}
catch (TimeoutException ex) {
// 省略日志的打印
}
}

closeAndWait() 方法中,还是调用了 close() 方法,这也是为什么使用 context.close() 关闭容器和 System.exit(0) 退出程序执行的逻辑基本一致的原因,它们最终都会调用 context.close() 方法,只不过执行的线程不一样。

3.2 只使用了 Spring

在 SpringBoot 应用中会自动注册 Shutdown Hook,如果只使用了 Spring 呢(虽然现在不大可能了)?

只使用了 Spring 的情况下,不会自动注册 Shutdown Hook,需要手动调用 AbstractApplicationContext#registerShutdownHook() 方法进行注册。比如:

1
2
3
4
5
6
7
8
9
public class SpringShutdownHook {
public static void main(String[] args) {
AnnotationConfigApplicationContext context =
new AnnotationConfigApplicationContext(MyShutDownConfig.class);
// 非 SpringBoot 环境下,需要手动注册
context.registerShutdownHook();
ShutdownUtil.shutdown(context);
}
}

运行结果与 SpringBoot 应用基本无异。

看看 registerShutdownHook() 方法做了啥:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Override
public void registerShutdownHook() {
if (this.shutdownHook == null) {
// No shutdown hook registered yet.
this.shutdownHook = new Thread(SHUTDOWN_HOOK_THREAD_NAME) {
@Override
public void run() {
synchronized (startupShutdownMonitor) {
doClose();
}
}
};
Runtime.getRuntime().addShutdownHook(this.shutdownHook);
}
}

其实就是实例化了个 Thread,重写的 run() 方法中调用了 doClose() 方法,最后注册 Shutdown Hook。

至于 doClose() 方法也没啥可分析的,如果调用 context.close() 方法最终也会调用 doClose() 方法,两个都差不多。

关于 doClose() 方法的内部执行逻辑,可以参考 SpringBoot 常用拓展点 一文中对 DisposableBeanSmartLifecycle 两个拓展点执行的分析。

哪有什么岁月静好,都是 SpringBoot 在为你负重前行。👻

4. 无法正常退出

通过前文对 System.exit() 方法的源码剖析可知: 用户自定义的 Shutdown Hook 会并发无序执行,并且每执行一个 Shutdown Hook 时,会死等它执行完。

那么这可能会导致尽管调用了 System.exit() 方法,但程序仍然无法正常退出。比如某个 Shutdown Hook 中:

  • 存在死循环
  • 存在死锁

4.1 存在死循环

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
public class UnableExit {
public static void main(String[] args) {
AnnotationConfigApplicationContext context =
new AnnotationConfigApplicationContext(Config.class);
context.registerShutdownHook();
simpleExit();
}

private static void simpleExit() {
System.exit(0);
}

@Configuration
static class Config {
@Bean
public UnableExitApi unableExitApi() {
return new EndlessLoop();
}
}

interface UnableExitApi {
}

@SuppressWarnings("all")
static class EndlessLoop implements UnableExitApi, DisposableBean {
@Override
public void destroy() throws Exception {
while (true) {
System.out.println("I'm still alive");
TimeUnit.SECONDS.sleep(5);
}
}
}
}

每隔 5 秒就打印出 I'm still alive,程序始终无法正常退出。

4.2 存在死锁

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
public class UnableExit {
public static void main(String[] args) {
AnnotationConfigApplicationContext context =
new AnnotationConfigApplicationContext(Config.class);
context.registerShutdownHook();
exitWithLock();
}

private static final Lock LOCK = new ReentrantLock();

private static void exitWithLock() {
LOCK.lock();
System.exit(0);
LOCK.unlock();
}

@Configuration
static class Config {
@Bean
public UnableExitApi unableExitApi() {
return new Deadlock();
}
}

static class Deadlock implements UnableExitApi, DisposableBean {
@Override
public void destroy() {
LOCK.lock();
System.out.println("Hey! I'm still alive!");
LOCK.unlock();
System.out.println("Oh, no! I'm dead.");
}
}
}

控制台一片空白,没有输出任何内容,程序也无法正常退出。

当执行 exitWithLock() 方法时,获取到锁 LOCK,然后执行 System.exit(0) 触发 Shutdown Hook,在执行 Shutdown Hook 时会执行 Deadlock#destroy() 方法,该方法也需要获取到锁 LOCK,此时的锁仍被主线程占用,需要 System.exit(0) 方法执行完才能解锁,但它能执行完的前提是能够执行完 destroy() 方法,这就出现:

  • main:你执行完,我就解锁;
  • SpringContextShutdownHook:你解锁,我才能执行完;

形成死锁,无法正常退出。

如果进行一点点改动,比如:

1
2
3
4
5
6
private static void exitWithLock() {
LOCK.lock();
// 在另一个线程中执行 System.exit(0)
new Thread(() -> System.exit(0)).start();
LOCK.unlock();
}

此时再运行程序,控制台打印出:

Hey! I'm still alive!
Oh, no! I'm dead.

程序也能正常退出。

这其实很好理解,退出程序的逻辑不在主线程中,那个线程不持有任何锁,主线程一路向下执行并释放锁,释放锁的瞬间,执行 Shutdown Hook 的线程就能拿到锁,然后执行,最终成功退出程序。

在这个基础上还能再变,比如在 Deadlock#destroy() 方法中又写个死循环,尽管退出程序的逻辑不在主线程中,程序又无法正常退出了。

5. 优雅关闭线程池

5.1 一般情况下

以 JDK 中的 ThreadPoolExecutor 为例,一般情况下关闭线程池涉及到三个方法:

  • shutdown()
  • shutdownNow()
  • awaitTermination()

一个任务一般会有以下几种情况:

  • 未提交到线程池
  • 已提交到线程池,但还未执行
  • 正在执行
  • 执行完毕

shutdown()

执行该方法后:

  • 拒绝新任务的提交,如果还有任务提交,则抛出 RejectedExecutionException
  • 待执行的任务继续执行
  • 正在执行的任务也继续执行

在该方法的注释上有这样一句话:

This method does not wait for previously submitted tasks to
complete execution. Use awaitTermination to do that.

它说:这个方法不会等待以前提交的任务执行完,要使用 awaitTermination() 方法来完成。

不是说待执行的任务还会继续执行吗,这里又说不等待是什么意思?看一个示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
public static void main(String[] args) {
useShutdownOnly();
}

private static ExecutorService getExecutorService() {
ExecutorService service = Executors.newFixedThreadPool(1);
for (int i = 1; i <= 5; i++) {
String str = i + "";
service.execute(new Runnable() {
@Override
public void run() {
System.out.println(str);
}

@Override
public String toString() {
return "第 " + str + " 个任务";
}
});
}
return service;
}

private static void useShutdownOnly() {
ExecutorService service = getExecutorService();
service.shutdown();
System.out.println("线程池关闭了");
}
线程池关闭了
1
2
3
4
5

主线程中的内容最先被输出,之后输出每个任务中的内容。

注释中说的“不会等待以前提交的任务执行完”指的是:这个方法不会阻塞,其他线程可以在这些任务前执行,如果要实现阻塞,可以使用 awaitTermination() 方法。比如:

1
2
3
4
5
6
7
8
@SneakyThrows
@SuppressWarnings("ResultOfMethodCallIgnored")
private static void shutdownAndAwait() {
ExecutorService service = getExecutorService();
service.shutdown();
service.awaitTermination(1, TimeUnit.MINUTES);
System.out.println("线程池关闭了");
}
1
2
3
4
5
线程池关闭了

主线程的内容在线程池的任务执行完毕后才被输出,主线程被阻塞了。

shutdownNow()

执行该方法后:

  • 拒绝新任务的提交,如果还有任务提交,则抛出 RejectedExecutionException
  • 取消待执行的任务,并将它们作为该方法的返回值
  • 尝试取消正在执行的任务,仅仅是尝试

看一个示例:

1
2
3
4
5
6
private static void shutdownNow() {
ExecutorService service = getExecutorService();
List<Runnable> list = service.shutdownNow();
System.out.println("线程池关闭了");
list.forEach(System.out::println);
}
线程池关闭了
1
第 2 个任务
第 3 个任务
第 4 个任务
第 5 个任务

主线程的内容输出后,正在执行的任务才执行完,也可以使用 shutdownAndAwait() 方法阻塞下:

1
2
3
4
5
6
7
8
9
@SneakyThrows
@SuppressWarnings("ResultOfMethodCallIgnored")
private static void shutdownNowAndAwait() {
ExecutorService service = getExecutorService();
List<Runnable> list = service.shutdownNow();
service.awaitTermination(1, TimeUnit.MINUTES);
System.out.println("线程池关闭了");
list.forEach(System.out::println);
}
1
线程池关闭了
第 2 个任务
第 3 个任务
第 4 个任务
第 5 个任务

综上可知,一般情况下关闭线程池可以使用 shutdown() + awaitTermination(),或者 shutdownNow() + awaitTermination()

当然也可以来个三合一,比如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
ExecutorService executor = Executors.newCachedThreadPool();

// 关闭线程池
executor.shutdown();
try {
// 等待线程池在指定时间内完成关闭
if (!executor.awaitTermination(60, TimeUnit.SECONDS)) {
// 如果超时未完成,强制关闭
executor.shutdownNow();
}
} catch (InterruptedException e) {
// 当前线程被中断,也强制关闭线程池
executor.shutdownNow();
// 恢复中断状态
Thread.currentThread().interrupt();
}

5.2 错误关闭线程池

当执行某个任务依赖容器的某个 Bean 时,但这个 Bean 在线程池关闭期间被先行销毁了,导致剩余任务不能执行完,这就会导致业务的不完整。

比如在实际场景中,线程池中的某个任务去操作了 Redis,当任务还未执行完时,收到了停机的指令,此时触发 Shutdown Hook,但 Redis 连接先一步被回收,导致线程池中的任务获取不到 Redis 连接,进而产生报错。

模拟下这个场景:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
@SpringBootApplication
public class ErrorShutdownThreadPool {
public static void main(String[] args) {
ConfigurableApplicationContext context = SpringApplication.run(ErrorShutdownThreadPool.class);
executeManually(context);
System.exit(0);
}

private static Object connection = new Object();

@Component
static class RedisService implements DisposableBean {
public Object getConnection() {
if (connection == null) {
throw new RuntimeException("connection is null");
}
return connection;
}

@Override
public void destroy() {
connection = null;
}
}

private static void executeManually(ApplicationContext context) {
RedisService redisService = context.getBean(RedisService.class);
ExecutorService service = Executors.newFixedThreadPool(1);
execute(service, redisService);

Runtime.getRuntime().addShutdownHook(new Thread(destroyThreadPool(service)));
}

private static Runnable destroyThreadPool(ExecutorService service) {
return () -> {
service.shutdown();
try {
// noinspection ResultOfMethodCallIgnored
service.awaitTermination(1, TimeUnit.MINUTES);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
};
}

private static void execute(Executor service, RedisService redisService) {
service.execute(() -> {
System.out.println("开始执行任务一");
System.out.println("获取连接: " + redisService.getConnection());
try {
TimeUnit.SECONDS.sleep(3);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
System.out.println("任务一执行结束");
});

service.execute(() -> {
System.out.println("开始执行任务二");
System.out.println("获取连接: " + redisService.getConnection());
try {
TimeUnit.SECONDS.sleep(3);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
System.out.println("任务二执行结束");
});
}
}

Redis 连接的回收由 SpringBoot Shutdown Hook 完成,而线程池的关闭由另一个 Shutdown Hook 完成,它们并行执行,此时就可能出现问题:

开始执行任务一
获取连接: java.lang.Object@69ede4fc
任务一执行结束
开始执行任务二
Exception in thread "pool-2-thread-1" java.lang.RuntimeException: connection is null

执行任务二获取连接时,连接已被回收,导致抛出异常。

5.3 优雅关闭

将线程池交给 Spring 管理,并且实现 DisposableBean 接口完成线程池的关闭即可。比如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@Component
static class MyThreadPool implements ApplicationRunner, DisposableBean {

@Autowired
private RedisService redisService;

ExecutorService service = Executors.newFixedThreadPool(1);

@Override
public void run(ApplicationArguments args) throws Exception {
// 模拟线程池中有未完成的任务
execute(service, redisService);
}

@Override
public void destroy() {
destroyThreadPool(service).run();
}
}

此时再次运行,能够使得线程池先关闭再回收连接:

开始执行任务一
获取连接: java.lang.Object@50be9718
任务一执行结束
开始执行任务二
获取连接: java.lang.Object@50be9718
任务二执行结束

原理和 DisposableBean 的执行有关,可以参考 SpringBoot 常用拓展点 一文中对 DisposableBean 执行的讲解。简单来说就是 SpringBoot 在执行销毁方法时,会判断哪些 Bean 依赖了当前 Bean,将那些 Bean 的销毁方法执行完毕后再执行当前 Bean 的销毁方法。

假设准备先执行 RedisService 的销毁方法,发现 MyThreadPool 又依赖了它,进而先执行后者的销毁方法并关闭线程池,之后再回收连接。

为了简化开发,SpringBoot 提供了类似的类,即 ThreadPoolTaskExecutorRedisTemplate,销毁方法都由 SpringBoot 重写好了,直接使用即可。