Java執行緒池的瞭解使用—築基篇

DMingO 發表於 2020-08-01

前言

Java中的執行緒池是一個很重要的概念,它的應用場景十分廣泛,可以被廣泛的用於高併發的處理場景。J.U.C提供的執行緒池:ThreadPoolExecutor類,可以幫助我們管理執行緒並方便地並行執行任務。因此瞭解併合理使用執行緒池非常重要。

本文對執行緒池採用 3W 的策略結合原始碼進行思考逐層分析,即是什麼為什麼怎麼做。

什麼是執行緒池

執行緒池的本質是對任務和執行緒的管理,做到了將任務執行緒兩者解耦。執行緒池對任務的管理可看作生產者消費者的關係,通過阻塞佇列的存與取。阻塞佇列快取待執行的任務,工作執行緒從阻塞佇列中獲取任務。執行緒池對執行緒的管理,是結合執行緒池狀態,已有執行緒的狀態,核心執行緒數和最大執行緒數、阻塞佇列狀態做出增加、執行任務、回收、複用等操作,體現了享元模式和池化思想。

享元模式:

主要目的是實現物件的共享,運用共享技術有效地支援大量細粒度的物件,避免大量相類似的開銷。當系統中物件多的時候可以減少記憶體的開銷,通常與搭配工廠模式使用。

池化思想:

在多種使用物件的策略上,主張讓使用的代價最小化。在重新建立物件的代價 遠大於更換狀態,複用物件的代價的前提下,將可以複用的物件放入池中待複用,以此降低使用的代價。

為什麼要用執行緒池

執行緒池的優點,也是它為什麼被流行使用的原因:

  • 重用執行緒池中的執行緒,避免因為執行緒的建立和銷燬帶來效能開銷。
  • 能有效控制執行緒池的最大併發數,能提供定時執行以及定間隔迴圈執行等功能。
  • 執行緒池還提供了一種方法來約束和管理執行一組任務時消耗的資源(包括執行緒),避免大量的執行緒之間因互相搶佔系統資源而導致的阻塞現象。
  • 可維護一些基本統計資訊,比如已完成任務的數量。

主要的缺點:

  • 執行緒池的引數不存在完美的配置,高度依賴於開發者的經驗,使用不當容易造成線上的危機
  • 執行緒池執行的情況和任務型別相關性較大,IO密集型和CPU密集型的任務執行起來的情況差異非常大,業界並沒有一些成熟的經驗策略幫助開發人員參考。

怎麼用執行緒池

先了解執行緒池的相關重要概念:

Core and maximum pool sizes

核心執行緒數以及最大執行緒數,這是構造一個執行緒池所必需的引數。

不同的搭配會有不同效果的執行緒池,也是執行緒池判斷在執行任務前是否需建立新執行緒的重要依據。

ThreadFactory

執行緒工廠,這是構造一個執行緒池的引數。

提供執行緒的建立,如果構建執行緒池不指定ThreadFactory,則使用預設執行緒工廠,建立的執行緒預設進入同一個ThreadGroup和預設執行緒優先順序。

Keep-alive times

存活時間,這是構造一個執行緒池的引數。

如果執行緒池當前有超過corePoolSize大小的執行緒,如果非核心執行緒的空閒時間超過了keepAliveTime,則被視為可回收的多餘執行緒,被終止

Queuing

任務/阻塞佇列,這是構造一個執行緒池的引數。

不同型別的阻塞佇列可以構造出適合不同場景的執行緒池。最常見的四種執行緒池就有著不同型別的阻塞佇列。

作為任務的緩衝停留區,執行緒池管理執行緒的機制核心之一。

生產者消費者模式的體現,生產者是往佇列裡新增元素的執行緒,消費者是從佇列裡拿元素的執行緒。阻塞佇列就是生產者存放元素的容器,而消費者也只從容器裡拿元素。

  • 在佇列為空時,獲取元素的執行緒會等待佇列變為非空再嘗試獲取
  • 當佇列滿時,儲存元素的執行緒會等待佇列可用再嘗試儲存

img

Rejected tasks

拒絕任務後的策略,這是構造一個執行緒池的引數

加入任務時,根據執行緒池當前狀態是否停止銷燬、執行緒數是否以及飽和,判斷是否拒絕本次任務的加入。若拒絕任務就會執行拒絕任務後的策略。預設的拒絕後的策略是丟擲執行期異常RejectedExecutionException

On-demand construction

需求到達才建立,預設情況下,即使是核心執行緒最初也只有在新任務到達時才建立和啟動,但是可以使用prestartCoreThread。如果使用非空佇列構造池,可能需要預啟動執行緒。

Hook methods

鉤子方法

可重寫的方法,beforeExecute(Runnable)afterExecute(Runnable,Throwable)terminated ,在執行每個任務之前和之後,執行緒池被完全終止後會被回撥。可以用來執行特殊任務:重新初始化ThreadLocal變數、收集統計資訊或新增日誌條目。

最常見最常用的執行緒池

Executors類提供的也是最常見的執行緒池種類,配置,以及它們維護的阻塞佇列型別,使用場景如下:

型別 核心執行緒數 最大執行緒數 阻塞佇列 說明/使用場景
FixedThreadPool 構造時傳入 與核心執行緒數相同 LinkedBlockingQueue 執行緒數量固定,只有核心執行緒並且不會被回收,沒有超時機制
CachedThreadPool 0 Integer.MAX_VALUE SynchronousQueue 執行緒數量不固定的執行緒池,只有非核心的執行緒,當執行緒都處於活動狀態時,直接建立新執行緒來處理新任務,否則就利用空閒的執行緒。處於空閒狀態超過60s的執行緒被回收
ScheduledThreadPool 構造時傳入 Integer.MAX_VALUE DelayedWorkQueue 非核心執行緒在閒置時立刻回收,主要用於執行定時任務和固定週期的重複任務
SingleThreadExecutor 1 1 LinkedBlockingQueue 只有一個核心執行緒,確保所有任務在同一執行緒中按順序執行

分析建立這四個執行緒池的方法的原始碼,最後都來到了ThreadPoolExecutor類的ThreadPoolExecutor構造方法,由此可見ThreadPoolExecutor才是真正的執行緒池。Executors作為執行緒池工廠,提供的四種執行緒池是利用不同引數建立的適應不同使用場景的執行緒池。

//ThreadPoolExecutor.java

/**
    * @param corePoolSize 核心執行緒數
    * @param maximumPoolSize 最大執行緒數
    * @param keepAliveTime 非核心執行緒閒置的超時時長
    * @param unit 用於指定 keepAliveTime 引數的時間單位
    * @param 任務佇列,通過執行緒池的 execute 方法提交的 Runnable 物件會儲存在這個引數中
    * @param threadFactory 執行緒工廠,用於提供新執行緒
    * @param handler 任務佇列已滿或者是無法成功執行任務時呼叫
    */
public ThreadPoolExecutor(int corePoolSize,
                            int maximumPoolSize,
                            long keepAliveTime,
                            TimeUnit unit,
                            BlockingQueue<Runnable> workQueue,
                            ThreadFactory threadFactory,
                            RejectedExecutionHandler handler) {
    //···
}

執行緒池的簡單使用

以手動建立一個核心數為5,最大執行緒數為7,空閒超時為20s,阻塞佇列為陣列實現的有界佇列的ThreadPoolExecutor為例子:

        ExecutorService executor = new ThreadPoolExecutor(
                5, 7, 20L, TimeUnit.SECONDS, 
                new ArrayBlockingQueue<Runnable>(8)
        );
        for(int i = 0; i < 9; i++){
            final int index = i;
            executor.execute(new Runnable() {
                @Override
                public void run() {
                    try {
                        Thread.sleep(1500);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    System.out.println(String.valueOf(index)+ " " +Thread.currentThread().getName());
                }
            });
        }

手動建立執行緒池的好處

阿里Java開發手冊執行緒池使用建議

阿里巴巴Java開發手冊中使用強制標註:需通過手動建立 ThreadPoolExecutor 取代使用 Executors 提供的工廠方法。資料量併發量很大或難以把握時,應避免直接使用 Executors 提供的執行緒池,防止資源被耗盡

以CachedThreadPool為例子,CachedThreadPool將空閒執行緒銷燬前的等待時間設定成了60s,同時阻塞佇列型別是SynchronousQueue,不儲存元素的佇列。 CachedThreadPool 在一定程度上能夠應對不斷突增的併發任務,但是一旦任務量遠遠大於處理量,會造成執行緒數量的激增和資源的消耗,容易引發OOM。

手動建立執行緒池可以更好規範該執行緒池的職責,更好地管理這個執行緒池,讓執行緒池在合適的場景下,可以用來處理適當的任務,而不是一顆隨時會被引爆的炸彈。

總結

執行緒池,基於池化思想,體現了享元模式,可以用來管理執行緒並方便地並行執行任務的工具。本質上是對任務和執行緒解耦後進行管理,利用不同的構造引數可以構造出適合不同場景的執行緒池。優點是降低資源消耗提高響應速度提高執行緒的可管理性可擴充性良好。缺點是引數不易配置,出錯後易造成OOM。

篇幅問題,對執行緒池的設計和管理機制的分析安排在下一篇文章~

參考資料