Java執行緒池實現原理及其在美團業務中的實踐

guoduan發表於2020-04-18

隨著計算機行業的飛速發展,摩爾定律逐漸失效,多核CPU成為主流。使用多執行緒平行計算逐漸成為開發人員提升伺服器效能的基本武器。J.U.C提供的執行緒池ThreadPoolExecutor類,幫助開發人員管理執行緒並方便地執行並行任務。瞭解併合理使用執行緒池,是一個開發人員必修的基本功。

本文開篇簡述執行緒池概念和用途,接著結合執行緒池的原始碼,幫助讀者領略執行緒池的設計思路,最後迴歸實踐,透過案例講述使用執行緒池遇到的問題,並給出了一種動態化執行緒池解決方案。

一、寫在前面

1.1 執行緒池是什麼

執行緒池(Thread Pool)是一種基於池化思想管理執行緒的工具,經常出現在多執行緒伺服器中,如MySQL。

執行緒過多會帶來額外的開銷,其中包括建立銷燬執行緒的開銷、排程執行緒的開銷等等,同時也降低了計算機的整體效能。執行緒池維護多個執行緒,等待監督管理者分配可併發執行的任務。這種做法,一方面避免了處理任務時建立銷燬執行緒開銷的代價,另一方面避免了執行緒數量膨脹導致的過分排程問題,保證了對核心的充分利用。

而本文描述執行緒池是JDK中提供的ThreadPoolExecutor類。

當然,使用執行緒池可以帶來一系列好處:

  • 降低資源消耗:透過池化技術重複利用已建立的執行緒,降低執行緒建立和銷燬造成的損耗。

  • 提高響應速度:任務到達時,無需等待執行緒建立即可立即執行。

  • 提高執行緒的可管理性:執行緒是稀缺資源,如果無限制建立,不僅會消耗系統資源,還會因為執行緒的不合理分佈導致資源排程失衡,降低系統的穩定性。使用執行緒池可以進行統一的分配、調優和監控。

  • 提供更多更強大的功能:執行緒池具備可擴充性,允許開發人員向其中增加更多的功能。比如延時定時執行緒池ScheduledThreadPoolExecutor,就允許任務延期執行或定期執行。

1.2 執行緒池解決的問題是什麼

執行緒池解決的核心問題就是資源管理問題。在併發環境下,系統不能夠確定在任意時刻中,有多少任務需要執行,有多少資源需要投入。這種不確定性將帶來以下若干問題:

  1. 頻繁申請/銷燬資源和排程資源,將帶來額外的消耗,可能會非常巨大。

  2. 對資源無限申請缺少抑制手段,易引發系統資源耗盡的風險。

  3. 系統無法合理管理內部的資源分佈,會降低系統的穩定性。

為解決資源分配這個問題,執行緒池採用了“池化”(Pooling)思想。池化,顧名思義,是為了最大化收益並最小化風險,而將資源統一在一起管理的一種思想。

Pooling is the grouping together of resources (assets, equipment, personnel, effort, etc.) for the purposes of maximizing advantage or minimizing risk to the users. The term is used in finance, computing and equipment management.——wikipedia

“池化”思想不僅僅能應用在計算機領域,在金融、裝置、人員管理、工作管理等領域也有相關的應用。

在計算機領域中的表現為:統一管理IT資源,包括伺服器、儲存、和網路資源等等。透過共享資源,使使用者在低投入中獲益。除去執行緒池,還有其他比較典型的幾種使用策略包括:

  1. 記憶體池(Memory Pooling):預先申請記憶體,提升申請記憶體速度,減少記憶體碎片。

  2. 連線池(Connection Pooling):預先申請資料庫連線,提升申請連線的速度,降低系統的開銷。

  3. 例項池(Object Pooling):迴圈使用物件,減少資源在初始化和釋放時的昂貴損耗。

在瞭解完“是什麼”和“為什麼”之後,下面我們來一起深入一下執行緒池的內部實現原理。

二、執行緒池核心設計與實現

在前文中,我們瞭解到:執行緒池是一種透過“池化”思想,幫助我們管理執行緒而獲取併發性的工具,在Java中的體現是ThreadPoolExecutor類。那麼它的的詳細設計與實現是什麼樣的呢?我們會在本章進行詳細介紹。

2.1 總體設計

Java中的執行緒池核心實現類是ThreadPoolExecutor,本章基於JDK 1.8的原始碼來分析Java執行緒池的核心設計與實現。我們首先來看一下ThreadPoolExecutor的UML類圖,瞭解下ThreadPoolExecutor的繼承關係。

Java執行緒池實現原理及其在美團業務中的實踐

ThreadPoolExecutor實現的頂層介面是Executor,頂層介面Executor提供了一種思想:將任務提交和任務執行進行解耦。使用者無需關注如何建立執行緒,如何排程執行緒來執行任務,使用者只需提供Runnable物件,將任務的執行邏輯提交到執行器(Executor)中,由Executor框架完成執行緒的調配和任務的執行部分。ExecutorService介面增加了一些能力:(1)擴充執行任務的能力,補充可以為一個或一批非同步任務生成Future的方法;(2)提供了管控執行緒池的方法,比如停止執行緒池的執行。

AbstractExecutorService則是上層的抽象類,將執行任務的流程串聯了起來,保證下層的實現只需關注一個執行任務的方法即可。最下層的實現類ThreadPoolExecutor實現最複雜的執行部分,ThreadPoolExecutor將會一方面維護自身的生命週期,另一方面同時管理執行緒和任務,使兩者良好的結合從而執行並行任務。

ThreadPoolExecutor是如何執行,如何同時維護執行緒和執行任務的呢?其執行機制如下圖所示:

Java執行緒池實現原理及其在美團業務中的實踐

執行緒池在內部實際上構建了一個生產者消費者模型,將執行緒和任務兩者解耦,並不直接關聯,從而良好的緩衝任務,複用執行緒。執行緒池的執行主要分成兩部分:任務管理、執行緒管理。任務管理部分充當生產者的角色,當任務提交後,執行緒池會判斷該任務後續的流轉:(1)直接申請執行緒執行該任務;(2)緩衝到佇列中等待執行緒執行;(3)拒絕該任務。執行緒管理部分是消費者,它們被統一維護線上程池內,根據任務請求進行執行緒的分配,當執行緒執行完任務後則會繼續獲取新的任務去執行,最終當執行緒獲取不到任務的時候,執行緒就會被回收。

接下來,我們會按照以下三個部分去詳細講解執行緒池執行機制:

  1. 執行緒池如何維護自身狀態。

  2. 執行緒池如何管理任務。

  3. 執行緒池如何管理執行緒。

2.2 生命週期管理

執行緒池執行的狀態,並不是使用者顯式設定的,而是伴隨著執行緒池的執行,由內部來維護。執行緒池內部使用一個變數維護兩個值:執行狀態(runState)和執行緒數量 (workerCount)。在具體實現中,執行緒池將執行狀態(runState)、執行緒數量 (workerCount)兩個關鍵引數的維護放在了一起,如下程式碼所示:

private final AtomicInteger ctl = new AtomicInteger(ctlOf(RUNNING, 0));

ctl這個AtomicInteger型別,是對執行緒池的執行狀態和執行緒池中有效執行緒的數量進行控制的一個欄位, 它同時包含兩部分的資訊:執行緒池的執行狀態 (runState) 和執行緒池內有效執行緒的數量 (workerCount),高3位儲存runState,低29位儲存workerCount,兩個變數之間互不干擾。用一個變數去儲存兩個值,可避免在做相關決策時,出現不一致的情況,不必為了維護兩者的一致,而佔用鎖資源。透過閱讀執行緒池原始碼也可以發現,經常出現要同時判斷執行緒池執行狀態和執行緒數量的情況。執行緒池也提供了若干方法去供使用者獲得執行緒池當前的執行狀態、執行緒個數。這裡都使用的是位運算的方式,相比於基本運算,速度也會快很多。

關於內部封裝的獲取生命週期狀態、獲取執行緒池執行緒數量的計算方法如以下程式碼所示:

private static int runStateOf(int c)     return c & ~CAPACITY; } //計算當前執行狀態
private static int workerCountOf(int c)  return c & CAPACITY; }  //計算當前執行緒數量
private static int ctlOf(int rs, int wc) return rs | wc; }   //透過狀態和執行緒數生成ctl

ThreadPoolExecutor的執行狀態有5種,分別為:

Java執行緒池實現原理及其在美團業務中的實踐

其生命週期轉換如下入所示:

Java執行緒池實現原理及其在美團業務中的實踐

2.3 任務執行機制

2.3.1 任務排程

任務排程是執行緒池的主要入口,當使用者提交了一個任務,接下來這個任務將如何執行都是由這個階段決定的。瞭解這部分就相當於瞭解了執行緒池的核心執行機制。

首先,所有任務的排程都是由execute方法完成的,這部分完成的工作是:檢查現線上程池的執行狀態、執行執行緒數、執行策略,決定接下來執行的流程,是直接申請執行緒執行,或是緩衝到佇列中執行,亦或是直接拒絕該任務。其執行過程如下:

  1. 首先檢測執行緒池執行狀態,如果不是RUNNING,則直接拒絕,執行緒池要保證在RUNNING的狀態下執行任務。

  2. 如果workerCount < corePoolSize,則建立並啟動一個執行緒來執行新提交的任務。

  3. 如果workerCount >= corePoolSize,且執行緒池內的阻塞佇列未滿,則將任務新增到該阻塞佇列中。

  4. 如果workerCount >= corePoolSize && workerCount < maximumPoolSize,且執行緒池內的阻塞佇列已滿,則建立並啟動一個執行緒來執行新提交的任務。

  5. 如果workerCount >= maximumPoolSize,並且執行緒池內的阻塞佇列已滿, 則根據拒絕策略來處理該任務, 預設的處理方式是直接拋異常。

其執行流程如下圖所示:

Java執行緒池實現原理及其在美團業務中的實踐

2.3.2 任務緩衝

任務緩衝模組是執行緒池能夠管理任務的核心部分。執行緒池的本質是對任務和執行緒的管理,而做到這一點最關鍵的思想就是將任務和執行緒兩者解耦,不讓兩者直接關聯,才可以做後續的分配工作。執行緒池中是以生產者消費者模式,透過一個阻塞佇列來實現的。阻塞佇列快取任務,工作執行緒從阻塞佇列中獲取任務。

阻塞佇列(BlockingQueue)是一個支援兩個附加操作的佇列。這兩個附加的操作是:在佇列為空時,獲取元素的執行緒會等待佇列變為非空。當佇列滿時,儲存元素的執行緒會等待佇列可用。阻塞佇列常用於生產者和消費者的場景,生產者是往佇列裡新增元素的執行緒,消費者是從佇列裡拿元素的執行緒。阻塞佇列就是生產者存放元素的容器,而消費者也只從容器裡拿元素。

下圖中展示了執行緒1往阻塞佇列中新增元素,而執行緒2從阻塞佇列中移除元素:

Java執行緒池實現原理及其在美團業務中的實踐

使用不同的佇列可以實現不一樣的任務存取策略。在這裡,我們可以再介紹下阻塞佇列的成員:

Java執行緒池實現原理及其在美團業務中的實踐

2.3.3 任務申請

由上文的任務分配部分可知,任務的執行有兩種可能:一種是任務直接由新建立的執行緒執行。另一種是執行緒從任務佇列中獲取任務然後執行,執行完任務的空閒執行緒會再次去從佇列中申請任務再去執行。第一種情況僅出現線上程初始建立的時候,第二種是執行緒獲取任務絕大多數的情況。

執行緒需要從任務快取模組中不斷地取任務執行,幫助執行緒從阻塞佇列中獲取任務,實現執行緒管理模組和任務管理模組之間的通訊。這部分策略由getTask方法實現,其執行流程如下圖所示:

Java執行緒池實現原理及其在美團業務中的實踐

getTask這部分進行了多次判斷,為的是控制執行緒的數量,使其符合執行緒池的狀態。如果執行緒池現在不應該持有那麼多執行緒,則會返回null值。工作執行緒Worker會不斷接收新任務去執行,而當工作執行緒Worker接收不到任務的時候,就會開始被回收。

2.3.4 任務拒絕

任務拒絕模組是執行緒池的保護部分,執行緒池有一個最大的容量,當執行緒池的任務快取佇列已滿,並且執行緒池中的執行緒數目達到maximumPoolSize時,就需要拒絕掉該任務,採取任務拒絕策略,保護執行緒池。

拒絕策略是一個介面,其設計如下:

public interface RejectedExecutionHandler {
    void rejectedExecution(Runnable r, ThreadPoolExecutor executor);
}

使用者可以透過實現這個介面去定製拒絕策略,也可以選擇JDK提供的四種已有拒絕策略,其特點如下:

Java執行緒池實現原理及其在美團業務中的實踐

2.4 Worker執行緒管理

2.4.1 Worker執行緒

執行緒池為了掌握執行緒的狀態並維護執行緒的生命週期,設計了執行緒池內的工作執行緒Worker。我們來看一下它的部分程式碼:

private final class Worker extends AbstractQueuedSynchronizer implements Runnable{
    final Thread thread;//Worker持有的執行緒
    Runnable firstTask;//初始化的任務,可以為null
}

Worker這個工作執行緒,實現了Runnable介面,並持有一個執行緒thread,一個初始化的任務firstTask。thread是在呼叫構造方法時透過ThreadFactory來建立的執行緒,可以用來執行任務;firstTask用它來儲存傳入的第一個任務,這個任務可以有也可以為null。如果這個值是非空的,那麼執行緒就會在啟動初期立即執行這個任務,也就對應核心執行緒建立時的情況;如果這個值是null,那麼就需要建立一個執行緒去執行任務列表(workQueue)中的任務,也就是非核心執行緒的建立。

Worker執行任務的模型如下圖所示:

Java執行緒池實現原理及其在美團業務中的實踐

執行緒池需要管理執行緒的生命週期,需要線上程長時間不執行的時候進行回收。執行緒池使用一張Hash表去持有執行緒的引用,這樣可以透過新增引用、移除引用這樣的操作來控制執行緒的生命週期。這個時候重要的就是如何判斷執行緒是否在執行。

Worker是透過繼承AQS,使用AQS來實現獨佔鎖這個功能。沒有使用可重入鎖ReentrantLock,而是使用AQS,為的就是實現不可重入的特性去反應執行緒現在的執行狀態。

  1. lock方法一旦獲取了獨佔鎖,表示當前執行緒正在執行任務中。

  2. 如果正在執行任務,則不應該中斷執行緒。

  3. 如果該執行緒現在不是獨佔鎖的狀態,也就是空閒的狀態,說明它沒有在處理任務,這時可以對該執行緒進行中斷。

  4. 執行緒池在執行shutdown方法或tryTerminate方法時會呼叫interruptIdleWorkers方法來中斷空閒的執行緒,interruptIdleWorkers方法會使用tryLock方法來判斷執行緒池中的執行緒是否是空閒狀態;如果執行緒是空閒狀態則可以安全回收。

線上程回收過程中就使用到了這種特性,回收過程如下圖所示:

Java執行緒池實現原理及其在美團業務中的實踐

2.4.2 Worker執行緒增加

增加執行緒是透過執行緒池中的addWorker方法,該方法的功能就是增加一個執行緒,該方法不考慮執行緒池是在哪個階段增加的該執行緒,這個分配執行緒的策略是在上個步驟完成的,該步驟僅僅完成增加執行緒,並使它執行,最後返回是否成功這個結果。addWorker方法有兩個引數:firstTask、core。firstTask引數用於指定新增的執行緒執行的第一個任務,該引數可以為空;core引數為true表示在新增執行緒時會判斷當前活動執行緒數是否少於corePoolSize,false表示新增執行緒前需要判斷當前活動執行緒數是否少於maximumPoolSize,其執行流程如下圖所示:

Java執行緒池實現原理及其在美團業務中的實踐

2.4.3 Worker執行緒回收

執行緒池中執行緒的銷燬依賴JVM自動的回收,執行緒池做的工作是根據當前執行緒池的狀態維護一定數量的執行緒引用,防止這部分執行緒被JVM回收,當執行緒池決定哪些執行緒需要回收時,只需要將其引用消除即可。Worker被建立出來後,就會不斷地進行輪詢,然後獲取任務去執行,核心執行緒可以無限等待獲取任務,非核心執行緒要限時獲取任務。當Worker無法獲取到任務,也就是獲取的任務為空時,迴圈會結束,Worker會主動消除自身線上程池內的引用。

try {
  while (task != null || (task = getTask()) != null) {
    //執行任務
  }
finally {
  processWorkerExit(w, completedAbruptly);//獲取不到任務時,主動回收自己
}

執行緒回收的工作是在processWorkerExit方法完成的。

Java執行緒池實現原理及其在美團業務中的實踐

事實上,在這個方法中,將執行緒引用移出執行緒池就已經結束了執行緒銷燬的部分。但由於引起執行緒銷燬的可能性有很多,執行緒池還要判斷是什麼引發了這次銷燬,是否要改變執行緒池的現階段狀態,是否要根據新狀態,重新分配執行緒。

2.4.4 Worker執行緒執行任務

在Worker類中的run方法呼叫了runWorker方法來執行任務,runWorker方法的執行過程如下:

  1. while迴圈不斷地透過getTask()方法獲取任務。

  2. getTask()方法從阻塞佇列中取任務。

  3. 如果執行緒池正在停止,那麼要保證當前執行緒是中斷狀態,否則要保證當前執行緒不是中斷狀態。

  4. 執行任務。

  5. 如果getTask結果為null則跳出迴圈,執行processWorkerExit()方法,銷燬執行緒。

執行流程如下圖所示:

Java執行緒池實現原理及其在美團業務中的實踐

三、執行緒池在業務中的實踐

3.1 業務背景

在當今的網際網路業界,為了最大程度利用CPU的多核效能,並行運算的能力是不可或缺的。透過執行緒池管理執行緒獲取併發性是一個非常基礎的操作,讓我們來看兩個典型的使用執行緒池獲取併發性的場景。

場景1:快速響應使用者請求

描述:使用者發起的實時請求,服務追求響應時間。比如說使用者要檢視一個商品的資訊,那麼我們需要將商品維度的一系列資訊如商品的價格、優惠、庫存、圖片等等聚合起來,展示給使用者。

分析:從使用者體驗角度看,這個結果響應的越快越好,如果一個頁面半天都刷不出,使用者可能就放棄檢視這個商品了。而面向使用者的功能聚合通常非常複雜,伴隨著呼叫與呼叫之間的級聯、多級級聯等情況,業務開發同學往往會選擇使用執行緒池這種簡單的方式,將呼叫封裝成任務並行的執行,縮短總體響應時間。另外,使用執行緒池也是有考量的,這種場景最重要的就是獲取最大的響應速度去滿足使用者,所以應該不設定佇列去緩衝併發任務,調高corePoolSize和maxPoolSize去儘可能創造多的執行緒快速執行任務。

Java執行緒池實現原理及其在美團業務中的實踐

場景2:快速處理批次任務

描述:離線的大量計算任務,需要快速執行。比如說,統計某個報表,需要計算出全國各個門店中有哪些商品有某種屬性,用於後續營銷策略的分析,那麼我們需要查詢全國所有門店中的所有商品,並且記錄具有某屬性的商品,然後快速生成報表。

分析:這種場景需要執行大量的任務,我們也會希望任務執行的越快越好。這種情況下,也應該使用多執行緒策略,平行計算。但與響應速度優先的場景區別在於,這類場景任務量巨大,並不需要瞬時的完成,而是關注如何使用有限的資源,儘可能在單位時間內處理更多的任務,也就是吞吐量優先的問題。所以應該設定佇列去緩衝併發任務,調整合適的corePoolSize去設定處理任務的執行緒數。在這裡,設定的執行緒數過多可能還會引發執行緒上下文切換頻繁的問題,也會降低處理任務的速度,降低吞吐量。

Java執行緒池實現原理及其在美團業務中的實踐

3.2 實際問題及方案思考

執行緒池使用面臨的核心的問題在於:執行緒池的引數並不好配置。一方面執行緒池的執行機制不是很好理解,配置合理需要強依賴開發人員的個人經驗和知識;另一方面,執行緒池執行的情況和任務型別相關性較大,IO密集型和CPU密集型的任務執行起來的情況差異非常大,這導致業界並沒有一些成熟的經驗策略幫助開發人員參考。

關於執行緒池配置不合理引發的故障,公司內部有較多記錄,下面舉一些例子:

Case1:2018年XX頁面展示介面大量呼叫降級。

事故描述:XX頁面展示介面產生大量呼叫降級,數量級在幾十到上百。

事故原因:該服務展示介面內部邏輯使用執行緒池做平行計算,由於沒有預估好呼叫的流量,導致最大核心數設定偏小,大量丟擲RejectedExecutionException,觸發介面降級條件,示意圖如下:

Java執行緒池實現原理及其在美團業務中的實踐

Case2:2018年XX業務服務不可用S2級故障。

事故描述:XX業務提供的服務執行時間過長,作為上游服務整體超時,大量下游服務呼叫失敗。

事故原因:該服務處理請求內部邏輯使用執行緒池做資源隔離,由於佇列設定過長,最大執行緒數設定失效,導致請求數量增加時,大量任務堆積在佇列中,任務執行時間過長,最終導致下游服務的大量呼叫超時失敗。示意圖如下:

Java執行緒池實現原理及其在美團業務中的實踐

業務中要使用執行緒池,而使用不當又會導致故障,那麼我們怎樣才能更好地使用執行緒池呢?針對這個問題,我們下面延展幾個方向:

1. 能否不用執行緒池?

回到最初的問題,業務使用執行緒池是為了獲取併發性,對於獲取併發性,是否可以有什麼其他的方案呢替代?我們嘗試進行了一些其他方案的調研:

Java執行緒池實現原理及其在美團業務中的實踐

綜合考慮,這些新的方案都能在某種情況下提升並行任務的效能,然而本次重點解決的問題是如何更簡易、更安全地獲得的併發性。另外,Actor模型的應用實際上甚少,只在Scala中使用廣泛,協程框架在Java中維護的也不成熟。這三者現階段都不是足夠的易用,也並不能解決業務上現階段的問題。

2. 追求引數設定合理性?

有沒有一種計算公式,能夠讓開發同學很簡易地計算出某種場景中的執行緒池應該是什麼引數呢?

帶著這樣的疑問,我們調研了業界的一些執行緒池引數配置方案:

Java執行緒池實現原理及其在美團業務中的實踐

調研了以上業界方案後,我們並沒有得出通用的執行緒池計算方式。併發任務的執行情況和任務型別相關,IO密集型和CPU密集型的任務執行起來的情況差異非常大,但這種佔比是較難合理預估的,這導致很難有一個簡單有效的通用公式幫我們直接計算出結果。

3. 執行緒池引數動態化?

儘管經過謹慎的評估,仍然不能夠保證一次計算出來合適的引數,那麼我們是否可以將修改執行緒池引數的成本降下來,這樣至少可以發生故障的時候可以快速調整從而縮短故障恢復的時間呢?基於這個思考,我們是否可以將執行緒池的引數從程式碼中遷移到分散式配置中心上,實現執行緒池引數可動態配置和即時生效,執行緒池引數動態化前後的引數修改流程對比如下:

Java執行緒池實現原理及其在美團業務中的實踐

基於以上三個方向對比,我們可以看出引數動態化方向簡單有效。

3.3 動態化執行緒池

3.3.1 整體設計

動態化執行緒池的核心設計包括以下三個方面:

  1. 簡化執行緒池配置:執行緒池構造引數有8個,但是最核心的是3個:corePoolSize、maximumPoolSize,workQueue,它們最大程度地決定了執行緒池的任務分配和執行緒分配策略。考慮到在實際應用中我們獲取併發性的場景主要是兩種:(1)並行執行子任務,提高響應速度。這種情況下,應該使用同步佇列,沒有什麼任務應該被快取下來,而是應該立即執行。(2)並行執行大批次任務,提升吞吐量。這種情況下,應該使用有界佇列,使用佇列去緩衝大批次的任務,佇列容量必須宣告,防止任務無限制堆積。所以執行緒池只需要提供這三個關鍵引數的配置,並且提供兩種佇列的選擇,就可以滿足絕大多數的業務需求,Less is More。
  2. 引數可動態修改:為了解決引數不好配,修改引數成本高等問題。在Java執行緒池留有高擴充套件性的基礎上,封裝執行緒池,允許執行緒池監聽同步外部的訊息,根據訊息進行修改配置。將執行緒池的配置放置在平臺側,允許開發同學簡單的檢視、修改執行緒池配置。
  3. 增加執行緒池監控:對某事物缺乏狀態的觀測,就對其改進無從下手。線上程池執行任務的生命週期新增監控能力,幫助開發同學瞭解執行緒池狀態。

Java執行緒池實現原理及其在美團業務中的實踐

3.3.2 功能架構

動態化執行緒池提供如下功能:

  • 動態調參:支援執行緒池引數動態調整、介面化操作;包括修改執行緒池核心大小、最大核心大小、佇列長度等;引數修改後及時生效。

  • 任務監控:支援應用粒度、執行緒池粒度、任務粒度的Transaction監控;可以看到執行緒池的任務執行情況、最大任務執行時間、平均任務執行時間、95/99線等。

  • 負載告警:執行緒池佇列任務積壓到一定值的時候會透過大象(美團內部通訊工具)告知應用開發負責人;當執行緒池負載數達到一定閾值的時候會透過大象告知應用開發負責人。

  • 操作監控:建立/修改和刪除執行緒池都會通知到應用的開發負責人。

  • 操作日誌:可以檢視執行緒池引數的修改記錄,誰在什麼時候修改了執行緒池引數、修改前的引數值是什麼。

  • 許可權校驗:只有應用開發負責人才能夠修改應用的執行緒池引數。

Java執行緒池實現原理及其在美團業務中的實踐

引數動態化

JDK原生執行緒池ThreadPoolExecutor提供瞭如下幾個public的setter方法,如下圖所示:

Java執行緒池實現原理及其在美團業務中的實踐

JDK允許執行緒池使用方透過ThreadPoolExecutor的例項來動態設定執行緒池的核心策略,以setCorePoolSize為方法例,在執行期執行緒池使用方呼叫此方法設定corePoolSize之後,執行緒池會直接覆蓋原來的corePoolSize值,並且基於當前值和原始值的比較結果採取不同的處理策略。對於當前值小於當前工作執行緒數的情況,說明有多餘的worker執行緒,此時會向當前idle的worker執行緒發起中斷請求以實現回收,多餘的worker在下次idle的時候也會被回收;對於當前值大於原始值且當前佇列中有待執行任務,則執行緒池會建立新的worker執行緒來執行佇列任務,setCorePoolSize具體流程如下:

Java執行緒池實現原理及其在美團業務中的實踐

執行緒池內部會處理好當前狀態做到平滑修改,其他幾個方法限於篇幅,這裡不一一介紹。重點是基於這幾個public方法,我們只需要維護ThreadPoolExecutor的例項,並且在需要修改的時候拿到例項修改其引數即可。基於以上的思路,我們實現了執行緒池引數的動態化、執行緒池引數在管理平臺可配置可修改,其效果圖如下圖所示:

Java執行緒池實現原理及其在美團業務中的實踐

使用者可以在管理平臺上透過執行緒池的名字找到指定的執行緒池,然後對其引數進行修改,儲存後會實時生效。目前支援的動態引數包括核心數、最大值、佇列長度等。除此之外,在介面中,我們還能看到使用者可以配置是否開啟告警、佇列等待任務告警閾值、活躍度告警等等。關於監控和告警,我們下面一節會對齊進行介紹。

執行緒池監控

除了引數動態化之外,為了更好地使用執行緒池,我們需要對執行緒池的執行狀況有感知,比如當前執行緒池的負載是怎麼樣的?分配的資源夠不夠用?任務的執行情況是怎麼樣的?是長任務還是短任務?

基於對這些問題的思考,動態化執行緒池提供了多個維度的監控和告警能力,包括:執行緒池活躍度、任務的執行Transaction(頻率、耗時)、Reject異常、執行緒池內部統計資訊等等,既能幫助使用者從多個維度分析執行緒池的使用情況,又能在出現問題第一時間通知到使用者,從而避免故障或加速故障恢復。

1. 負載監控和告警

執行緒池負載關注的核心問題是:基於當前執行緒池引數分配的資源夠不夠。對於這個問題,我們可以從事前和事中兩個角度來看。事前,執行緒池定義了“活躍度”這個概念,來讓使用者在發生Reject異常之前能夠感知執行緒池負載問題,執行緒池活躍度計算公式為:執行緒池活躍度 = activeCount/maximumPoolSize。這個公式代表當活躍執行緒數趨向於maximumPoolSize的時候,代表執行緒負載趨高。

事中,也可以從兩方面來看執行緒池的過載判定條件,一個是發生了Reject異常,一個是佇列中有等待任務(支援定製閾值)。以上兩種情況發生了都會觸發告警,告警資訊會透過大象推送給服務所關聯的負責人。

Java執行緒池實現原理及其在美團業務中的實踐

2. 任務級精細化監控

在傳統的執行緒池應用場景中,執行緒池中的任務執行情況對於使用者來說是透明的。比如在一個具體的業務場景中,業務開發申請了一個執行緒池同時用於執行兩種任務,一個是發訊息任務、一個是發簡訊任務,這兩類任務實際執行的頻率和時長對於使用者來說沒有一個直觀的感受,很可能這兩類任務不適合共享一個執行緒池,但是由於使用者無法感知,因此也無從最佳化。動態化執行緒池內部實現了任務級別的埋點,且允許為不同的業務任務指定具有業務含義的名稱,執行緒池內部基於這個名稱做Transaction打點,基於這個功能,使用者可以看到執行緒池內部任務級別的執行情況,且區分業務,任務監控示意圖如下圖所示:

Java執行緒池實現原理及其在美團業務中的實踐

3. 執行時狀態實時檢視

使用者基於JDK原生執行緒池ThreadPoolExecutor提供的幾個public的getter方法,可以讀取到當前執行緒池的執行狀態以及引數,如下圖所示:

Java執行緒池實現原理及其在美團業務中的實踐

動態化執行緒池基於這幾個介面封裝了執行時狀態實時檢視的功能,使用者基於這個功能可以瞭解執行緒池的實時狀態,比如當前有多少個工作執行緒,執行了多少個任務,佇列中等待的任務數等等。效果如下圖所示:

Java執行緒池實現原理及其在美團業務中的實踐

3.4 實踐總結

面對業務中使用執行緒池遇到的實際問題,我們曾回到支援併發性問題本身來思考有沒有取代執行緒池的方案,也曾嘗試著去追求執行緒池引數設定的合理性,但面對業界方案具體落地的複雜性、可維護性以及真實執行環境的不確定性,我們在前兩個方向上可謂“舉步維艱”。

最終,我們回到執行緒池引數動態化方向上探索,得出一個且可以解決業務問題的方案,雖然本質上還是沒有逃離使用執行緒池的範疇,但是在成本和收益之間,算是取得了一個很好的平衡。成本在於實現動態化以及監控成本不高,收益在於:在不顛覆原有執行緒池使用方式的基礎之上,從降低執行緒池引數修改的成本以及多維度監控這兩個方面降低了故障發生的機率。希望本文提供的動態化執行緒池思路能對大家有幫助。

四、參考資料

[1]JDK 1.8 原始碼

[2] 維基百科-執行緒池

[3] 更好的使用Java執行緒池

[4] 維基百科Pooling(Resource Management)

[5] 深入理解Java執行緒池:ThreadPoolExecutor

[6]《Java併發程式設計實踐》


五、作者簡介

致遠,2018年加入美團點評,美團到店綜合研發中心後臺開發工程師。
陸晨,2015年加入美團點評,美團到店綜合研發中心後臺技術專家。


來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/31559353/viewspace-2686086/,如需轉載,請註明出處,否則將追究法律責任。

相關文章