日日操夜夜添-日日操影院-日日草夜夜操-日日干干-精品一区二区三区波多野结衣-精品一区二区三区高清免费不卡

公告:魔扣目錄網為廣大站長提供免費收錄網站服務,提交前請做好本站友鏈:【 網站目錄:http://www.ylptlb.cn 】, 免友鏈快審服務(50元/站),

點擊這里在線咨詢客服
新站提交
  • 網站:51998
  • 待審:31
  • 小程序:12
  • 文章:1030137
  • 會員:747


【系統架構】使用線程池你需要注意這幾點

 
 
 

在程序開發中,我們會用各種池化技術來緩存創建昂貴的對象,比如線程池、連接池、內存池等。一般是預先創建一些對象放入池中,使用的時候直接取出使用,用完歸還以便復用。還會通過一定的策略調整池中緩存對象的數量,實現池的動態伸縮。

由于線程的創建比較昂貴,隨意、沒有控制地創建大量線程會造成性能問題,因此,短平快的任務一般考慮使用線程池來處理,而不是直接創建線程。

今天,我們就針對線程池這個話題展開討論。通過三個生產事故,來看看使用線程池應該注意些什么。

線程池的聲明需要手動進行

JAVA 中的 Executors 類定義了一些快捷的工具方法,來幫助我們快速創建線程池。不過,《阿里巴巴 Java 開發手冊》中也提到,我們應該禁止使用這些方法來創建線程池,而應該手動通過 new ThreadPoolExecutor 來創建線程池。這一條規則的背后,是大量血淋淋的生產事故。最典型的就是 newFixedThreadPool 和 newCachedThreadPool 可能因為資源耗盡而導致 OOM 問題。

首先,我們來看一下 newFixedThreadPool 為什么可能會出現 OOM 的問題。

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

【系統架構】使用線程池你需要注意這幾點
 
 
 

執行程序后不久,日志中就出現了如下 OOM:

【系統架構】使用線程池你需要注意這幾點
 
 
 

翻看 newFixedThreadPool 方法的源碼不難發現,線程池的工作隊列直接 new 了一個 LinkedBlockingQueue,而默認構造方法的 LinkedBlockingQueue 是一個 Integer.MAX_VALUE 長度的隊列,可以認為是無界的:

【系統架構】使用線程池你需要注意這幾點
 
 
 

雖然使用 newFixedThreadPool 可以把工作線程控制在固定的數量上,但任務隊列是無界的。如果任務較多并且執行較慢的話,隊列可能會快速積壓,撐爆內存導致 OOM。

我們再把剛才的例子稍微改一下,改為使用 newCachedThreadPool 方法來獲得線程池。程序運行不久后,同樣看到了如下 OOM 異常:

【系統架構】使用線程池你需要注意這幾點
 
 
 

從日志中可以看到,這次 OOM 的原因是無法創建線程,翻看 newCachedThreadPool 的源碼可以看到,這種線程池的最大線程數是 Integer.MAX_VALUE,可以認為是沒有上限的,而其工作隊列 SynchronousQueue 是一個沒有存儲空間的阻塞隊列。這意味著,只要有請求到來,就必須找到一條工作線程來處理,如果當前沒有空閑的線程就再創建一條新的。

由于我們的任務需要 1 小時才能執行完成,大量的任務進來后會創建大量的線程。我們知道線程是需要分配一定的內存空間作為線程棧的,比如 1MB,因此無限制創建線程必然會導致 OOM:

【系統架構】使用線程池你需要注意這幾點
 
 
 

其實,大部分 Java 開發同學知道這兩種線程池的特性,只是抱有僥幸心理,覺得只是使用線程池做一些輕量級的任務,不可能造成隊列積壓或開啟大量線程。

但現實往往是殘酷的。我之前就遇到過這么一個事故:用戶注冊后,我們調用一個外部服務去發送短信,發送短信接口正常時可以在 100 毫秒內響應,TPS 100 的注冊量,CachedThreadPool 能穩定在占用 10 個左右線程的情況下滿足需求。在某個時間點,外部短信服務不可用了,我們調用這個服務的超時又特別長, 比如 1 分鐘,1 分鐘可能就進來了 6000 用戶,產生 6000 個發送短信的任務,需要 6000 個線程,沒多久就因為無法創建線程導致了 OOM,整個應用程序崩潰。

因此,我同樣不建議使用 Executors 提供的兩種快捷的線程池,原因如下:

  1. 我們需要根據自己的場景、并發情況來評估線程池的幾個核心參數,包括核心線程數、最大線程數、線程回收策略、工作隊列的類型,以及拒絕策略,確保線程池的工作行為符合需求,一般都需要設置有界的工作隊列和可控的線程數。
  2. 任何時候,都應該為自定義線程池指定有意義的名稱,以方便排查問題。當出現線程數量暴增、線程死鎖、線程占用大量 CPU、線程執行出現異常等問題時,我們往往會抓取線程棧。此時,有意義的線程名稱,就可以方便我們定位問題。

除了建議手動聲明線程池以外,我還建議用一些監控手段來觀察線程池的狀態。線程池這個組件往往會表現得任勞任怨、默默無聞,除非是出現了拒絕策略,否則壓力再大都不會拋出一個異常。如果我們能提前觀察到線程池隊列的積壓,或者線程數量的快速膨脹,往往可以提早發現并解決問題。

線程池線程管理策略詳解

在之前的 Demo 中,我們用一個 printStats 方法實現了最簡陋的監控,每秒輸出一次線程池的基本內部信息,包括線程數、活躍線程數、完成了多少任務,以及隊列中還有多少積壓任務等信息:

【系統架構】使用線程池你需要注意這幾點
 
 
 

接下來,我們就利用這個方法來觀察一下線程池的基本特性吧。

首先,自定義一個線程池。這個線程池具有 2 個核心線程、5 個最大線程、使用容量為 10 的 ArrayBlockingQueue 阻塞隊列作為工作隊列,使用默認的 AbortPolicy 拒絕策略,也就是任務添加到線程池失敗會拋出 RejectedExecutionException。此外,我們借助了 Jodd 類庫的 ThreadFactoryBuilder 方法來構造一個線程工廠,實現線程池線程的自定義命名。

然后,我們寫一段測試代碼來觀察線程池管理線程的策略。測試代碼的邏輯為每次間隔 1 秒向線程池提交任務,循環 20 次,每個任務需要 10 秒才能執行完成,代碼如下:

【系統架構】使用線程池你需要注意這幾點
 
 
 

60 秒后頁面輸出了 17,有 3 次提交失敗了:

【系統架構】使用線程池你需要注意這幾點
 
 
 

并且日志中也出現了 3 次類似的錯誤信息:

【系統架構】使用線程池你需要注意這幾點
 
 
 

我們把 printStats 方法打印出的日志繪制成圖表,得出如下曲線:

【系統架構】使用線程池你需要注意這幾點
 
 
 

至此,我們可以總結出線程池默認的工作行為:

  1. 不會初始化 corePoolSize 個線程,有任務來了才創建工作線程;
  2. 當核心線程滿了之后不會立即擴容線程池,而是把任務堆積到工作隊列中;
  3. 當工作隊列滿了后擴容線程池,一直到線程個數達到 maximumPoolSize 為止;
  4. 如果隊列已滿且達到了最大線程后還有任務進來,按照拒絕策略處理;
  5. 當線程數大于核心線程數時,線程等待 keepAliveTime 后還是沒有任務需要處理的話,收縮線程到核心線程數。

了解這個策略,有助于我們根據實際的容量規劃需求,為線程池設置合適的初始化參數。當然,我們也可以通過一些手段來改變這些默認工作行為,比如:

  1. 聲明線程池后立即調用 prestartAllCoreThreads 方法,來啟動所有核心線程;
  2. 傳入 true 給 allowCoreThreadTimeOut 方法,來讓線程池在空閑的時候同樣回收核心線程。

不知道你有沒有想過:Java 線程池是先用工作隊列來存放來不及處理的任務,滿了之后再擴容線程池。當我們的工作隊列設置得很大時,最大線程數這個參數顯得沒有意義,因為隊列很難滿,或者到滿的時候再去擴容線程池已經于事無補了。

那么,我們有沒有辦法讓線程池更激進一點,優先開啟更多的線程,而把隊列當成一個后備方案呢?比如我們這個例子,任務執行得很慢,需要 10 秒,如果線程池可以優先擴容到 5 個最大線程,那么這些任務最終都可以完成,而不會因為線程池擴容過晚導致慢任務來不及處理。

限于篇幅,這里我只給你一個大致思路:

  1. 由于線程池在工作隊列滿了無法入隊的情況下會擴容線程池,那么我們是否可以重寫隊列的 offer 方法,造成這個隊列已滿的假象呢?
  2. 由于我們 Hack 了隊列,在達到了最大線程后勢必會觸發拒絕策略,那么能否實現一個自定義的拒絕策略處理程序,這個時候再把任務真正插入隊列呢?

接下來,就請你動手試試看如何實現這樣一個“彈性”線程池吧。Tomcat 線程池也實現了類似的效果,可供你借鑒。

務必確認清楚線程池本身是不是復用的

不久之前我遇到了這樣一個事故:某項目生產環境時不時有報警提示線程數過多,超過 2000 個,收到報警后查看監控發現,瞬時線程數比較多但過一會兒又會降下來,線程數抖動很厲害,而應用的訪問量變化不大。

為了定位問題,我們在線程數比較高的時候進行線程棧抓取,抓取后發現內存中有 1000 多個自定義線程池。一般而言,線程池肯定是復用的,有 5 個以內的線程池都可以認為正常,而 1000 多個線程池肯定不正常。

在項目代碼里,我們沒有搜到聲明線程池的地方,搜索 execute 關鍵字后定位到,原來是業務代碼調用了一個類庫來獲得線程池,類似如下的業務代碼:調用 ThreadPoolHelper 的 getThreadPool 方法來獲得線程池,然后提交數個任務到線程池處理,看不出什么異常。

【系統架構】使用線程池你需要注意這幾點
 
 
 

但是,來到 ThreadPoolHelper 的實現讓人大跌眼鏡,getThreadPool 方法居然是每次都使用 Executors.newCachedThreadPool 來創建一個線程池。

【系統架構】使用線程池你需要注意這幾點
 
 
 

通過上一小節的學習,我們可以想到 newCachedThreadPool 會在需要時創建必要多的線程,業務代碼的一次業務操作會向線程池提交多個慢任務,這樣執行一次業務操作就會開啟多個線程。如果業務操作并發量較大的話,的確有可能一下子開啟幾千個線程。

那為什么我們能在監控中看到線程數量會下降,而不會撐爆內存呢?

回到 newCachedThreadPool 的定義就會發現,它的核心線程數是 0,而 keepAliveTime 是 60 秒,也就是在 60 秒之后所有的線程都是可以回收的。好吧,就因為這個特性,我們的業務程序死得沒太難看。

要修復這個 Bug 也很簡單,使用一個靜態字段來存放線程池的引用,返回線程池的代碼直接返回這個靜態字段即可。這里一定要記得我們的最佳實踐,手動創建線程池。修復后的 ThreadPoolHelper 類如下:

【系統架構】使用線程池你需要注意這幾點
 
 
 

需要仔細斟酌線程池的混用策略

線程池的意義在于復用,那這是不是意味著程序應該始終使用一個線程池呢?

當然不是。這要根據任務的“輕重緩急”來指定線程池的核心參數,包括線程數、回收策略和任務隊列:

  1. 對于執行比較慢、數量不大的 IO 任務,或許要考慮更多的線程數,而不需要太大的隊列。
  2. 而對于吞吐量較大的計算型任務,線程數量不宜過多,可以是 CPU 核數或核數 *2(理由是,線程一定調度到某個 CPU 進行執行,如果任務本身是 CPU 綁定的任務,那么過多的線程只會增加線程切換的開銷,并不能提升吞吐量),但可能需要較長的隊列來做緩沖。

之前我也遇到過這么一個問題,業務代碼使用了線程池異步處理一些內存中的數據,但通過監控發現處理得非常慢,整個處理過程都是內存中的計算不涉及 IO 操作,也需要數秒的處理時間,應用程序 CPU 占用也不是特別高,有點不可思議。

經排查發現,業務代碼使用的線程池,還被一個后臺的文件批處理任務用到了。

或許是夠用就好的原則,這個線程池只有 2 個核心線程,最大線程也是 2,使用了容量為 100 的 ArrayBlockingQueue 作為工作隊列,使用了 CallerRunsPolicy 拒絕策略:

【系統架構】使用線程池你需要注意這幾點
 
 
 

這里,我們模擬一下文件批處理的代碼,在程序啟動后通過一個線程開啟死循環邏輯,不斷向線程池提交任務,任務的邏輯是向一個文件中寫入大量的數據:

【系統架構】使用線程池你需要注意這幾點
 
 
 

可以想象到,這個線程池中的 2 個線程任務是相當重的。通過 printStats 方法打印出的日志,我們觀察下線程池的負擔:

【系統架構】使用線程池你需要注意這幾點
 
 
 

可以看到,線程池的 2 個線程始終處于活躍狀態,隊列也基本處于打滿狀態。因為開啟了 CallerRunsPolicy 拒絕處理策略,所以當線程滿載隊列也滿的情況下,任務會在提交任務的線程,或者說調用 execute 方法的線程執行,也就是說不能認為提交到線程池的任務就一定是異步處理的。如果使用了 CallerRunsPolicy 策略,那么有可能異步任務變為同步執行。從日志的第四行也可以看到這點。這也是這個拒絕策略比較特別的原因。

不知道寫代碼的同學為什么設置這個策略,或許是測試時發現線程池因為任務處理不過來出現了異常,而又不希望線程池丟棄任務,所以最終選擇了這樣的拒絕策略。不管怎樣,這些日志足以說明線程池是飽和狀態。

可以想象到,業務代碼復用這樣的線程池來做內存計算,命運一定是悲慘的。我們寫一段代碼測試下,向線程池提交一個簡單的任務,這個任務只是休眠 10 毫秒沒有其他邏輯:

【系統架構】使用線程池你需要注意這幾點
 
 
 

我們使用 wrk 工具對這個接口進行一個簡單的壓測,可以看到 TPS 為 75,性能的確非常差。

【系統架構】使用線程池你需要注意這幾點
 
 
 

細想一下,問題其實沒有這么簡單。因為原來執行 IO 任務的線程池使用的是 CallerRunsPolicy 策略,所以直接使用這個線程池進行異步計算的話,當線程池飽和的時候,計算任務會在執行 Web 請求的 Tomcat 線程執行,這時就會進一步影響到其他同步處理的線程,甚至造成整個應用程序崩潰。

解決方案很簡單,使用獨立的線程池來做這樣的“計算任務”即可。計算任務打了雙引號,是因為我們的模擬代碼執行的是休眠操作,并不屬于 CPU 綁定的操作,更類似 IO 綁定的操作,如果線程池線程數設置太小會限制吞吐能力:

【系統架構】使用線程池你需要注意這幾點
 
 
 

使用單獨的線程池改造代碼后再來測試一下性能,TPS 提高到了 1727:

【系統架構】使用線程池你需要注意這幾點
 
 
 

可以看到,盲目復用線程池混用線程的問題在于,別人定義的線程池屬性不一定適合你的任務,而且混用會相互干擾。這就好比,我們往往會用虛擬化技術來實現資源的隔離,而不是讓所有應用程序都直接使用物理機。

就線程池混用問題,我想再和你補充一個坑:Java 8 的 parallel stream 功能,可以讓我們很方便地并行處理集合中的元素,其背后是共享同一個 ForkJoinPool,默認并行度是 CPU 核數 -1。對于 CPU 綁定的任務來說,使用這樣的配置比較合適,但如果集合操作涉及同步 IO 操作的話(比如數據庫操作、外部服務調用等),建議自定義一個 ForkJoinPool(或普通線程池)。

重點回顧

線程池管理著線程,線程又屬于寶貴的資源,有許多應用程序的性能問題都來自線程池的配置和使用不當。在今天的學習中,我通過三個和線程池相關的生產事故,和你分享了使用線程池的幾個最佳實踐。

第一,Executors 類提供的一些快捷聲明線程池的方法雖然簡單,但隱藏了線程池的參數細節。因此,使用線程池時,我們一定要根據場景和需求配置合理的線程數、任務隊列、拒絕策略、線程回收策略,并對線程進行明確的命名方便排查問題。

第二,既然使用了線程池就需要確保線程池是在復用的,每次 new 一個線程池出來可能比不用線程池還糟糕。如果你沒有直接聲明線程池而是使用其他同學提供的類庫來獲得一個線程池,請務必查看源碼,以確認線程池的實例化方式和配置是符合預期的。

第三,復用線程池不代表應用程序始終使用同一個線程池,我們應該根據任務的性質來選用不同的線程池。特別注意 IO 綁定的任務和 CPU 綁定的任務對于線程池屬性的偏好,如果希望減少任務間的相互干擾,考慮按需使用隔離的線程池。

最后,我想強調的是,線程池作為應用程序內部的核心組件往往缺乏監控(如果你使用類似 RabbitMQ 這樣的 MQ 中間件,運維同學一般會幫我們做好中間件監控),往往到程序崩潰后才發現線程池的問題,很被動。

分享到:
標簽:線程
用戶無頭像

網友整理

注冊時間:

網站:5 個   小程序:0 個  文章:12 篇

  • 51998

    網站

  • 12

    小程序

  • 1030137

    文章

  • 747

    會員

趕快注冊賬號,推廣您的網站吧!
最新入駐小程序

數獨大挑戰2018-06-03

數獨一種數學游戲,玩家需要根據9

答題星2018-06-03

您可以通過答題星輕松地創建試卷

全階人生考試2018-06-03

各種考試題,題庫,初中,高中,大學四六

運動步數有氧達人2018-06-03

記錄運動步數,積累氧氣值。還可偷

每日養生app2018-06-03

每日養生,天天健康

體育訓練成績評定2018-06-03

通用課目體育訓練成績評定