在Spring環(huán)境中正確關閉線程池的姿勢
前言
在Java System#exit 無法退出程序的問題一文末尾提到優(yōu)雅停機的一種實現(xiàn)方案,要借助Shutdown Hook
進行實現(xiàn),本文,將繼續(xù)探索優(yōu)雅停機中遇到的一些問題:應用中線程池的優(yōu)雅關閉
線程池正確關閉的姿勢
在這一節(jié),先不討論應用中線程池該如何優(yōu)雅關閉以達到優(yōu)雅停機的效果,只是簡單介紹一下線程池正確關閉的姿勢
為簡化討論的復雜性,本文的線程池均是指JDK中的java.util.concurrent.ThreadPoolExecutor
正確關閉線程池的關鍵是 shutdown
+ awaitTermination
或者 shutdownNow
+ awaitTermination
一種可能的使用姿勢如下:
ExecutorService executorService = Executors.newFixedThreadPool(1); executorService.execute(() -> { // do task }); // 執(zhí)行shutdown,將會拒絕新任務提交到線程池;待執(zhí)行的任務不會取消,正在執(zhí)行的任務也不會取消,將會繼續(xù)執(zhí)行直到結束 executorService.shutdown(); // 執(zhí)行shutdownNow,將會拒絕新任務提交到線程池;取消待執(zhí)行的任務,嘗試取消執(zhí)行中的任務 // executorService.shutdownNow(); // 超時等待線程池完畢 executorService.awaitTermination(3, TimeUnit.SECONDS);
一個任務會有如下幾個狀態(tài):
- 未提交,此時可以將任務提交到線程池
- 已提交未執(zhí)行,此時任務已在線程池的隊列中,等待著執(zhí)行
- 執(zhí)行中,此時任務正在執(zhí)行
- 執(zhí)行完畢
那么,執(zhí)行shutdown
方法或shutdownNow
方法之后,將會影響任務的狀態(tài)
shutdown
- 拒絕新任務提交
- 待執(zhí)行的任務不會取消
- 正在執(zhí)行的任務也不會取消,將繼續(xù)執(zhí)行
shutdownNow
- 拒絕新任務提交
- 取消待執(zhí)行的任務
- 嘗試取消執(zhí)行中的任務(僅僅是做嘗試,成功與否取決于是否響應InterruptedException,以及對其做出的反應)
接下來看一下java doc對這兩個方法的描述:
shutdown: Initiates an orderly shutdown in which previously submitted tasks are executed, but no new tasks will be accepted. Invocation has no additional effect if already shut down.
This method does not wait for previously submitted tasks to complete execution. Use awaitTermination to do that.
shutdownNow: Attempts to stop all actively executing tasks, halts the processing of waiting tasks, and returns a list of the tasks that were awaiting execution.
This method does not wait for actively executing tasks to terminate. Use awaitTermination to do that.
There are no guarantees beyond best-effort attempts to stop processing actively executing tasks. For example, typical implementations will cancel via Thread.interrupt, so any task that fails to respond to interrupts may never terminate.
Java doc 提到,這兩個方法都不會等執(zhí)任務執(zhí)行完畢,如果需要等待,請使用awaitTermination
。該方法帶有超時參數(shù):如果超時后任務仍然未執(zhí)行完畢,也不再等待。畢竟應用總歸要停機重啟,而不可能無限等待下去,因此超時機制是提供給用戶的最后一道底線
綜上,shutdown(Now) + awaitTermination 確實是實現(xiàn)線程池優(yōu)雅關閉的關鍵
應用中如何正確關閉線程池
這一節(jié)內(nèi)容其實才是本文要介紹的重心。上一小節(jié)內(nèi)容我們知道了如何優(yōu)雅關閉線程池,但那是一般意義上方法論指導,如果將線程池運用于我們的應用中,譬如Spring Boot環(huán)境中,復雜度將會變得不一樣
本一節(jié),將會介紹線程池在Spring (Boot)環(huán)境中優(yōu)雅關閉遇到的一個問題跟挑戰(zhàn),以及解決方案
注:本節(jié)使用Spring Boot舉例,僅僅是因為它的應用面廣,受眾多,大家容易理解,并不代表只在該環(huán)境下才會出問題。在純Spring、甚至非Spring環(huán)境,都有可能出現(xiàn)問題
場景1
我們來假設一個場景,有了場景的鋪墊,對問題的理解會簡單一些
@Resource private RedisTemplate<String, Integer> redisTemplate; // 自定義線程池 public static ExecutorService executorService = Executors.newFixedThreadPool(1); @GetMapping("/incr") public void incr() { executorService.execute(() -> { // 依賴Redis進行計數(shù) redisTemplate.opsForValue().increment("demo", 1L); }); }
- 自定義線程池,用于異步任務的執(zhí)行。此處為演示方便使用
Executors.newFixedThreadPool(1)
生成了只有一個線程的線程池 - 高并發(fā)請求/incr接口,每次請求該接口,都會往線程池中添加一個任務,任務異步執(zhí)行的過程中依賴Redis
此時,要求停機發(fā)布新版本,按照Java System#exit 無法退出程序的問題文章,我們知道了優(yōu)雅停機的一般步驟:
- 切斷上游流量入口,確保不再有流量進入到當前節(jié)點
- 向應用發(fā)送kill 命令,在設定的時間內(nèi)待應用正常關閉,若超時后應用仍然存活,則使用kill -9命令強制關閉
- 當JVM接收到kill命令,會喚起應用中所有的Shutdown Hooks,等待Shutdown Hooks執(zhí)行完畢便可以正常關機;與此同時,應用會接著處理在途請求,以確保不會向客戶端拋出連接中斷異常,實現(xiàn)無感知發(fā)布
一切看起來很美好,然而…
當JVM收到kill指令后,便會喚醒所有的Shutdown Hook,而其中有一個Shutdown Hook是Spring應用在啟動之初注冊的,它的作用是對Spring管理的Bean進行回收,并銷毀IOC容器
那么問題就產(chǎn)生了:以我們的場景為例,線程池里的任務與Spring Shutdhwon Hook正在并發(fā)地執(zhí)行著,一旦任務執(zhí)行期依賴的資源先行被釋放,那任務執(zhí)行時必然會報錯
在我們的場景中,就很有可能因為Redis連接被回收,從而導致redisTemplate.opsForValue().increment("demo", 1L);
拋出異常,執(zhí)行失敗
如圖示:
Jedis連接池先行被回收
下一刻,線程池里的任務嘗試獲取Jedis連接,失敗并拋出異常
場景2
除了上述場景外,還有一個場景或許大家也經(jīng)常會碰到:本地啟動一個定時任務,按一定頻率將數(shù)據(jù)從DB加載到Cache中
例如:
ScheduledExecutorService scheduledExecutorService = Executors.newScheduledThreadPool(1); scheduledExecutorService.scheduleWithFixedDelay(() -> { // load from db and put into cache // ... }, 100, 100, TimeUnit.MILLISECONDS);
- 每100ms向線程池里扔一個任務
- 任務是:從DB中取出數(shù)據(jù),放入緩存(例如Local Cache,Redis)
在Spring Shutdown Hook執(zhí)行期間,新的任務仍然會產(chǎn)生,又或者舊的任務未執(zhí)行完畢,一旦嘗試獲取DB資源,就可能由于資源被回收而獲取失敗,拋出異常
此時的系統(tǒng)關閉已經(jīng)不優(yōu)雅—任務執(zhí)行有異常,這種異??赡軐I(yè)務有損,我們應盡量避免類似問題的產(chǎn)生,而不是抱著"算了吧,反正產(chǎn)生這個問題的概率很低",或者"算了吧,反正異常對我目前業(yè)務影響也不大"的態(tài)度,這是技術人的基本修養(yǎng),也是對自我提高的要求—目前業(yè)務影響不大,允許不優(yōu)先解決,但是期望掌握一種解決方案,將來有一天如果碰到了對業(yè)務損傷比較大的場景,可以很有底氣地說:我能行
解決方案
這個問題產(chǎn)生的根因,是Spring Shutdown Hook與線程池里的任務并發(fā)執(zhí)行,有可能使任務依賴的資源被提前回收導致的。那么一個很直白的思路即是:在切斷流量之后,能否讓線程池先關閉,再執(zhí)行Spring 的Shutdown Hook,避免依賴資源被提前回收?
順著這個思路,有三個問題需要解決:
- 線程池如何關閉
- 線程池如何感知Spring Shutdown Hook將要被執(zhí)行
- 如何讓線程池先于Spring Shutdown Hook關閉
對于第一個問題,本文的上一個小節(jié)線程池正確關閉的姿勢已經(jīng)給出了解決方案:即shutdown(Now) + awaitTermination
對于第二個問題,Spring Shutdown Hook被觸發(fā)的時候,會主動發(fā)出一些事件,我們只要監(jiān)聽這些的事件,就能夠做出相應的反應
對于第三個問題,我們只要在這些事件的監(jiān)聽器中先行將線程池關閉,再讓程序走接下來的關閉流程即可
二、三涉及到Spring 的Shutdown Hook 執(zhí)行過程,具體原理本篇按下不表,留待下一篇進行分析
從上圖中可以看出,只要在destroyBeans
之前關閉線程池即可,因此,有兩種解決方案:
- 監(jiān)聽Spring的ContextClosedEvent事件,在事件被觸發(fā)時關閉線程池
- 實現(xiàn)Lifecycle接口,并在其stop方法中關閉線程池
此處以監(jiān)聽ContextClosedEvent
為例:
@Component public class ContextClosedHandler implements ApplicationListener<ContextClosedEvent> { @Override public void onApplicationEvent(ContextClosedEvent event) { // 獲取線程池 // ... // 關閉線程池,并等待一段時間 myExecutorService.shutdown(); myExecutorService.awaitTermination(3, TimeUnit.SECONDS); } }
此處大家或許能看出一些小問題:需要自行管理線程池。在Spring環(huán)境中,我們其實有更多的選擇:使用Spring提供的org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor
,并將實例交給Spring管理
代碼如下:
// 將ThreadPoolTaskExecutor實例交給Spring管理 @Bean public ThreadPoolTaskExecutor threadPoolTaskExecutor() { ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); executor.setCorePoolSize(1); executor.setMaxPoolSize(1); // 告訴線程池,在銷毀之前執(zhí)行shutdown方法 executor.setWaitForTasksToCompleteOnShutdown(true); // shutdown\shutdownNow 之后等待3秒 executor.setAwaitTerminationSeconds(3); return executor; }
@Component public class ContextClosedHandler implements ApplicationListener<ContextClosedEvent> { // 直接注入 @Resource private ThreadPoolTaskExecutor executor; @Override public void onApplicationEvent(ContextClosedEvent event) { // 關閉線程池 executor.destroy(); } }
注: ThreadPoolTaskExecutor的waitForTasksToCompleteOnShutdown
+ awaitTerminationSeconds
等于ThreadPoolExecutor的shutdown
+ awaitTermination
,且在定義線程池時就將優(yōu)雅關閉行為一同定義完畢,實現(xiàn)了高內(nèi)聚的目的
在Spring中使用ThreadPoolTaskExecutor,更便捷:
- 不用再自行管理線程池,獲取的時候也很方便,直接注入即可
- 在需要關閉的時候,直接調(diào)用destroy方法即可實現(xiàn)優(yōu)雅關閉
這樣,Spring就會等到線程池關閉(超時)后,才會接著往下執(zhí)行Bean的銷毀、資源回收、應用上下文關閉的邏輯,確保被依賴資源不會被提前回收掉
總結
本篇以兩種實際場景為例,拋出了一個很切合實際項目的問題:在Spring應用中如何正確地關閉線程池。文中指出,如果非正常關閉將可能會產(chǎn)生異常的問題,同時也分析了問題產(chǎn)生的原因并給出了相應的解決方案。下一篇,將會具體分析Spring Shutdown Hook執(zhí)行過程,與諸君共同探索其中的奧秘
思考
本文雖以"Spring環(huán)境中正確關閉線程池"為背景進行討論,然而實際上思維還可以更發(fā)散一些,可以不限于Spring環(huán)境,也不限于"關閉線程池"這個行為。更一般化地,在一個應用上下文環(huán)境中,許多的Bean有相互依賴的關系,這種依賴關系在應用啟動及應用關閉之時需要格外地注意:在啟動時,被依賴的Bean需要先行構造完畢;在關閉時,被依賴的Bean需要靠后銷毀。依托這個思想,只要找到應用上下文提供給我們的擴展點,就可以達到目的
以上為個人經(jīng)驗,希望能給大家一個參考,也希望大家多多支持腳本之家。
相關文章
Quartz實現(xiàn)JAVA定時任務的動態(tài)配置的方法
這篇文章主要介紹了Quartz實現(xiàn)JAVA定時任務的動態(tài)配置的方法,小編覺得挺不錯的,現(xiàn)在分享給大家,也給大家做個參考。一起跟隨小編過來看看吧2018-07-07SpringCloud Open feign 使用okhttp 優(yōu)化詳解
這篇文章主要介紹了SpringCloud Open feign 使用okhttp 優(yōu)化詳解,具有很好的參考價值,希望對大家有所幫助。一起跟隨小編過來看看吧2021-02-02Java?實現(xiàn)判定順序表中是否包含某個元素(思路詳解)
這篇文章主要介紹了Java?實現(xiàn)判定順序表中是否包含某個元素,本文給大家介紹的非常詳細,對大家的學習或工作具有一定的參考借鑒價值,需要的朋友可以參考下2023-06-06SpringBoot?實現(xiàn)CAS?Server統(tǒng)一登錄認證的詳細步驟
??CAS(Central?Authentication?Service)中心授權服務,是一個開源項目,目的在于為Web應用系統(tǒng)提供一種可靠的單點登錄,這篇文章主要介紹了SpringBoot?實現(xiàn)CAS?Server統(tǒng)一登錄認證,需要的朋友可以參考下2024-02-02Java 1.8使用數(shù)組實現(xiàn)循環(huán)隊列
這篇文章主要為大家詳細介紹了Java 1.8使用數(shù)組實現(xiàn)循環(huán)隊列,文中示例代碼介紹的非常詳細,具有一定的參考價值,感興趣的小伙伴們可以參考一下2020-10-10SpringBoot集成JPA持久層框架,簡化數(shù)據(jù)庫操作
JPA(Java Persistence API)意即Java持久化API,是Sun官方在JDK5.0后提出的Java持久化規(guī)范。主要是為了簡化持久層開發(fā)以及整合ORM技術,結束Hibernate、TopLink、JDO等ORM框架各自為營的局面。JPA是在吸收現(xiàn)有ORM框架的基礎上發(fā)展而來,易于使用,伸縮性強。2021-06-06