因爲不知道Java線程池的bug,某程序員叕被祭天

咱們會使用各類池化技術緩存 建立性能開銷較大的 對象,好比線程池、鏈接池、內存池。
它們的原理都是預先建立一些對象入池,使用時直接取出,用完歸還以複用,還會經過策略調整池中緩存對象的數量,實現動態伸縮性。java

因爲線程的建立比較昂貴,短平快的任務通常考慮使用線程池處理,而非直接建立線程。
手動聲明線程池程序員

JDK的Executors工具類定義了不少便捷的方法能夠快速建立線程池。web

可是阿里有話說:面試

咱們來看他說的弊端案例真的這麼嚴重嗎?
newFixedThreadPool 可能 OOM數據庫

咱們寫一段測試代碼,來初始化一個單線程的FixedThreadPool,循環1億次向線程池提交任務,每一個任務都會建立一個比較大的字符串而後休眠一小時:緩存

執行程序後不久,日誌中就出現了以下OOM:多線程

Exception in thread 「http-nio-45678-ClientPoller」
java.lang.OutOfMemoryError: GC overhead limit exceeded架構

1
2

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框架

1
2

日誌可見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());

1
2
3
4

開發同窗其實面試時都知道這倆線程池原理,只是抱有僥倖,以爲只是使用線程池作了輕量任務,不會形成隊列積壓或開啓大量線程。
案例

用戶註冊後,咱們調用一個外部服務去發送短信,發送短信接口正常時可在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佔用也不是很高。
最終排查發現業務代碼使用的線程池,還被一個後臺文件批處理任務用了。

模擬一下文件批處理,在程序啓動後經過一個線程開啓死循環邏輯,不斷向線程池提交任務,任務的邏輯是向一個文件中寫入大量的數據:

1

能夠想象到,這個線程池中的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開發手冊》

公衆號-JavaEdge CSDN博客專家 慕課網認證做者 騰訊雲+最佳做者 1.經歷:19屆雙一流本科,曾在百度、攜程、華爲等大廠搬金磚 2.涉獵領域:Java生態各類中間件原理、框架源碼、微服務、中臺等架構設計及落地實戰,只生產硬核乾貨! 3.開源社區榮譽:阿里雲棲社區博客專家、騰訊雲+社區2019年度最佳做者、慕課網認證做者、CSDN百萬流量萬粉博客專家,簡書優秀創做者兼《程序員》專題管理員 4.著做:在牛客網著有《Java源碼面試解析指南》,目前已有上千人在學習,已助衆多讀者成功拿到滿意offer~ 關注博主便可閱讀全文