Java執行緒池進階

木小豐發表於2022-02-27

執行緒池是日常開發中常用的技術,使用也非常簡單,不過想使用好執行緒池也不是件容易的事,開發者需要不斷探索底層的實現原理,才能在不同的場景中選擇合適的策略,最大程度發揮執行緒池的作用以及避免踩坑。

一、執行緒池工作流程

以下是Java執行緒池的工作流程,涉及建立執行緒的引數及拒絕策略,如果讀者對這部分內容不太瞭解,可參考其他的文件,本文不在贅述。

image-20220226153333763

二、執行緒池進階

1、執行緒池的建立

需要手動通過ThreadPoolExecutor建立,使用者要非常明確業務場景並定製執行緒池,避免誤用可能導致的問題。

以下是阿里巴巴Java開發手冊中的描述:

image-20220226153449939

ThreadFactory:推薦使用guava中的ThreadFactoryBuilder建立:

new ThreadFactoryBuilder().setNameFormat("name-%d").build();

2、阻塞佇列線上程池中的使用

很多同學一看到阻塞佇列就自然的認為出入佇列都是阻塞的,使用的阻塞佇列也就沒必要關心拒絕策略了,其實不然,阻塞佇列在任務提交和任務獲取階段使用了不同的策略。

任務提交階段:呼叫的阻塞佇列的offer方法,這個方法是非阻塞的,如果插入佇列失敗會直接返回false,並觸發拒絕策略;

獲取任務階段:使用的是take方法,此方法是阻塞的;

3、保證提交階段任務不丟失

有三種方法:使用CallerRunsPolicy拒絕策略、自定義拒絕策略、使用MQ系統保證任務不丟失。

(1)CallerRunsPolicy拒絕策略

ThreadPoolExecutor.CallerRunsPolicy:由提交任務的執行緒處理

這種是最簡單的策略,但需要注意的是如果任務耗時較長,會阻塞提交任務的執行緒,可能會成為系統瓶頸。

(2)自定義拒絕策略

既然Java執行緒預設使用的是offer提交任務,那我們可以自定義拒絕策略在任務提交失敗時改為put阻塞提交。

缺點也是會阻塞提交執行緒,不過相比CallerRunsPolicy策略更能發揮多執行緒的優勢。

 RejectedExecutionHandler executionHandler = (r, executor) -> {
   try {
​     executor.getQueue().put(r);
   } catch (InterruptedException e) {
​     Thread.currentThread().interrupt();
​     throw new RejectedExecutionException("Producer thread interrupted", e);
   }
 };

(3)配合MQ保證任務不丟失

使用預設的ThreadPoolExecutor.AbortPolicy策略,如果丟擲RejectedExecutionException異常則返回給MQ消費失敗,MQ會保證自動重試。

4、保證佇列、未執行完成的任務不丟失

當服務停止的時候,執行緒池中佇列和活躍執行緒中未執行完成的任務可能會造成資料丟失,首先說下結論:無論採取任何策略,在Java層都不能100%保證不丟,比如機器突然斷電的情況。我們還是可以採取一定的措施儘量避免任務丟失。

(1)執行緒池關閉

執行緒池關閉有兩個方法:

shutdownNow方法:執行緒池拒絕接收新提交的任務,同時立馬關閉執行緒池,執行緒池裡的任務不再執行,並丟擲InterruptedException異常。

shutdown方法:執行緒池拒絕接收新提交的任務,同時等待執行緒池裡的任務執行完畢後關閉執行緒池。

(2)註冊關閉鉤子

使用以下方法註冊JVM程式關閉鉤子,在鉤子方法中執行執行緒池關閉、未處理完成的任務持久化儲存等。

Runtime.getRuntime().addShutdownHook()

需要注意的是:鉤子方法在使用kill -9殺死程式時不會執行,一般的殺程式的方式是先執行kill,等待一段時間,如果程式還沒殺死,再執行kill -9。

要保證佇列中的任務不丟失,需要消費佇列中的資料,傳送到外部MQ中;

保證未執行完成的任務不丟失,需要在丟擲InterruptedException異常後,將任務引數保證到MQ中;

需要注意的是:1)儘量不要把未完成的任務儲存到本地磁碟,尤其是在經常擴縮容的彈性叢集裡;2)捕獲InterruptedException異常後,不要做重試等耗時操作;3)需要監控任務都傳送到MQ中的時間,以便調整kill -9強制執行前的等待時間。

(3)使用MQ保證任務必須執行完成

通過上面介紹的兩種方式,可以處理大部分正常停止服務丟資料的任務。不過對於極端情況下,比如斷電、斷網等,需要嚴格保證任務不丟失的場景還是不能滿足業務需要,這種情況下就需要依賴MQ。

方案是使用執行緒池的submit方法提交任務,通過future獲取到任務執行完成再返回給MQ消費完成。在MQ中如何保證資料不丟失是另外一個複雜的話題了,這裡不再深入探討。

需要注意的是,如果採用這種方案,需要保證處理任務的冪等性,在操作步驟比較多的時候,複雜性也會很高。

5、ThreadLocal變數

ThreadLocal中變數的作用域是當前執行緒,使用執行緒池後會因跨執行緒導致資料不能傳遞,如果業務中使用了ThreadLocal,需要額外處理這種場景。

(1)InheritableThreadLocal

InheritableThreadLocal是在父子執行緒中自動傳遞引數,線上程池場景中不適用。

(2)手動處理

在提交任務前把ThreadLocal中的值取出來,線上程池執行時再set到執行緒池中執行緒的ThreadLocal中,並且在finally中清理資料。

缺點是每個執行緒池都要處理一遍,如果對上下文不熟悉,有漏傳的風險。

(3)TransmittableThreadLocal

阿里開源地址:TransmittableThreadLocal

原理是通過javaagent自動處理ThreadLocal跨執行緒池傳參,對業務開發者無感知,也是推薦的方案。

6、異常處理

(1)異常感知

execute方法:拋異常會被提交任務執行緒感知;

submit方法:拋異常不會被提交任務執行緒感知,在Future.get()執行時會被感知;

(2)統一處理方案1:非同步任務裡統一catch

線上程池的執行邏輯最外層,包裝try、catch,處理所有異常。

缺點是: 1)所有的不同任務都要trycatch,增加了程式碼量。2)不存在checkedexception的地方也需要都trycatch起來,程式碼醜陋。

(3)統一處理方案2:覆寫統一異常處理方法

此方案有兩種常用實現:1)自定義執行緒池,繼承ThreadPoolExecutor並覆寫其afterExecute方法;2)建立執行緒池時自定義ThreadFactory,在實現裡手動建立執行緒池,並呼叫Thread.setUncaughtExceptionHandler註冊統一異常處理器。

(4)統一處理方案3:Future

任務提交都使用submit,並在Future.get()時捕獲所有異常。

三、總結

本文從建立執行緒池、佇列注意事項、如何保證任務不丟失、ThreadLocal、異常等方面總結了筆者的一些思考,各位讀者可以對照下自己的使用場景,看本文提到的問題是否都考慮到了呢,或者你還有什麼執行緒池方面的使用經驗,歡迎交流分享。

本文連結:Java執行緒池進階

作者簡介:木小豐,美團Java技術專家,專注分享軟體研發實踐、架構思考。歡迎關注公共號:Java研發

更多精彩文章:

從MVC到DDD的架構演進

平臺化建設思路淺談

構建可回滾的應用及上線checklist實踐

Maven依賴衝突問題排查經驗

相關文章