我們會使用各種池化技術快取 創建性能開銷較大的 物件,比如執行緒池、連接池、記憶體池,
它們的原理都是預先創建一些物件入池,使用時直接取出,用完歸還以復用,還會通過策略調整池中快取物件的數量,實作動態伸縮性,
由于執行緒的創建比較昂貴,短平快的任務一般考慮使用執行緒池處理,而非直接創建執行緒,
手動宣告執行緒池
JDK的Executors工具類定義了很多便捷的方法可以快速創建執行緒池,

但是阿里有話說:

我們來看他說的弊端案例真的這么嚴重嗎?
newFixedThreadPool 可能 OOM
我們寫一段測驗代碼,來初始化一個單執行緒的FixedThreadPool,回圈1億次向執行緒池提交任務,每個任務都會創建一個比較大的字串然后休眠一小時:

執行程式后不久,日志中就出現了如下OOM:
Exception in thread "http-nio-45678-ClientPoller"
java.lang.OutOfMemoryError: GC overhead limit exceeded

newFixedThreadPool執行緒池的作業佇列直接new了一個LinkedBlockingQueue

- 但其默認構造器是一個
Integer.MAX_VALUE長度的佇列,所以很快就佇列滿了

雖然使用newFixedThreadPool可以固定作業執行緒數量,但任務佇列幾乎無界,如果任務較多且執行較慢,佇列就會快速積壓,記憶體不夠就很容易導致OOM,
newCachedThreadPool導致OOM
[11:30:30.487] [http-nio-45678-exec-1] [ERROR] [.a.c.c.C.[.[.[/].[dispatcherServlet]:175 ] - Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception [Handler dispatch failed; nested exception is java.lang.OutOfMemoryError: unable to create new native thread] with root cause
java.lang.OutOfMemoryError: unable to create new native thread
日志可見OOM是因為無法創建執行緒,newCachedThreadPool這種執行緒池的最大執行緒數是Integer.MAX_VALUE,可認為無上限,而其作業佇列SynchronousQueue是一個沒有存盤空間的阻塞佇列,
所以只要有請求到來,就必須找到一條作業執行緒處理,若當前無空閑執行緒就再創建一個新的,
由于我們的任務需1小時才能執行完成,大量任務進來后會創建大量的執行緒,我們知道執行緒是需要分配一定的記憶體空間作為執行緒堆疊的,比如1MB,因此無限創建執行緒必然會導致OOM:
public static ExecutorService newCachedThreadPool() {
return new ThreadPoolExecutor(0, Integer.MAX_VALUE,
60L, TimeUnit.SECONDS,
new SynchronousQueue<Runnable>());
開發同學其實面試時都知道這倆執行緒池原理,只是抱有僥幸,覺得只是使用執行緒池做了輕量任務,不會造成佇列積壓或開啟大量執行緒,
案例
用戶注冊后,我們呼叫一個外部服務去發送短信,發送短信介面正常時可在100ms內回應,TPS 100的注冊量,CachedThreadPool能穩定在占用10個左右執行緒的情況下滿足需求,在某個時間點,外部短信服務不可用了,我們呼叫這個服務的超時又特別長, 比如1分鐘,1分鐘可能就進來了6000用戶,產生6000個發送短信的任務,需要6000個執行緒,沒多久就因為無法創建執行緒導致了OOM,
所以阿里也不建議使用Executors提供的兩種方便執行緒池創建方式:
- 需根據自己的場景、并發情況來評估執行緒池的幾個核心引數,包括核心執行緒數、最大執行緒數、執行緒回收策略、作業佇列的型別,以及拒絕策略,確保執行緒池的作業行為符合需求,一般都需要設定有界的作業佇列和可控的執行緒數
- 任何時候都應為自定義執行緒池指定有意義的名稱,以方便排查問題,當出現執行緒數量暴增、執行緒死鎖、執行緒占用大量CPU、執行緒執行出現例外等問題時,往往會抓取執行緒堆疊,此時,有意義的執行緒名稱,就可以方便定位問題,
除手動宣告執行緒池外,推薦用些監控手段觀察執行緒池狀態,執行緒池這個組件往往會表現得任勞任怨、默默無聞,除非出現拒絕策略,否則壓力再大都不會拋例外,若能提前觀察到執行緒池佇列的積壓或執行緒數量的快速膨脹,往往可提早發現并解決問題,
執行緒池執行緒管理
- 如下方法實作最簡陋的監控

自定義個執行緒池,借助Jodd類別庫的ThreadFactoryBuilder方法來構造一個執行緒工廠,實作執行緒池執行緒的自定義命名,
然后,我們寫一段測驗代碼來觀察執行緒池管理執行緒的策略,測驗代碼的邏輯為,每次間隔1秒向執行緒池提交任務,回圈20次,每個任務需要10秒才能執行完成,代碼如下:
- 發現提交失敗的記錄,日志就像這樣

執行緒池默認行為
- 不會初始化corePoolSize個執行緒,有任務來了才創建作業執行緒
- 核心執行緒滿后不會立即擴容執行緒池,而是把任務堆積到作業佇列
- 作業佇列滿后擴容執行緒池,直至執行緒數達到maximumPoolSize
- 若佇列已滿且達最大執行緒后,還有任務來按拒絕策略處理
- 當執行緒數大于核心執行緒數時,執行緒等待keepAliveTime后還是無任務需要處理,收縮執行緒到核心執行緒數
了解這個策略,有助于我們根據實際的容量規劃需求,為執行緒池設定合適的初始化引數,也可通過一些手段來改變這些默認作業行為,比如:
- 宣告執行緒池后立即呼叫prestartAllCoreThreads方法,來啟動所有核心執行緒
- 傳true給allowCoreThreadTimeOut,讓執行緒池在空閑時同樣回收核心執行緒
Java執行緒池是先用作業佇列來存放來不及處理的任務,滿后再擴容執行緒池,當作業佇列設定很大時(那個默認工具類),最大執行緒數這個引數就沒啥意義了,因為佇列很難滿或到滿時可能已OOM,更沒機會去擴容執行緒池了,
是否能讓執行緒池優先開啟更多執行緒,而把佇列當成后續方案?
比如案例的任務執行得很慢,需要10s,若執行緒池可優先擴容到5個最大執行緒,那么這些任務最終都可以完成,而不會因為執行緒池擴容過晚導致慢任務來不及處理,
實作思路
實作基本就如下兩個難題:
- 執行緒池在作業佇列滿了無法入隊的情況下會擴容執行緒池,那是否可重寫佇列的offer,人為制造該佇列已滿的假象?
- Hack了佇列,在達到最大執行緒后勢必會觸發拒絕策略,那么能否實作一個自定義的拒絕策略處理程式,這個時候再把任務真正插入佇列?
Tomcat其實已經實作了類似的“彈性”執行緒池,
務必確認清楚執行緒池本身是不是復用的
某專案生產環境偶爾報警執行緒數過多,超過2000個,收到報警后查看監控發現,瞬時執行緒數比較多但過一會兒又會降下來,執行緒數抖動很厲害,而應用的訪問量變化不大,
為定位問題,在執行緒數較高時抓取執行緒堆疊,發現記憶體中有1000多個自定義執行緒池,一般來說,執行緒池肯定是復用的,有5個以內的執行緒池都可認為正常,但1000多個執行緒池肯定不正常,
在專案代碼也沒看到宣告執行緒池,搜索execute關鍵字后定位到,原來是業務代碼呼叫了一個類別庫來獲得執行緒池,類似如下:
呼叫ThreadPoolHelper的getThreadPool方法來獲得執行緒池,然后提交數個任務到執行緒池處理,看不出什么例外,

但getThreadPool方法居然是每次都使用Executors.newCachedThreadPool來創建一個執行緒池,

newCachedThreadPool會在需要時創建必要多的執行緒,業務代碼的一次業務操作會向執行緒池提交多個慢任務,這樣執行一次業務操作就會開啟多個執行緒,如果業務操作并發量較大的話,的確有可能一下子開啟幾千個執行緒,
那為什么我們能在監控中看到執行緒數量會下降,而不OOM?
newCachedThreadPool的核心執行緒數是0,而keepAliveTime是60s,即60s后所有的執行緒都可回收,
修復
使用靜態欄位存放執行緒池的參考,回傳執行緒池的代碼直接回傳這個靜態欄位即可,

考慮執行緒池的混用
執行緒池的意義在于復用,那這是不是意味著程式應該始終使用一個執行緒池?
不是,要根據任務優先級指定執行緒池的核心引數,包括執行緒數、回收策略和任務佇列,
案例
業務代碼使用執行緒池異步處理一些記憶體中的資料,但監控發現處理得很慢,整個處理程序都是記憶體中的計算不涉及I/O操作,也需要數s處理時間,應用程式CPU占用也不是很高,
最終排查發現業務代碼使用的執行緒池,還被一個后臺檔案批處理任務用了,

模擬一下檔案批處理,在程式啟動后通過一個執行緒開啟死回圈邏輯,不斷向執行緒池提交任務,任務的邏輯是向一個檔案中寫入大量的資料:
可以想象到,這個執行緒池中的2個執行緒任務是相當重的,通過printStats方法列印出的日志,我們觀察下執行緒池的負擔:

執行緒池的2個執行緒始終處活躍狀態,佇列也基本滿,因為開啟了CallerRunsPolicy拒絕處理策略,所以當執行緒滿佇列滿,任務會在提交任務的執行緒或呼叫execute方法的執行緒執行,也就是說不能認為提交到執行緒池的任務就一定是異步處理的,
若使用CallerRunsPolicy,有可能異步任務變同步執行,從日志的第四行也可以看到這點,這也是這個拒絕策略比較特別的原因,
不知道寫代碼的同學為什么設定這個策略,或許是測驗時發現執行緒池因為任務處理不過來出現了例外,而又不希望執行緒池丟棄任務,所以最終選擇了這樣的拒絕策略,不管怎樣,這些日志足以說明執行緒池飽和了,
業務代碼復用這樣的執行緒池來做記憶體計算就難搞了,
- 向執行緒池提交一個簡單任務

- 簡單壓測TPS為85,性能差

問題沒這么簡單,原來執行IO任務的執行緒池使用CallerRunsPolicy,所以直接使用該執行緒池進行異步計算,當執行緒池飽和的時候,計算任務會在執行Web請求的Tomcat執行緒執行,這時就會進一步影響到其他同步處理的執行緒,甚至造成整個應用程式崩潰,
修正
使用獨立的執行緒池來做這樣的“計算任務”,
模擬代碼執行的是休眠操作,并不屬于CPU系結的操作,更類似I/O系結的操作,若執行緒池執行緒數設定太小會限制吞吐能力:

- 使用單獨的執行緒池改造代碼后再來測驗一下性能,TPS提高到1683

可見盲目復用執行緒池混用執行緒的問題在于,別人定義的執行緒池屬性不一定適合你的任務,混用會相互干擾,
就好比,我們往往會用虛擬化技術來實作資源的隔離,而不是讓所有應用程式都直接使用物理機,
執行緒池混用:Java 8的parallel stream
可方便并行處理集合中的元素,共享同一ForkJoinPool,默認并行度是CPU核數-1,對于CPU系結的任務,使用這樣的配置較合適,但若集合操作涉及同步IO操作的話(比如資料庫操作、外部服務呼叫等),建議自定義一個ForkJoinPool(或普通執行緒池),
參考
- 《阿里巴巴Java開發手冊》
轉載請註明出處,本文鏈接:https://www.uj5u.com/qita/238493.html
標籤:AI
