來聊聊守護(hù)線程和 JVM 的優(yōu)雅關(guān)閉
本文原本是針對守護(hù)線程的一些探討,感覺知識點(diǎn)稍顯淺薄,故基于原有文章進(jìn)行迭代補(bǔ)充對于Java程序優(yōu)雅關(guān)閉的一些思考。
一、JVM中的關(guān)閉
1. 詳解虛擬機(jī)鉤子
在Java進(jìn)程開發(fā)中,對于重量級的系統(tǒng)資源關(guān)閉或者進(jìn)程資源整理或信號輸出,常常會通過Java內(nèi)置的addShutdownHook方法注冊回調(diào)函數(shù),確保在Java進(jìn)程關(guān)閉不再使用這些資源時將其釋放,例如hutool這個工具類對應(yīng)連接池的管理工具GlobalDSFactory,其底層就會在類加載初始化時利用addShutdownHook注冊一個連接池銷毀的回調(diào)函數(shù):
/*
* 設(shè)置在JVM關(guān)閉時關(guān)閉所有數(shù)據(jù)庫連接
*/
static {
// JVM關(guān)閉時關(guān)閉所有連接池
Runtime.getRuntime().addShutdownHook(new Thread() {
@Override
public void run() {
if (null != factory) {
factory.destroy();
StaticLog.debug("DataSource: [{}] destroyed.", factory.dataSourceName);
factory = null;
}
}
});
}
而虛擬機(jī)鉤子注冊的原理本質(zhì)上就是在調(diào)用addShutdownHook時,其底層將這個現(xiàn)場hook注冊到一個hooks的map容器中,并在shutdown的時候遍歷調(diào)用這些hook線程:
對應(yīng)的我們也給出addShutdownHook的實(shí)現(xiàn),可以看到其底層就是調(diào)用ApplicationShutdownHooks來注冊hook:
public void addShutdownHook(Thread hook) {
SecurityManager sm = System.getSecurityManager();
if (sm != null) {
sm.checkPermission(new RuntimePermission("shutdownHooks"));
}
ApplicationShutdownHooks.add(hook);
}
而步入這個add方法后可以看到其內(nèi)部本質(zhì)上就是在必要的校驗(yàn)后,存入到hooks這個map中:
private static IdentityHashMap<Thread, Thread> hooks;
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);
}
當(dāng)觸發(fā)虛擬機(jī)鉤子關(guān)閉時,其內(nèi)部就會針對hooks進(jìn)行遍歷并按照如下邏輯處理:
- 將hook線程啟動,執(zhí)行hook邏輯
- 調(diào)用join確保該hook能夠準(zhǔn)確執(zhí)行完成
static void runHooks() {
Collection<Thread> threads;
synchronized(ApplicationShutdownHooks.class) {
threads = hooks.keySet();
hooks = null;
}
//遍歷hook線程啟動
for (Thread hook : threads) {
hook.start();
}
for (Thread hook : threads) {
while (true) {
try {
//調(diào)用join加入主線程確保當(dāng)前線程能夠正確執(zhí)行完成
hook.join();
break;
} catch (InterruptedException ignored) {
}
}
}
}
當(dāng)所有關(guān)閉鉤子都執(zhí)行結(jié)束時,如果runFinalizersOnExit為true,那么JVM就會運(yùn)行終結(jié)器finalizers,此時JVM并不會停止或者關(guān)閉仍然在運(yùn)行的應(yīng)用線程。直到最終JVM結(jié)束,應(yīng)用線程才會被關(guān)閉,對應(yīng)的我們可以在源碼Shutdown的exit方法印證:
static void exit(int status) {
boolean runMoreFinalizers = false;
synchronized (lock) {
//......
case FINALIZERS:
if (status != 0) {
/* Halt immediately on nonzero status */
halt(status);
} else {
//......
//將runFinalizersOnExit賦值給runMoreFinalizers
runMoreFinalizers = runFinalizersOnExit;
}
break;
}
}
//如果runMoreFinalizers 為true,則運(yùn)行終結(jié)器
if (runMoreFinalizers) {
runAllFinalizers();
halt(status);
}
//......
}
2. 虛擬機(jī)鉤子串行化使用
需要注意的虛擬機(jī)鉤子注冊后的調(diào)用時機(jī),當(dāng)JVM執(zhí)行關(guān)閉鉤子的時候,如果守護(hù)或者非守護(hù)線程也在運(yùn)行,那么虛擬機(jī)鉤子就可能和這些線程并發(fā)的執(zhí)行,即虛擬機(jī)鉤子可能會并行的執(zhí)行一些工作,所以對于一些存在依賴性的共享數(shù)據(jù)操作,虛擬機(jī)鉤子要慎重使用。
例如我們用虛擬機(jī)鉤子將日志服務(wù)關(guān)閉,此時如果另外的虛擬機(jī)鉤子需要使用日志打印,可能就會報(bào)錯:
例如我們的日志框架LogService ,本質(zhì)上就是對于文件流的寫入和關(guān)閉:
static class LogService {
private static final BufferedWriter writer = FileUtil.getWriter("F:\\tmp\\log.txt", Charset.defaultCharset(), true);
@SneakyThrows
public void log(String msg) {//將數(shù)據(jù)寫入日志中
writer.write(msg);
}
public void close() {
try {
writer.close();
} catch (IOException e) {
throw new RuntimeException(e);
}
}
}
如下圖所說,若在虛擬機(jī)鉤子上注冊關(guān)閉打印和關(guān)閉日志框架的鉤子,就有可能出現(xiàn)打印鉤子拋出stream close的錯誤:
LogService logService = new LogService();
Runtime.getRuntime().addShutdownHook(new Thread(() -> {
//拋出stream close的錯誤
logService.log("hello world");
}));
/**
* 注冊虛擬機(jī)鉤子
*/
Runtime.getRuntime().addShutdownHook(new Thread(() -> {
//執(zhí)行一些應(yīng)用程序的資源關(guān)閉
logService.close();
}));
總的來說,使用虛擬機(jī)鉤子必須注意:
- 虛擬機(jī)鉤子要保證線程安全,即針對共享資源做好同步把控
- 虛擬機(jī)鉤子盡量串行化執(zhí)行,且鉤子之間不可以有任何依賴
- 關(guān)閉鉤子應(yīng)該盡快的退出,因?yàn)樗苯拥臎Q定的JVM退出的結(jié)束時間
二、守護(hù)線程
1. 守護(hù)線程的基本概念
很多人對守護(hù)線程都不陌生,對于守護(hù)線程大部分讀者都停留在JDK官方文檔所介紹的概念:
The Java Virtual Machine exits when the only threads running are all daemon threads.
文檔的意思是當(dāng)JVM中不存在任何一個正在運(yùn)行的非守護(hù)線程時,JVM進(jìn)程會直接退出。
讀起來很拗口對不對,沒關(guān)系,本文就會基于幾個代碼示例,讓你更深層次的理解守護(hù)線程。在此之前,讀者不妨自測一下,下面這幾道面試題:
- 守護(hù)線程和普通線程有什么區(qū)別?
- 守護(hù)線程默認(rèn)優(yōu)先級是多少?
- 若父線程為守護(hù)線程,在其內(nèi)部創(chuàng)建一個普通線程,父線程停止,子線程是否也會停止呢?
- 如何創(chuàng)建守護(hù)線程池?
- 守護(hù)線程使用有哪些注意事項(xiàng)?
2. 守護(hù)線程和普通線程的區(qū)別
要了解區(qū)別就先來了解一下兩者的使用,非守護(hù)線程,也就我們?nèi)粘?chuàng)建的普通線程,可以看到這段代碼創(chuàng)建了一個普通線程,在無限循環(huán)的定時輸出內(nèi)容,而主線程僅僅是輸出一段文字后就不做任何動作了。
public static void main(String[] args) {
Thread t = new Thread(() -> {
while (true) {
log.info("普通線程執(zhí)行了......");
try {
Thread.sleep(5000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
t.start();
log.info("主線程運(yùn)行結(jié)束");
}
對應(yīng)的輸出結(jié)果如下,可以看到,即使主線程停止運(yùn)行了,而非守護(hù)線程也仍然會在運(yùn)行,也就是JDK官方文檔的字面含義,普通線程不停止,JVM就不停止運(yùn)行:
12:44:57.022 [Thread-0] INFO com.sharkChili.webTemplate.Main - 普通線程執(zhí)行了......
12:44:57.022 [main] INFO com.sharkChili.webTemplate.Main - 主線程運(yùn)行結(jié)束
12:45:02.031 [Thread-0] INFO com.sharkChili.webTemplate.Main - 普通線程執(zhí)行了......
基于上述代碼,用setDaemon(true)將該線程設(shè)置為守護(hù)線程:
public static void main(String[] args) {
Thread t = new Thread(() -> {
while (true) {
log.info("守護(hù)線程執(zhí)行了......");
try {
Thread.sleep(5000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
//設(shè)置當(dāng)前線程為守護(hù)線程
t.setDaemon(true);
t.start();
log.info("主線程運(yùn)行結(jié)束");
}
輸出結(jié)果如下,可以看到隨著主線程的消亡,守護(hù)線程也會隨之停止,不再運(yùn)行,自此我相信讀者可以理解JDK官方文檔所說的那句話了,只要有一個普通線程在,JVM就不會退出,只要所有普通線程停止工作,JVM自動退出,守護(hù)線程也會自動結(jié)束。
12:44:23.239 [Thread-0] INFO com.sharkChili.webTemplate.Main - 守護(hù)線程執(zhí)行了......
12:44:23.239 [main] INFO com.sharkChili.webTemplate.Main - 主線程運(yùn)行結(jié)束
3. 守護(hù)線程和普通線程優(yōu)先級的區(qū)別
我們可以通過getPriority方法查看兩者的區(qū)別:
public static void main(String[] args) {
Thread t = new Thread(() -> {
log.info("守護(hù)線程優(yōu)先級:{}", Thread.currentThread().getPriority());
});
//設(shè)置當(dāng)前線程為守護(hù)線程
t.setDaemon(true);
t.start();
log.info("主線程運(yùn)行結(jié)束,當(dāng)前線程運(yùn)行優(yōu)先級:{}", Thread.currentThread().getPriority());
}
從輸出結(jié)果來看,兩者的優(yōu)先級是一樣的,都為5:
12:54:36.344 [main] INFO com.sharkChili.webTemplate.Main - 主線程運(yùn)行結(jié)束,當(dāng)前線程運(yùn)行優(yōu)先級:5
12:54:36.344 [Thread-0] INFO com.sharkChili.webTemplate.Main - 守護(hù)線程優(yōu)先級:5
4. 父守護(hù)線程問題
我們創(chuàng)建了一個守護(hù)線程,在其runnable實(shí)現(xiàn)中創(chuàng)建一個子線程:
public static void main(String[] args) {
Thread parentThread = new Thread(() -> {
Thread childThread = new Thread(() -> {
while (true) {
log.info("子線程運(yùn)行中,是否為守護(hù)線程:{}",Thread.currentThread().isDaemon());
try {
TimeUnit.HOURS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
childThread.start();
log.info("parentThread守護(hù)線程運(yùn)行中");
});
//設(shè)置當(dāng)前線程為守護(hù)線程
parentThread.setDaemon(true);
parentThread.start();
log.info("主線程運(yùn)行結(jié)束");
}
從輸出結(jié)果來看,父線程為守護(hù)線程時,其內(nèi)部創(chuàng)建的子線程也為守護(hù)線程,所以隨著父線程的銷毀,子線程也會同步銷毀。
00:05:56.869 [Thread-1] INFO com.sharkChili.webTemplate.Main - 子線程運(yùn)行中,是否為守護(hù)線程:true
00:05:56.869 [main] INFO com.sharkChili.webTemplate.Main - 主線程運(yùn)行結(jié)束
00:05:56.869 [Thread-0] INFO com.sharkChili.webTemplate.Main - parentThread守護(hù)線程運(yùn)行中
5. 守護(hù)線程池的創(chuàng)建
public static void main(String[] args) {
ExecutorService threadPool = Executors.newFixedThreadPool(10, ThreadFactoryBuilder.create()
.setNamePrefix("worker-")
.setDaemon(true)
.build());
threadPool.execute(()->{
while (true){
try {
log.info("守護(hù)線程運(yùn)行了");
Thread.sleep(5000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
log.info("主線程退出");
}
6. 守護(hù)線程的使用場景
因?yàn)槭刈o(hù)線程擁有自動結(jié)束自己生命周期的特性,當(dāng)JVM中沒有一個普通線程運(yùn)行時,JVM會退出,即所有守護(hù)線程會自動停止,所以守護(hù)線程的使用場景可以有以下幾種:
- 垃圾回收線程就是典型的守護(hù)線程,在后臺進(jìn)行垃圾對象回收的工作。
- 非核心業(yè)務(wù)工作可交由守護(hù)線程,例如:各類信息統(tǒng)計(jì)、服務(wù)監(jiān)控等,一旦進(jìn)程結(jié)束運(yùn)行則這些守護(hù)線程停止工作。
7. 守護(hù)線程注意事項(xiàng)
- 復(fù)雜計(jì)算、資源回收這種不建議使用守護(hù)線程。
- setDaemon要在start方法前面,否者該設(shè)置會不生效。
三、finalize關(guān)閉的哲學(xué)
1. 基本介紹
針對一些系統(tǒng)資源例如文件句柄或者套接字句柄,當(dāng)不需要它們時,垃圾回收器定義了finalize方法進(jìn)行一些資源關(guān)閉,一旦垃圾回收器回收這些對象之后,對應(yīng)的資源就會調(diào)用finalize釋放。
例如FileInputStream的finalize方法,它就會檢查當(dāng)前文件句柄是否非空,然后顯示的調(diào)用一下close方法:
protected void finalize() throws IOException {
if ((fd != null) && (fd != FileDescriptor.in)) {
//關(guān)閉文件句柄
close();
}
}
2. 終結(jié)器注意事項(xiàng)和正確資源關(guān)閉姿勢
需要注意的finalize在JVM運(yùn)行中可能會執(zhí)行也可能不會執(zhí)行,JVM對此無法做出保證,所以它運(yùn)行時存著極端的不確定性,所以進(jìn)行資源關(guān)閉時,我們非常不建議使用finalize。
正確的一些系統(tǒng)資源關(guān)閉回收,筆者更建議是使用階段采用try-with-resource手動關(guān)閉資源:
//使用try-with-resource手動關(guān)閉資源
try(BufferedReader reader = FileUtil.getUtf8Reader("filePahth")){
System.out.println(reader.readLine());
}catch (Exception e){
//異常處理
}