我是陳皮,一個在網際網路 Coding 的 ITer,微信搜尋「陳皮的JavaLib」第一時間閱讀最新文章,回覆【資料】,即可獲得我精心整理的技術資料,電子書籍,一線大廠面試資料和優秀簡歷模板。
背景
如果想在 Java 程式退出時,包括正常和異常退出,做一些額外處理工作,例如資源清理,物件銷燬,記憶體資料持久化到磁碟,等待執行緒池處理完所有任務等等。特別是程式異常掛掉的情況,如果一些重要狀態沒及時保留下來,或執行緒池的任務沒被處理完,有可能會造成嚴重問題。那該怎麼辦呢?
Java 中的 Shutdown Hook
提供了比較好的方案。我們可以通過 Java.Runtime.addShutdownHook(Thread hook)
方法向 JVM 註冊關閉鉤子,在 JVM 退出之前會自動呼叫執行鉤子方法,做一些結尾操作,從而讓程式平滑優雅的退出,保證了業務的完整性。
Shutdown Hook 介紹
其實,shutdown hook
就是一個簡單的已初始化
但是未啟動
的執行緒
。當虛擬機器開始關閉時,它將會呼叫所有已註冊的鉤子,這些鉤子執行是併發的,執行順序是不確定的。
在虛擬機器關閉的過程中,還可以繼續註冊新的鉤子,或者撤銷已經註冊過的鉤子。不過有可能會丟擲 IllegalStateException
。註冊和登出鉤子的方法定義如下:
public void addShutdownHook(Thread hook) {
// 省略
}
public void removeShutdownHook(Thread hook) {
// 省略
}
關閉鉤子被呼叫場景
關閉鉤子可以在以下幾種場景被呼叫:
- 程式正常退出
- 程式呼叫 System.exit() 退出
- 終端使用 Ctrl+C 中斷程式
- 程式丟擲異常導致程式退出,例如 OOM,陣列越界等異常
- 系統事件,例如使用者登出或關閉系統
- 使用 Kill pid 命令殺掉程式,注意使用 kill -9 pid 強制殺掉不會觸發執行鉤子
驗證程式正常退出情況
package com.chenpi;
public class ShutdownHookDemo {
static {
Runtime.getRuntime().addShutdownHook(new Thread(() -> System.out.println("執行鉤子方法...")));
}
public static void main(String[] args) throws InterruptedException {
System.out.println("程式開始啟動...");
Thread.sleep(2000);
System.out.println("程式即將退出...");
}
}
執行結果
程式開始啟動...
程式即將退出...
執行鉤子方法...
Process finished with exit code 0
驗證程式呼叫 System.exit() 退出情況
package com.chenpi;
public class ShutdownHookDemo {
static {
Runtime.getRuntime().addShutdownHook(new Thread(() -> System.out.println("執行鉤子方法...")));
}
public static void main(String[] args) throws InterruptedException {
System.out.println("程式開始啟動...");
Thread.sleep(2000);
System.exit(-1);
System.out.println("程式即將退出...");
}
}
執行結果
程式開始啟動...
執行鉤子方法...
Process finished with exit code -1
驗證終端使用 Ctrl+C 中斷程式,在命令列視窗中執行程式,然後使用 Ctrl+C 中斷
package com.chenpi;
public class ShutdownHookDemo {
static {
Runtime.getRuntime().addShutdownHook(new Thread(() -> System.out.println("執行鉤子方法...")));
}
public static void main(String[] args) throws InterruptedException {
System.out.println("程式開始啟動...");
Thread.sleep(2000);
System.out.println("程式即將退出...");
}
}
執行結果
D:\IdeaProjects\java-demo\java ShutdownHookDemo
程式開始啟動...
執行鉤子方法...
演示丟擲異常導致程式異常退出
package com.chenpi;
public class ShutdownHookDemo {
static {
Runtime.getRuntime().addShutdownHook(new Thread(() -> System.out.println("執行鉤子方法...")));
}
public static void main(String[] args) {
System.out.println("程式開始啟動...");
int a = 0;
System.out.println(10 / a);
System.out.println("程式即將退出...");
}
}
執行結果
程式開始啟動...
執行鉤子方法...
Exception in thread "main" java.lang.ArithmeticException: / by zero
at com.chenpi.ShutdownHookDemo.main(ShutdownHookDemo.java:12)
Process finished with exit code 1
至於系統被關閉,或者使用 Kill pid 命令殺掉程式就不演示了,感興趣的可以自行驗證。
注意事項
可以向虛擬機器註冊多個關閉鉤子,但是注意這些鉤子執行是併發的,執行順序是不確定的。
package com.chenpi;
public class ShutdownHookDemo {
static {
Runtime.getRuntime().addShutdownHook(new Thread(() -> System.out.println("執行鉤子方法A...")));
Runtime.getRuntime().addShutdownHook(new Thread(() -> System.out.println("執行鉤子方法B...")));
Runtime.getRuntime().addShutdownHook(new Thread(() -> System.out.println("執行鉤子方法C...")));
}
public static void main(String[] args) throws InterruptedException {
System.out.println("程式開始啟動...");
Thread.sleep(2000);
System.out.println("程式即將退出...");
}
}
執行結果
程式開始啟動...
程式即將退出...
執行鉤子方法B...
執行鉤子方法C...
執行鉤子方法A...
向虛擬機器註冊的鉤子方法需要儘快執行結束,儘量不要執行長時間的操作,例如 I/O 等可能被阻塞的操作,死鎖等,這樣就會導致程式短時間不能被關閉,甚至一直關閉不了。我們也可以引入超時機制強制退出鉤子,讓程式正常結束。
package com.chenpi;
public class ShutdownHookDemo {
static {
Runtime.getRuntime().addShutdownHook(new Thread(() -> {
// 模擬長時間的操作
try {
Thread.sleep(1000000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}));
}
public static void main(String[] args) throws InterruptedException {
System.out.println("程式開始啟動...");
Thread.sleep(2000);
System.out.println("程式即將退出...");
}
}
以上的鉤子執行時間比較長,最終會導致程式在等待很長時間之後才能被關閉。
如果 JVM 已經呼叫執行關閉鉤子的過程中,不允許註冊新的鉤子和登出已經註冊的鉤子,否則會報 IllegalStateException
異常。通過原始碼分析,JVM 呼叫鉤子的時候,即呼叫 ApplicationShutdownHooks#runHooks()
方法,會將所有鉤子從變數 hooks
取出,然後將此變數置為 null
。
// 呼叫執行鉤子
static void runHooks() {
Collection<Thread> threads;
synchronized(ApplicationShutdownHooks.class) {
threads = hooks.keySet();
hooks = null;
}
for (Thread hook : threads) {
hook.start();
}
for (Thread hook : threads) {
try {
hook.join();
} catch (InterruptedException x) { }
}
}
在註冊和登出鉤子的方法中,首先會判斷 hooks
變數是否為 null
,如果為 null 則丟擲異常。
// 註冊鉤子
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);
}
// 登出鉤子
static synchronized boolean remove(Thread hook) {
if(hooks == null)
throw new IllegalStateException("Shutdown in progress");
if (hook == null)
throw new NullPointerException();
return hooks.remove(hook) != null;
}
我們演示下這種情況
package com.chenpi;
public class ShutdownHookDemo {
static {
Runtime.getRuntime().addShutdownHook(new Thread(() -> {
System.out.println("執行鉤子方法...");
Runtime.getRuntime().addShutdownHook(new Thread(
() -> System.out.println("在JVM呼叫鉤子的過程中再新註冊鉤子,會報錯IllegalStateException")));
// 在JVM呼叫鉤子的過程中登出鉤子,會報錯IllegalStateException
Runtime.getRuntime().removeShutdownHook(Thread.currentThread());
}));
}
public static void main(String[] args) throws InterruptedException {
System.out.println("程式開始啟動...");
Thread.sleep(2000);
System.out.println("程式即將退出...");
}
}
執行結果
程式開始啟動...
程式即將退出...
執行鉤子方法...
Exception in thread "Thread-0" java.lang.IllegalStateException: Shutdown in progress
at java.lang.ApplicationShutdownHooks.add(ApplicationShutdownHooks.java:66)
at java.lang.Runtime.addShutdownHook(Runtime.java:211)
at com.chenpi.ShutdownHookDemo.lambda$static$1(ShutdownHookDemo.java:8)
at java.lang.Thread.run(Thread.java:748)
如果呼叫 Runtime.getRuntime().halt()
方法停止 JVM,那麼虛擬機器是不會呼叫鉤子的。
package com.chenpi;
public class ShutdownHookDemo {
static {
Runtime.getRuntime().addShutdownHook(new Thread(() -> System.out.println("執行鉤子方法...")));
}
public static void main(String[] args) {
System.out.println("程式開始啟動...");
System.out.println("程式即將退出...");
Runtime.getRuntime().halt(0);
}
}
執行結果
程式開始啟動...
程式即將退出...
Process finished with exit code 0
如果要想終止執行中的鉤子方法,只能通過呼叫 Runtime.getRuntime().halt()
方法,強制讓程式退出。在Linux環境中使用 kill -9 pid
命令也是可以強制終止退出。
package com.chenpi;
public class ShutdownHookDemo {
static {
Runtime.getRuntime().addShutdownHook(new Thread(() -> {
System.out.println("開始執行鉤子方法...");
Runtime.getRuntime().halt(-1);
System.out.println("結束執行鉤子方法...");
}));
}
public static void main(String[] args) {
System.out.println("程式開始啟動...");
System.out.println("程式即將退出...");
}
}
執行結果
程式開始啟動...
程式即將退出...
開始執行鉤子方法...
Process finished with exit code -1
如果程式使用 Java Security Managers
,使用 shutdown Hook 則需要安全許可權 RuntimePermission(“shutdownHooks”)
,否則會導致 SecurityException
。
實踐
例如,我們程式自定義了一個執行緒池,用來接收和處理任務。如果程式突然奔潰異常退出,這時執行緒池的所有任務有可能還未處理完成,如果不處理完程式就直接退出,可能會導致資料丟失,業務異常等重要問題。這時鉤子就派上用場了。
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
public class ShutdownHookDemo {
// 執行緒池
private static ExecutorService executorService = Executors.newFixedThreadPool(3);
static {
Runtime.getRuntime().addShutdownHook(new Thread(() -> {
System.out.println("開始執行鉤子方法...");
// 關閉執行緒池
executorService.shutdown();
try {
// 等待60秒
System.out.println(executorService.awaitTermination(60, TimeUnit.SECONDS));
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("結束執行鉤子方法...");
}));
}
public static void main(String[] args) throws InterruptedException {
System.out.println("程式開始啟動...");
// 向執行緒池新增10個任務
for (int i = 0; i < 10; i++) {
Thread.sleep(1000);
final int finalI = i;
executorService.execute(() -> {
try {
Thread.sleep(4000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("Task " + finalI + " execute...");
});
System.out.println("Task " + finalI + " is in thread pool...");
}
}
}
在命令列視窗中執行程式,在10個任務都提交到執行緒池之後,任務都還未處理完成之前,使用 Ctrl+C
中斷程式,最終在虛擬機器關閉之前,呼叫了關閉鉤子,關閉執行緒池,並且等待60秒讓所有任務執行完成。
Shutdown Hook 在 Spring 中的運用
Shutdown Hook 在 Spring 中是如何運用的呢。通過原始碼分析,Springboot 專案啟動時會判斷 registerShutdownHook
的值是否為 true,預設是 true,如果為真則向虛擬機器註冊關閉鉤子。
private void refreshContext(ConfigurableApplicationContext context) {
refresh(context);
if (this.registerShutdownHook) {
try {
context.registerShutdownHook();
}
catch (AccessControlException ex) {
// Not allowed in some environments.
}
}
}
@Override
public void registerShutdownHook() {
if (this.shutdownHook == null) {
// No shutdown hook registered yet.
this.shutdownHook = new Thread() {
@Override
public void run() {
synchronized (startupShutdownMonitor) {
// 鉤子方法
doClose();
}
}
};
// 底層還是使用此方法註冊鉤子
Runtime.getRuntime().addShutdownHook(this.shutdownHook);
}
}
在關閉鉤子的方法 doClose
中,會做一些虛擬機器關閉前處理工作,例如銷燬容器裡所有單例 Bean,關閉 BeanFactory,釋出關閉事件等等。
protected void doClose() {
// Check whether an actual close attempt is necessary...
if (this.active.get() && this.closed.compareAndSet(false, true)) {
if (logger.isDebugEnabled()) {
logger.debug("Closing " + this);
}
LiveBeansView.unregisterApplicationContext(this);
try {
// 釋出Spring 應用上下文的關閉事件,讓監聽器在應用關閉之前做出響應處理
publishEvent(new ContextClosedEvent(this));
}
catch (Throwable ex) {
logger.warn("Exception thrown from ApplicationListener handling ContextClosedEvent", ex);
}
// Stop all Lifecycle beans, to avoid delays during individual destruction.
if (this.lifecycleProcessor != null) {
try {
// 執行lifecycleProcessor的關閉方法
this.lifecycleProcessor.onClose();
}
catch (Throwable ex) {
logger.warn("Exception thrown from LifecycleProcessor on context close", ex);
}
}
// 銷燬容器裡所有單例Bean
destroyBeans();
// 關閉BeanFactory
closeBeanFactory();
// Let subclasses do some final clean-up if they wish...
onClose();
// Reset local application listeners to pre-refresh state.
if (this.earlyApplicationListeners != null) {
this.applicationListeners.clear();
this.applicationListeners.addAll(this.earlyApplicationListeners);
}
// Switch to inactive.
this.active.set(false);
}
}
我們知道,我們可以定義 bean 並且實現 DisposableBean
介面,重寫 destroy
物件銷燬方法。destroy 方法就是在 Spring 註冊的關閉鉤子裡被呼叫的。例如我們使用 Spring 框架的 ThreadPoolTaskExecutor
執行緒池類,它就實現了 DisposableBean 介面,重寫了 destroy 方法,從而在程式退出前,進行執行緒池銷燬工作。原始碼如下:
@Override
public void destroy() {
shutdown();
}
/**
* Perform a shutdown on the underlying ExecutorService.
* @see java.util.concurrent.ExecutorService#shutdown()
* @see java.util.concurrent.ExecutorService#shutdownNow()
*/
public void shutdown() {
if (logger.isInfoEnabled()) {
logger.info("Shutting down ExecutorService" + (this.beanName != null ? " '" + this.beanName + "'" : ""));
}
if (this.executor != null) {
if (this.waitForTasksToCompleteOnShutdown) {
this.executor.shutdown();
}
else {
for (Runnable remainingTask : this.executor.shutdownNow()) {
cancelRemainingTask(remainingTask);
}
}
awaitTerminationIfNecessary(this.executor);
}
}