Java Shutdown Hook 場景使用和源碼分析


我是陳皮,一個在互聯網 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) {
	// 省略
}

關閉鈎子被調用場景

關閉鈎子可以在以下幾種場景被調用:

  1. 程序正常退出
  2. 程序調用 System.exit() 退出
  3. 終端使用 Ctrl+C 中斷程序
  4. 程序拋出異常導致程序退出,例如 OOM,數組越界等異常
  5. 系統事件,例如用戶注銷或關閉系統
  6. 使用 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);
	}
}


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM