Java併發程式設計學習筆記----執行緒池

Mr_Gin發表於2020-04-05

前言

記錄一下有關Java執行緒池的學習筆記,主要內容來自於《Java併發程式設計的藝術》,增加了一些自己的理解和實際問題中的處理。

1. 執行緒池概念和優點

1.1 為什麼需要執行緒池

執行緒同樣是一個物件,物件的建立和銷燬都需要消耗系統資源(類載入、垃圾回收)。頻繁地建立執行緒會消耗系統資源,降低系統穩定性。 使用執行緒池可以對執行緒進行統一分配、調優和監控。

1.2 優點

  • 降低資源消耗。通過重複利用已經建立的執行緒,來節約執行緒建立和銷燬的資源消耗。
  • 提高相應速度。任務提交時,可以使用執行緒池中執行緒執行任務,減少了等待執行緒建立的時間。
  • 使任務趨於平緩。當有大量任務到來時,通過執行緒池的排程策略,先將任務放入佇列中,控制同時執行的執行緒數,避免對系統造成巨大壓力。
  • 監控和調優等。

2. 執行緒池排程流程

執行緒池的排程流程主要涉及三個概念:核心執行緒池任務佇列最大執行緒池,這三個概念分別對應執行緒池構造引數中的corePoolSizeworkQueuemaximumPoolSize. 結合下圖,說明提交一個新任務到執行緒池時,執行緒池的處理流程:

(1)第一階段(預熱階段):核心執行緒池

判斷執行緒池中執行緒數目是否達到核心執行緒大小,未達到則建立新執行緒執行任務;達到,則轉(2);
說明:

  • 經過測試,只看執行緒數目,不管執行緒是否活躍(不活躍執行緒達到最大存活時間才會被銷燬)。
  • 經過測試,如果核心執行緒池大小為0,等同於核心執行緒池大小為1的情況,即任務提交時會新建立一個執行緒,然後再放入佇列。

(2)第二階段:工作佇列

判斷佇列是否已滿,未滿則將任務放入佇列;滿,則轉(3);

  • 這也就要求佇列書儘量避免使用無界佇列,有造成記憶體洩露的風險。

(3)第三階段:最大執行緒池

判斷執行緒池中執行緒數目是否達到最大執行緒池數目,未達到建立新執行緒來處理任務;達到,則採取對應的任務拒絕策略

  • 拒絕的意思是拒絕將任務加入到執行緒池,並不是說任務一定被拒絕。

Java併發程式設計學習筆記----執行緒池

3. 執行緒池使用

3.1 執行緒池建立

Jdk中定義了任務執行介面ExecutorService,並提供了實現類ThreadPoolExecutor。建立示例:

BlockingQueue<Runnable> queue = new ArrayBlockingQueue<>(1);
ExecutorService executorService = new ThreadPoolExecutor(0, 1, 60, TimeUnit.SECONDS, 
        queue, new ThreadFactoryBuilder().build(), new ThreadPoolExecutor.CallerRunsPolicy());
複製程式碼

3.2 任務提交

execute用於提交無返回值的任務,submit用於提交有返回值的任務(與Callable、Future結合)。 示例:

ExecutorService executorService = Executors.newFixedThreadPool(3);
executorService.execute(() -> {
    System.out.println("HelloWorld1");
});

Future<String> future = executorService.submit(() -> {
    return "HelloWorld2";
});
try {
    System.out.println(future.get());
} catch (InterruptedException | ExecutionException ex) {
    ex.printStackTrace();
}
複製程式碼

3.3 執行緒池關閉

可以使用shutdownshutdownNow來關閉執行緒池。
實際測試:

  • shutdown不會終止活躍執行緒;
  • 而shutdownNow會給活躍執行緒傳送Interrupt訊號,需要執行緒捕獲InterruptedException。

3.4 執行緒池監控

ThreadPoolExecutor還提供了多種API對執行緒池的狀態進行監控,需要注意的是這些API都需要加鎖。常見API有:

  • getActiveCount:獲取活躍執行緒數
  • getPoolSize:獲取執行緒池中執行緒數目
  • getLargestPoolSize: 執行緒池中執行緒數最高紀錄
  • getTaskCount:獲取執行緒池中提交過任務總數
  • getCompletedTaskCount:已完成任務數

4. 執行緒池核心引數詳解

通過ThreadPoolExecutor類可以建立一個執行緒池,作為ExecutorService介面的實現類。

4.1 引數列表

ThreadPoolExecutor類提供了多重構造器方法,其中引數最齊全的構造器如下,引數包括corePoolSize(核心執行緒池大小),maximumPoolSize(執行緒池最大執行緒數),keepAliveTime(空閒執行緒存活時間),unit(空閒執行緒池存活時間單位), workQueue(執行緒池任務佇列),threadFactory(執行緒建立工廠),handler(任務拒絕策略)

public ThreadPoolExecutor(int corePoolSize,
                              int maximumPoolSize,
                              long keepAliveTime,
                              TimeUnit unit,
                              BlockingQueue<Runnable> workQueue,
                              ThreadFactory threadFactory,
                              RejectedExecutionHandler handler)
複製程式碼

4.2 corePoolSize(核心執行緒池數目)

執行緒池的第一階段可以理解為預熱階段,就是要先將核心執行緒池裝滿(不論執行緒池中是否有空閒執行緒,且核心執行緒數目為0時等價於為1)。
如果執行prestartAllCoreThreads()方法,會直接建立並啟動所有核心執行緒。

((ThreadPoolExecutor) executorService).prestartAllCoreThreads();
複製程式碼

理解

核心執行緒數目不宜太小,比如核心執行緒池為1,那麼假若執行緒執行時裡有休眠或者寫redis超時等,這會使得後續所有任務都處於等待狀態(工作佇列較大的情況下),可以考慮將核心執行緒池數目設為CPU核數,這樣保證預熱階段就可以每個核執行一個執行緒。(任務對映到執行緒,執行緒對映到CPU)

4.3 workQueue(工作佇列)

工作佇列是用於存放等待執行任務的阻塞佇列,可以根據實際場景來決策選擇什麼樣的佇列。
阻塞佇列是指,當佇列滿時往佇列中放元素的執行緒會阻塞;當佇列空時從佇列中取元素的執行緒會阻塞。主要有如下集中可選的佇列:

  • ArrayBlockingQueue:有界阻塞佇列,先進先出
  • LinkedBlockingQueue:阻塞佇列,可以設定最大容量進而變成有界,先進先出
  • SynchronousQueue:詳見5.2.2
  • PriorityBlockingQueue

理解

工作佇列應儘量使用有界佇列,無界佇列一是容易導致記憶體洩露,二是最大執行緒池引數無效。

4.4 maximumPoolSize(最大執行緒數)

執行緒池最大執行緒數,只有當工作佇列有界且容量有限的情況下,該引數才有意義。如果佇列很大,會導致任務一直被放入佇列中,而不會建立新的執行緒去執行。

理解

首先最大執行緒池數目只有在工作佇列有界時才有效,另外最大執行緒池數目設定的意義在於充分利用CPU資源、避免任務等待時間過長。

4.5 threadFactory(執行緒工廠)

用於指定建立執行緒的工廠,比如可以使用可以google guaua包中提供的ThreadFactoryBuilder來快速的給執行緒池中執行緒設定自定義的執行緒名,一般使用預設的即可。

new ThreadFactoryBuilder().build()
複製程式碼

4.6 RejectedExecutionHandler(拒絕策略)

當執行緒池經過了第三階段,即執行緒數目已經達到最大執行緒數目,那樣的話任務將被拒絕新增到執行緒池中,根據拒絕策略執行任務。
常見拒絕策略有:

  • AbortPolicy: 直接丟擲異常
  • CallerRunsPolicy: 由提交任務的執行緒直接執行任務(執行的位置相當於將程式碼插入在提交任務的位置)
  • DiscardOldestPolicy:丟棄佇列中最早加入的任務,並將當前任務新增進去。
  • DiscardPolicy:直接丟棄

理解

個人覺得如果不是特別佔CPU資源的任務,使用CallerRunsPolicy策略比較合適,能保證任務被執行。

4.7 keepAliveTime和timeUnit(存活時間)

當執行緒池中執行緒處於空閒狀態超過一定時間時會銷燬該執行緒,一般常見設定是60s存活時間。

5. 執行緒工廠

工廠類Executors提供了多個用於建立典型執行緒池的API,接下來詳細介紹幾種典型執行緒池。

5.1 FixedThreadPool(固定數目執行緒池)

5.1.1 原始碼

Executors提供了兩種FixedThreadPool建立API,分別是

public static ExecutorService newFixedThreadPool(int nThreads);
public static ExecutorService newFixedThreadPool(int nThreads, ThreadFactory threadFactory) ;
複製程式碼

第一種API原始碼如下

public static ExecutorService newFixedThreadPool(int nThreads) {
    return new ThreadPoolExecutor(nThreads, nThreads,
                                  0L, TimeUnit.MILLISECONDS,
                                  new LinkedBlockingQueue<Runnable>());
}
複製程式碼

5.1.2 特點

觀察原始碼可以分析,FixedThreadPool有如下特點:

  • (1)核心執行緒池數目和最大執行緒數目相等,也就是說永遠不會進入執行緒池排程的第三階段
  • (2)執行緒存活時間為0,空閒執行緒會被立即銷燬
  • (3)無界阻塞佇列,即任務永遠不會被拒絕,佇列存在OOM異常的可能
  • (4)當執行緒數目大於等於CPU核數,且任務到來速率大於任務處理速率時(CPU密集型任務),可能會導致CPU被打滿。

5.1.3 適用場景

FixedThreadPool最大的特點是限制了執行緒池最大執行緒數目,比較適用於任務對系統資源消耗較大、負載比較重的伺服器。

5.2 CachedThreadPool

5.2.1 原始碼

Executors工廠類提供了兩種CachedThreadPool的建立API:

public static ExecutorService newCachedThreadPool();
public static ExecutorService newCachedThreadPool(ThreadFactory threadFactory);
複製程式碼

第一種API的原始碼如下:

public static ExecutorService newCachedThreadPool() {
    return new ThreadPoolExecutor(0, Integer.MAX_VALUE,
                                  60L, TimeUnit.SECONDS,
                                  new SynchronousQueue<Runnable>());
}
複製程式碼

5.2.2 SynchronousQueue(同步佇列)

SynchronousQueue被稱為無緩衝阻塞佇列,用於在兩個執行緒間移交元素。

經過測試SynchronousQueue有如下特點:

  • (1)put和take必須在不同的執行緒中,同一個執行緒無法先後offer、take。可以理解為被offer元素必須被take後put方法才能退出,所以無法在同一個執行緒中先後offer、take。
  • (2)在任何時候判斷佇列都是滿的,remainingCapacity()方法總是返回0。比如直接使用add方法向佇列新增元素,會丟擲java.lang.IllegalStateException: Queue full。 猜測正是因為這點使得使用SynchronousQueue作工作佇列的執行緒池沒有第二階段,直接進入第三階段

5.2.3 特點

觀察原始碼可以分析得到,CachedThreadPool具有以下特點:

  • (1)工作佇列使用SynchronousQueue,判斷工作佇列總是滿的,也就說的當核心執行緒池滿後會不停地建立執行緒直至達到最大執行緒數目,而跳過第二階段
  • (2)核心執行緒池數為0(等價於1),最大執行緒數目是最大整數,也就說任務提交時會優先使用非活躍執行緒(因為核心執行緒池已滿,且非活躍執行緒會有60s存活時間,不會直接新建執行緒來執行任務),沒有活躍執行緒則會新建執行緒。當任務到來速率大於任務處理速率時,有建立過多執行緒打滿CPU的可能

5.2.4 適用場景

根據CachedThreadPool特點可以分析出,其適用於處理大量短期任務、或者負載較輕的伺服器。(主要是不需要限制執行緒數目的場景)

5.3 SingleThreadExecutor(單執行緒池)

5.3.1 原始碼

(1)SingleThreadExecutor原始碼如下
public static ExecutorService newSingleThreadExecutor() {
    return new FinalizableDelegatedExecutorService
        (new ThreadPoolExecutor(1, 1,
                                0L, TimeUnit.MILLISECONDS,
                                new LinkedBlockingQueue<Runnable>()));
}
複製程式碼

可以看出在ThreadPoolExecutor又包裝了一層FinalizableDelegatedExecutorService。

(2)FinalizableDelegatedExecutorService原始碼如下:
static class FinalizableDelegatedExecutorService extends DelegatedExecutorService {
    FinalizableDelegatedExecutorService(ExecutorService executor) {
        super(executor);
    }
    protected void finalize() {
        super.shutdown();
    }
}
複製程式碼

FinalizableDelegatedExecutorService又如下特點:

  • 繼承了DelegatedExecutorService(代理執行緒池)
  • 重寫了finalize方法,在物件被回收時主動關閉執行緒池
(3)DelegatedExecutorService原始碼如下:
static class DelegatedExecutorService extends AbstractExecutorService {
    private final ExecutorService e;
    DelegatedExecutorService(ExecutorService executor) { e = executor; }
    public void execute(Runnable command) { e.execute(command); }
    public void shutdown() { e.shutdown(); }
    public List<Runnable> shutdownNow() { return e.shutdownNow(); }
    public boolean isShutdown() { return e.isShutdown(); }
    public boolean isTerminated() { return e.isTerminated(); }
    public boolean awaitTermination(long timeout, TimeUnit unit)
        throws InterruptedException {
        return e.awaitTermination(timeout, unit);
    }
    public Future<?> submit(Runnable task) {
        return e.submit(task);
    }
    public <T> Future<T> submit(Callable<T> task) {
        return e.submit(task);
    }
    public <T> Future<T> submit(Runnable task, T result) {
        return e.submit(task, result);
    }
    public <T> List<Future<T>> invokeAll(Collection<? extends Callable<T>> tasks)
        throws InterruptedException {
        return e.invokeAll(tasks);
    }
    public <T> List<Future<T>> invokeAll(Collection<? extends Callable<T>> tasks,
                                         long timeout, TimeUnit unit)
        throws InterruptedException {
        return e.invokeAll(tasks, timeout, unit);
    }
    public <T> T invokeAny(Collection<? extends Callable<T>> tasks)
        throws InterruptedException, ExecutionException {
        return e.invokeAny(tasks);
    }
    public <T> T invokeAny(Collection<? extends Callable<T>> tasks,
                           long timeout, TimeUnit unit)
        throws InterruptedException, ExecutionException, TimeoutException {
        return e.invokeAny(tasks, timeout, unit);
    }
}
複製程式碼

可以看出DelegatedExecutorService:

  • 構造器需要傳入一個執行緒池物件,並且內部方法都對映成對傳入執行緒池物件方法的執行,相當於一個包裝類、代理
  • 只可以執行包裝類提供的方法,也就是說被DelegatedExecutorService包裝的執行緒池不論其具體型別如何,都不能執行其特殊方法,只能執行包裝類提供的ExecutorService方法

5.3.2 SingleThreadExecutor特點

結合FinalizableDelegatedExecutorService和內部ThreadPoolExecutor可以得到SingleThreadExecutor具備以下特點:

  • 執行緒池的核心執行緒池數目和最大執行緒數目固定,且都為1。也就說任意時刻只會一個任務被執行
  • 空閒執行緒存活時間為0,也就是說空閒執行緒會被即時銷燬;
  • 執行緒池被GC回收時會主動關閉執行緒池中執行緒

5.3.3 適用場景

SingleThreadExecutor適用於需要順序執行任務的場景,但對應地併發量會降低、QPS也會降低。

6. 合理使用執行緒池

6.1 核心執行緒池大小和最大執行緒數目配置

配置執行緒池大小主要需要考慮任務型別任務執行時間機器負載

(1)任務型別

根據任務消耗資源可以將任務分為CPU密集型任務IO密集型任務混合型任務
常見的程式碼處理(計算、邏輯判斷等)任務都屬於CPU密集型任務,而檔案讀寫(列印日誌)、網路佔用等屬於IO密集型任務。

  • CPU密集型任務。對於CPU密集型任務,建議降低最大執行緒數、使之小於CPU核數,執行緒過多一是頻繁線上程間切換上下文會帶來額外開銷,而是容易使CPU滿負荷執行。
  • IO密集型任務。對於IO密集型任務,建議提高執行緒數(比如2*CPU核數),因為活躍執行緒往往因為正在讀寫IO而沒有持有CPU,此時可充分利用CPU處理其它執行緒。
  • 混合型任務。根據任務佔用CPU和IO時間,看能否將任務分解,當任務佔用CPU和IO時間相差不大時,分解併發度提升較大。

(2)任務執行時間

一般情況下,一類任務使用一個執行緒池,這樣可以使任務間在獲取CPU時更加公平,避免較短任務等待時間過長。
需要注意的是,影響任務執行時間除了任務本身外,往往還需特別關注休眠操作任務依賴性

  • 長任務。任務執行時間較長時,建議一定程度增大最大執行緒數目,來增加併發能力(執行緒數小於CPU核數情況下),同時可以減少後面任務的等待時間(但是會由於CPU線上程間切換而增加一定額外開銷)
  • 短任務。任務執行時間較短時,建議降低執行緒數。因為執行較快,少量執行緒就可以勝任任務,並且當任務突增時,由於執行緒數較少可以避免CPU被打滿
  • 任務依賴性。任務中如果有訪問redis、資料庫等操作,需要額外注意,對其他模組或元件的依賴將可能導致任務執行時間突增。(1)首先避免核心執行緒池設為0或1,假如核心執行緒只有1個,一個任務超時,將導致後續所有任務都延遲執行;(2)根據實際情況,可適度增大最大執行緒池
  • 休眠操作。任務內如果在某些情況下需要進行休眠的話(非常不建議使用休眠,如必要,建議間歇性休眠並設定最大休眠時間),也會導致任務執行時間增加。與上一點相同,注意核心執行緒池大小。

(3)機器負載

在負載較重的機器,通過限制執行緒池數目來降低機器壓力。影響機器負載的因素一般是機器配置,如果機器有多個服務混布,也會導致機器壓力較大。

  • 機器配置。當機器配置較低時,可以降低執行緒池數目來減輕伺服器壓力。
  • 服務混布。一般情況下避免服務混布,在混布時要考慮多個服務同時任務壓力情況,降低執行緒數目。
  • 執行緒池的種類和數目。不僅僅服務混佈會影響機器壓力,同一個服務如果存在多重型別任務、多個執行緒池,在這些執行緒池都滿負荷運轉,也會機器整體壓力劇增,也可能存在某一種任務突增,影響其它型別任務執行的情況。

6.2 其它配置

  • 儘量使用自定義的執行緒池,這樣可以自定義策略。
  • 工作佇列建議使用有界佇列(避免OOM、使最大執行緒數目生效),佇列數可以儘可能大些,防止佇列一下子被填滿;
  • 拒絕策略建議使用CallerRunsPolicy保證任務被執行;

6.3 實際問題舉例

(1)執行緒池數目過小 + 訪問redis超時,導致所有任務延遲處理

問題:當時由於對執行緒池引數理解不夠,將核心執行緒池大小設為了0,同時任務出現了訪問redis超時的現象,這使得在請求數並不多的情況下就出現了大量任務執行延遲的現象。
解決:將核心執行緒池和最大執行緒池數目都修改成了1/2 CPU核數;並且修改redis連線池配置,一定程度降低連線redis的retryInterval和timeout時間。

(2)短任務 + 請求突增,導致CPU被打滿

問題:因為客戶端錯誤使用,使得qps突增(幾毫秒一次、甚至一毫米數次請求),並持續了近10分鐘。10分鐘內伺服器CPU被打滿,服務呈現一定程度不可用,存在大面積掉線現象。

分析:執行緒池在理論上是可以對請求進行削峰的,但仍然造成了CPU打滿的現象。分析原因有:

  • i. 突增的請求是一種短任務,認為是一種CPU密集型任務;
  • ii. 處理突增請求的執行緒池最大執行緒數達到CPU核數(N),這使得執行緒池在10分鐘內一直有N個活躍執行緒。
  • iii. 機器同時有混布服務、服務中還有多種不同型別執行緒池。 綜合以上這些因素,使得客戶端請求突增時,每一個CPU在絕大部分時間處理突增的異常請求,導致CPU被打滿,合理請求無法正常處理或者延時處理。

解決辦法

  • 伺服器對處理客戶端請求進行限流,丟棄一部分次要訊息、限制合理訊息的速率。
  • 降低每種執行緒池的最大執行緒數,出於有混布服務,將當前服務上所有執行緒池最大執行緒數目控制到2/3 CPU核數。

相關文章