從0到1玩轉執行緒池

兜裡有辣條發表於2019-03-24

我們一般不會選擇直接使用執行緒類Thread進行多執行緒程式設計,而是使用更方便的執行緒池來進行任務的排程和管理。執行緒池就像共享單車,我們只要在我們有需要的時候去獲取就可以了。甚至可以說執行緒池更棒,我們只需要把任務提交給它,它就會在合適的時候執行了。但是如果直接使用Thread類,我們就需要在每次執行任務時自己建立、執行、等待執行緒了,而且很難對執行緒進行整體的管理,這可不是一件輕鬆的事情。既然我們已經有了執行緒池,那還是把這些麻煩事交給執行緒池來處理吧。

之前一篇介紹執行緒池使用及其原始碼的文章篇幅太長了、跨度太大了一些,感覺不是很好理解。所以我把內容重新組織了一下,拆為了兩篇文章,並且補充了一些內容,希望能讓大家更容易地理解相關內容。

這篇文章將從執行緒池的概念與一般使用入手,首先介紹執行緒池的一般使用。然後詳細介紹執行緒池中常用的可配置項,例如任務佇列、拒絕策略等,最後會介紹四種常用的執行緒池配置。通過這篇文章,大家可以熟練掌握執行緒池的使用方式,在實踐中游刃有餘地使用執行緒池對執行緒進行靈活的排程。

閱讀本文需要對多執行緒程式設計有基本的認識,例如什麼是執行緒、多執行緒解決的是什麼問題等。不瞭解的讀者可以參考一下我之前釋出的一篇文章《這一次,讓我們完全掌握Java多執行緒(2/10)》

一般我們最常用的執行緒池實現類是ThreadPoolExecutor,我們接下來會介紹這個類的基本使用方法。JDK已經對執行緒池做了比較好的封裝,相信這個過程會非常輕鬆。

執行緒池的基本使用

建立執行緒池

既然執行緒池是一個Java類,那麼最直接的使用方法一定是new一個ThreadPoolExecutor類的物件,例如ThreadPoolExecutor threadPool = new ThreadPoolExecutor(1, 1, 0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueue<Runnable>() )。那麼這個構造器的裡每個引數是什麼意思呢?我們可以暫時不用關心這些細節,繼續完成執行緒池的使用之旅,稍後再回頭來研究這個問題。

提交任務

當建立了一個執行緒池之後我們就可以將任務提交到執行緒池中執行了。提交任務到執行緒池中相當簡單,我們只要把原來傳入Thread類構造器的Runnable物件傳入執行緒池的execute方法或者submit方法就可以了。execute方法和submit方法基本沒有區別,兩者的區別只是submit方法會返回一個Future物件,用於檢查非同步任務的執行情況和獲取執行結果(非同步任務完成後)。

我們可以先試試如何使用比較簡單的execute方法,程式碼例子如下:

public class ThreadPoolTest {

    private static int count = 0;

    public static void main(String[] args) throws Exception {
        Runnable task = new Runnable() {
            public void run() {
                for (int i = 0; i < 1000000; ++i) {
                    synchronized (ThreadPoolTest.class) {
                        count += 1;
                    }
                }
            }
        };

        // 重要:建立執行緒池
        ThreadPoolExecutor threadPool = new ThreadPoolExecutor(1, 1, 0L,
        TimeUnit.MILLISECONDS, new LinkedBlockingQueue<Runnable>());

        // 重要:向執行緒池提交兩個任務
        threadPool.execute(task);
        threadPool.execute(task);

        // 等待執行緒池中的所有任務完成
        threadPool.shutdown();
        while (!threadPool.awaitTermination(1L, TimeUnit.MINUTES)) {
            System.out.println("Not yet. Still waiting for termination");
        }
        
        System.out.println("count = " + count);
    }
}
複製程式碼

執行之後得到的結果是兩百萬,我們成功實現了第一個使用執行緒池的程式。那麼回到剛才的問題,建立執行緒池時傳入的那些引數有什麼作用的呢?

深入解析執行緒池

建立執行緒池的引數

下面是ThreadPoolExecutor的構造器定義:

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

各個引數分別表示下面的含義:

  1. corePoolSize,核心執行緒池大小,一般執行緒池會至少保持這麼多的執行緒數量;
  2. maximumPoolSize,最大執行緒池大小,也就是執行緒池最大的執行緒數量;
  3. keepAliveTime和unit共同組成了一個超時時間,keepAliveTime是時間數量,unit是時間單位,單位加數量組成了最終的超時時間。這個超時時間表示如果執行緒池中包含了超過corePoolSize數量的執行緒,則在有執行緒空閒的時間超過了超時時間時該執行緒就會被銷燬;
  4. workQueue是任務的阻塞佇列,在沒有執行緒池中沒有足夠的執行緒可用的情況下會將任務先放入到這個阻塞佇列中等待執行。這裡傳入的佇列型別就決定了執行緒池在處理這些任務時的策略,具體型別會在下文中介紹;
  5. threadFactory,執行緒的工廠物件,執行緒池通過該物件建立執行緒。我們可以通過傳入自定義的實現了ThreadFactory介面的類來修改執行緒的建立邏輯,可以不傳,預設使用Executors.defaultThreadFactory()作為預設的執行緒工廠;
  6. handler,拒絕策略,線上程池無法執行或儲存新提交的任務時進行處理的物件,常用的有以下幾種策略類:
    • ThreadPoolExecutor.AbortPolicy,預設策略,行為是直接丟擲RejectedExecutionException異常
    • ThreadPoolExecutor.CallerRunsPolicy,用呼叫者所在的執行緒來執行任務
    • ThreadPoolExecutor.DiscardOldestPolicy,丟棄阻塞佇列中最早提交的任務,並重試execute方法
    • ThreadPoolExecutor.DiscardPolicy,靜默地直接丟棄任務,不返回任何錯誤

看到這裡可能大部分讀者並不能理解每個引數具體的作用,接下來我們就通過執行緒池原始碼中使用了這些引數配置的程式碼來深入理解每一個引數的意義。

execute方法的實現

我們一般會使用execute方法提交我們的任務,那麼執行緒池在這個過程中做了什麼呢?在ThreadPoolExecutor類的execute()方法的原始碼中,我們主要做了四件事:

  1. 如果當前執行緒池中的執行緒數小於核心執行緒數corePoolSize,則通過threadFactory建立一個新的執行緒,並把入參中的任務作為第一個任務傳入該執行緒;
  2. 如果當前執行緒池中的執行緒數已經達到了核心執行緒數corePoolSize,那麼就會通過阻塞佇列workerQueueoffer方法來將任務新增到佇列中儲存,並等待執行緒空閒後進行執行;
  3. 如果執行緒數已經達到了corePoolSize且阻塞佇列中無法插入該任務(比如已滿),那麼執行緒池就會再增加一個執行緒來執行該任務,除非執行緒數已經達到了最大執行緒數maximumPoolSize
  4. 如果確實已經達到了最大執行緒數,那麼就會通過拒絕策略物件handler拒絕這個任務。

總體上的執行流程如下,左側的實心黑點代表流程開始,下方的黑色同心圓代表流程結束:

從0到1玩轉執行緒池

上面提到了執行緒池構造器引數中除了超時時間之外的所有引數的作用,相信大家根據上面的流程已經可以理解每個引數的意義了。但是有一個名詞我們還一直沒有深入講解,那就是阻塞佇列的含義。

執行緒池中的阻塞佇列

執行緒池中的阻塞佇列專門用於存放需要等待執行緒空閒的待執行任務,而阻塞佇列是這樣的一種資料結構,它是一個佇列(類似於一個List),可以存放0到N個元素。我們可以對這個佇列進行插入和彈出元素的操作,彈出操作可以理解為是一個獲取並從佇列中刪除一個元素的操作。當佇列中沒有元素時,對這個佇列的獲取操作將會被阻塞,直到有元素被插入時才會被喚醒;當佇列已滿時,對這個佇列的插入操作將會被阻塞,直到有元素被彈出後才會被喚醒。

這樣的一種資料結構非常適合於執行緒池的場景,當一個工作執行緒沒有任務可處理時就會進入阻塞狀態,直到有新任務提交後才被喚醒。

線上程池中,不同的阻塞佇列型別會被執行緒池的行為產生不同的影響,下面是三種我們最常用的阻塞佇列型別:

  1. 直連佇列,以SynchronousQueue類為代表,佇列不會儲存任何任務。當有任務提交執行緒試圖向佇列中新增待執行任務時會被阻塞,直到有任務處理執行緒試圖從佇列中獲取待執行任務時會與阻塞狀態中的任務提交執行緒發生直接聯絡,由任務提交執行緒把任務直接交給任務執行執行緒;
  2. 無界佇列,以LinkedBlockingQueue類為代表,佇列中可以儲存無限數量的任務。這種佇列永遠不會因為佇列已滿導致任務放入佇列失敗,所以結合前面介紹的流程我們可以發現,當使用無界佇列時,執行緒池中的執行緒最多隻能達到核心執行緒數就不會再增長了,最大執行緒數maximumPoolSize引數不會產生作用;
  3. 有界佇列,以ArrayBlockingQueue類為代表,可以儲存固定數量的任務。這種佇列在實踐中比較常用,因為它既不會因為儲存太多工導致資源消耗過多(無界佇列),又不會因為任務提交執行緒被阻塞而影響到系統的效能(直連佇列)。總體上來說,有界佇列在實際效果上比較均衡。

閱讀execute方法的原始碼

在IDE中,例如IDEA裡,我們可以點選我們樣例程式碼裡的ThreadPoolExecutor類跳轉到JDK中ThreadPoolExecutor類的原始碼。在原始碼中我們可以看到很多java.util.concurrent包的締造者大牛“Doug Lea”所留下的各種註釋,下面的圖片就是該類原始碼的一個截圖。

從0到1玩轉執行緒池

這些註釋的內容非常有參考價值,建議有能力的讀者朋友可以自己閱讀一遍。下面,我們就一步步地抽絲剝繭,來揭開執行緒池類ThreadPoolExecutor原始碼的神祕面紗。不過這一步並不是必須的,可以跳過。

下面是ThreadPoolExecutorexecute方法帶有中文解釋的原始碼,有興趣的朋友可以和上面的流程對照起來參考一下:

public void execute(Runnable command) {
    // 檢查提交的任務是否為空
    if (command == null)
        throw new NullPointerException();
    
    // 獲取控制變數值
    int c = ctl.get();
    // 檢查當前執行緒數是否達到了核心執行緒數
    if (workerCountOf(c) < corePoolSize) {
        // 未達到核心執行緒數,則建立新執行緒
        // 並將傳入的任務作為該執行緒的第一個任務
        if (addWorker(command, true))
            // 新增執行緒成功則直接返回,否則繼續執行
            return;

        // 因為前面呼叫了耗時操作addWorker方法
        // 所以執行緒池狀態有可能發生了改變,重新獲取狀態值
        c = ctl.get();
    }

    // 判斷執行緒池當前狀態是否是執行中
    // 如果是則呼叫workQueue.offer方法將任務放入阻塞佇列
    if (isRunning(c) && workQueue.offer(command)) {
        // 因為執行了耗時操作“放入阻塞佇列”,所以重新獲取狀態值
        int recheck = ctl.get();
        // 如果當前狀態不是執行中,則將剛才放入阻塞佇列的任務拿出,如果拿出成功,則直接拒絕這個任務
        if (! isRunning(recheck) && remove(command))
            reject(command);
        else if (workerCountOf(recheck) == 0)
            // 如果執行緒池中沒有執行緒了,那就建立一個
            addWorker(null, false);
    }
    // 如果放入阻塞佇列失敗(如佇列已滿),則新增一個執行緒
    else if (!addWorker(command, false))
        // 如果新增執行緒失敗(如已經達到了最大執行緒數),則拒絕任務
        reject(command);
}
複製程式碼

在這段原始碼中,我們可以看到,執行緒池是通過addWorker方法來建立執行緒的,這裡的這個Worker指的就是ThreadPoolExecutor類中用來對執行緒進行包裝和管理的Worker類物件。如果想了解Worker類的具體執行流程可以閱讀一下下一篇深入剖析執行緒池的任務執行流程的文章。

超時時間

那麼還有一個我們沒有提到的超時時間在這個過程中發揮了什麼作用呢?從前面我們可以看出,執行緒數量被劃分為了核心執行緒數和最大執行緒數。當執行緒沒有任務可執行時會阻塞在從佇列中獲取新任務這個操作上,這時我們稱這個執行緒為空閒執行緒,一旦有新任務被提交,則該執行緒就會退出阻塞狀態並開始執行這個新任務。

如果當前執行緒池中的執行緒總數大於核心執行緒數,那麼只要有執行緒的空閒時間超過了超時時間,那麼這個執行緒就會被銷燬;如果執行緒池中的執行緒總數小於等於核心執行緒數,那麼超時執行緒就不會被銷燬了(除了一些特殊情況外)。這也就是超時時間引數所發揮的作用了。

其他執行緒池操作

關閉執行緒池

在之前使用執行緒池執行任務的程式碼中為了等待執行緒池中的所有任務執行完已經使用了shutdown()方法,這是關閉執行緒池的一種方法。對於ThreadPoolExecutor,關閉執行緒池的方法主要有兩個:

  1. shutdown(),有序關閉執行緒池,呼叫後執行緒池會讓已經提交的任務完成執行,但是不會再接受新任務。
  2. shutdownNow(),直接關閉執行緒池,執行緒池中正在執行的任務會被中斷,正在等待執行的任務不會再被執行,但是這些還在阻塞佇列中等待的任務會被作為返回值返回。

監控執行緒池執行狀態

我們可以通過呼叫執行緒池物件上的一些方法來獲取執行緒池當前的執行資訊,常用的方法有:

  • getTaskCount,執行緒池中已完成、執行中、等待執行的任務總數估計值。因為在統計過程中任務會發生動態變化,所以最後的結果並不是一個準確值;
  • getCompletedTaskCount,執行緒池中已完成的任務總數,這同樣是一個估計值;
  • getLargestPoolSize,執行緒池曾經建立過的最大執行緒數量。通過這個資料可以知道執行緒池是否充滿過,也就是達到過maximumPoolSize;
  • getPoolSize,執行緒池當前的執行緒數量;
  • getActiveCount,當前執行緒池中正在執行任務的執行緒數量估計值。

四種常用執行緒池

很多情況下我們也不會直接建立ThreadPoolExecutor類的物件,而是根據需要通過Executors的幾個靜態方法來建立特定用途的執行緒池。目前常用的執行緒池有四種:

  1. 可快取執行緒池,使用Executors.newCachedThreadPool方法建立
  2. 定長執行緒池,使用Executors.newFixedThreadPool方法建立
  3. 延時任務執行緒池,使用Executors.newScheduledThreadPool方法建立
  4. 單執行緒執行緒池,使用Executors.newSingleThreadExecutor方法建立

下面通過這些靜態方法的原始碼來具體瞭解一下不同型別執行緒池的特性與適用場景。

可快取執行緒池

JDK中的原始碼我們通過在IDE中進行跳轉可以很方便地進行檢視,下面就是Executors.newCachedThreadPool方法中的原始碼。從程式碼中我們可以看到,可快取執行緒池其實也是通過直接建立ThreadPoolExecutor類的構造器建立的,只是其中的引數都已經被設定好了,我們可以不用做具體的設定。所以我們要觀察的重點就是在這個方法中具體產生了一個怎樣配置的ThreadPoolExecutor物件,以及這樣的執行緒池適用於怎樣的場景。

從下面的程式碼中,我們可以看到,傳入ThreadPoolExecutor構造器的值有: - corePoolSize核心執行緒數為0,代表執行緒池中的執行緒數可以為0 - maximumPoolSize最大執行緒數為Integer.MAX_VALUE,代表執行緒池中最多可以有無限多個執行緒 - 超時時間設定為60秒,表示執行緒池中的執行緒在空閒60秒後會被回收 - 最後傳入的是一個SynchronousQueue型別的阻塞佇列,代表每一個新新增的任務都要馬上有一個工作執行緒進行處理

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

所以可快取執行緒池在新增任務時會優先使用空閒的執行緒,如果沒有就建立一個新執行緒,執行緒數沒有上限,所以每一個任務都會馬上被分配到一個工作執行緒進行執行,不需要在阻塞佇列中等待;如果執行緒池長期閒置,那麼其中的所有執行緒都會被銷燬,節約系統資源。

  • 優點
    • 任務在新增後可以馬上執行,不需要進入阻塞佇列等待
    • 在閒置時不會保留執行緒,可以節約系統資源
  • 缺點
    • 對執行緒數沒有限制,可能會過量消耗系統資源
  • 適用場景
    • 適用於大量短耗時任務和對響應時間要求較高的場景

定長執行緒池

傳入ThreadPoolExecutor構造器的值有:

  • corePoolSize核心執行緒數和maximumPoolSize最大執行緒數都為固定值nThreads,即執行緒池中的執行緒數量會保持在nThreads,所以被稱為“定長執行緒池”
  • 超時時間被設定為0毫秒,因為執行緒池中只有核心執行緒,所以不需要考慮超時釋放
  • 最後一個引數使用了無界佇列,所以在所有執行緒都在處理任務的情況下,可以無限新增任務到阻塞佇列中等待執行
public static ExecutorService newFixedThreadPool(int nThreads) {
    return new ThreadPoolExecutor(nThreads, nThreads,
                                  0L, TimeUnit.MILLISECONDS,
                                  new LinkedBlockingQueue<Runnable>());
}
複製程式碼

定長執行緒池中的執行緒數會逐步增長到nThreads個,並且在之後空閒執行緒不會被釋放,執行緒數會一直保持在nThreads個。如果新增任務時所有執行緒都處於忙碌狀態,那麼就會把任務新增到阻塞佇列中等待執行,阻塞佇列中任務的總數沒有上限。

  • 優點
    • 執行緒數固定,對系統資源的消耗可控
  • 缺點
    • 在任務量暴增的情況下執行緒池不會彈性增長,會導致任務完成時間延遲
    • 使用了無界佇列,線上程數設定過小的情況下可能會導致過多的任務積壓,引起任務完成時間過晚和資源被過度消耗的問題
  • 適用場景
    • 任務量峰值不會過高,且任務對響應時間要求不高的場景

延時任務執行緒池

與之前的兩個方法不同,Executors.newScheduledThreadPool返回的是ScheduledExecutorService介面物件,可以提供延時執行、定時執行等功能。線上程池配置上有如下特點:

  • maximumPoolSize最大執行緒數為無限,在任務量較大時可以建立大量新執行緒執行任務
  • 超時時間為0,執行緒空閒後會被立即銷燬
  • 使用了延時工作佇列,延時工作佇列中的元素都有對應的過期時間,只有過期的元素才會被彈出
public static ScheduledExecutorService newScheduledThreadPool(int corePoolSize) {
    return new ScheduledThreadPoolExecutor(corePoolSize);
}

public ScheduledThreadPoolExecutor(int corePoolSize) {
    super(corePoolSize, Integer.MAX_VALUE, 0, NANOSECONDS,
          new DelayedWorkQueue());
}
複製程式碼

延時任務執行緒池實現了ScheduledExecutorService介面,主要用於需要延時執行和定時執行的情況。

單執行緒執行緒池

單執行緒執行緒池中只有一個工作執行緒,可以保證新增的任務都以指定順序執行(先進先出、後進先出、優先順序)。但是如果執行緒池裡只有一個執行緒,為什麼我們還要用執行緒池而不直接用Thread呢?這種情況下主要有兩種優點:一是我們可以通過共享的執行緒池很方便地提交任務進行非同步執行,而不用自己管理執行緒的生命週期;二是我們可以使用任務佇列並指定任務的執行順序,很容易做到任務管理的功能。

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

總結

在這篇文章中我們從執行緒池的概念和基本使用方法說起,通過execute方法的原始碼深入剖析了任務提交的全過程和各個執行緒池構造器引數線上程池實際執行過程中所發揮的作用,還真正閱讀了執行緒池類ThreadPoolExecutor的execute方法的原始碼。最後,我們介紹了執行緒池的其他常用操作和四種常用的執行緒池。

到這裡我們的執行緒池原始碼之旅就結束了,希望大家在看完這篇文章之後能對執行緒池的使用和執行流程有了一個大概的印象。為什麼說只是有了一個大概的印象呢?因為我覺得很多沒有相關基礎的讀者讀到這裡可能還只是對執行緒池有了一個自己的認識,對其中的一些細節可能還沒有完全捕捉到。所以我建議大家在看完這篇文章後不妨再返回到文章的開頭多讀幾遍,相信第二遍的閱讀能給大家帶來不一樣的體驗,因為我自己也是在第三次讀ThreadPoolExecutor類的原始碼時才真正打通了其中的一些重要關節的。

引子

在這篇文章中,我們還只是探究了執行緒池的基本使用方法,以及提交任務方法execute的原始碼。那麼在任務提交以後是怎麼被執行緒池所執行的呢?在下一篇文章中我們就可以找到答案,在下一篇文章中,我們會深入剖析執行緒池的任務執行流程。

相關文章