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