深入淺出Java執行緒池:使用篇

一隻修仙的猿發表於2021-01-31

前言

很高興遇見你~

藉助於很多強大的框架,現在我們已經很少直接去管理執行緒,框架的內部都會為我們自動維護一個執行緒池。例如我們使用最多的okHttp以及他的封裝框架Retrofit,執行緒封裝框架RxJava和kotlin協程等等。為了更好地使用這些框架,則必須瞭解他的實現原理,而瞭解他的原理,執行緒池是永遠繞不開的話題。

執行緒的建立與切換的成本是比較昂貴的。JVM的執行緒實現使用的是輕量級程式,也就是一個執行緒對應一個cpu核心。因此在建立與切換執行緒時,則會涉及到系統呼叫,是開銷比較大的過程。為了解決這個問題,執行緒池誕生了。

與很多連線池,如sql連線池、http連線池的思想類似,執行緒池的出現是為了複用執行緒,減少建立和切換執行緒所帶來的開銷,同時可以更方便地管理執行緒。執行緒池的內部維護有一定數量的執行緒,這些執行緒就像一個個的“工人”,我們只需要向執行緒池提交任務,那麼這些任務就會被自動分配到這些“工人”,也就是執行緒去執行。

執行緒池的好處:

  1. 減少資源損耗。重用執行緒、控制執行緒數量,減少執行緒建立和切換所帶來的開銷。
  2. 提高響應速度。可直接使用執行緒池中空閒的執行緒而不必等待執行緒的建立。
  3. 方便管理執行緒。執行緒池可以對其中的執行緒進行簡單的管理,如實時監控資料調整引數、設定定時任務等。

這個系列文章主要分兩篇:使用篇與原理篇。作為使用篇,顧名思義,主要是瞭解什麼是執行緒池以及如何正確去使用它。

執行緒池的主要實現類有兩個:ThreadPoolExecutor和ScheduledThreadPoolExecutor,後者繼承了前者。執行緒池的配置引數比較多,系統也預設了一些常用的執行緒池,放在Executors中,開發者只需要配置簡單的引數就可以。執行緒池的可執行任務有兩種物件:Runnable和Callable,這兩種物件可以直接被執行緒池執行,以及他們的非同步返回物件Future。

那麼以上講到的,就是本文要討論的內容。首先,從執行緒池的可執行任務開始。

任務型別

Runnable

public interface Runnable {
    public abstract void run();
}

Runnable我們是比較熟悉的了。他是一個介面,內部只有一個方法 run ,沒有引數,沒有返回值。當我們提交一個Runnable物件給執行緒池執行時,他的 run 方法會被執行。

Callable

public interface Callable<V> {
    V call() throws Exception;
}

與Runnable很類似,最大的不同就是他擁有返回值,而且會丟擲異常。任務物件適合用於需要等待一個返回值的後臺計算任務。

Future

public interface Future<V> {
    // 取消任務
    boolean cancel(boolean mayInterruptIfRunning);
    // 任務是否被取消
    boolean isCancelled();
    // 任務是否已經完成
    boolean isDone();
    // 獲取返回值
    V get() throws InterruptedException, ExecutionException;
    // 在一定的時間內等待返回值
    V get(long timeout, TimeUnit unit)
        throws InterruptedException, ExecutionException, TimeoutException;
}

Futrue他並不是一個任務,而是一個計算結果非同步返回的管理物件。前面的Callable任務提交給執行緒池之後,他需要一定的計算時間才能將結果返回,所以執行緒池會返回一個Future物件。我們可以呼叫他的 isDone 方法來檢測是否完成,如果完成可以呼叫 get 方法來獲取結果。同時也可以通過 cancelisCancel 來取消或者判斷任務是否被取消。如下圖:

  1. 當Future未被執行或正在執行中時,get方法會阻塞直到執行完成。如果不希望等待時間過長,可以呼叫它另外一個帶有時間引數的方法,該方法等待指定時間之後,如果任務尚未完成則會丟擲TimeoutException異常。
  2. 當Future執行完成之後,get方法會返回結果;而此時如果任務是被取消,那麼會丟擲異常。
  3. 當一個任務尚未被執行時,cancel方法會讓該任務不會被執行,直接結束。
  4. 當任務正在執行,cancel方法的引數如果為false,那麼不會中斷任務;而如果引數是true則會嘗試中斷任務。
  5. 當任務已完成,取消方法返回false,取消失敗。

Futrue介面的具體實現類是FutureTask,該類同時實現了Runnable介面,可以被執行緒池直接執行。我們可以通過傳入一個Callable物件來建立一個FutureTask。如果是Runnable物件,則可以通過 Executors.callable() 來構造一個Callable物件,只不過這個Callable物件返回null,所構造出來的Future物件get方法在成功時也會返回null。當FutureTask的 run 方法被執行後,其所包含的任務開始執行。

當然,我們也可以單獨使用FutureTask,如下:

// 建立一個FutureTask
FutureTask<String> future = new FutureTask<>(() -> {
    Thread.sleep(1000);
    return "一隻修仙的猿";
});
// 使用一個後臺執行緒來執行他的run方法
new Thread(future).start();
// 執行結束之後可以通過他的get方法得到返回值
System.out.println(future.get());

當然,如果我們要這樣使用的話,那為什麼不用執行緒池呢?(手動狗頭)

接下來介紹執行緒池的兩個主要類:ThreadPoolExecutor和ScheduledThreadPoolExecutor。

ThreadPoolExecutor

概述

我們常說的執行緒池,很大程度上指的就是ThreadPoolExecutor,他也是我們使用最為頻繁的執行緒池。ThreadPoolExecutor的內部核心角色有三個:等待佇列、執行緒和拒絕策略者:

執行緒池的內部很像一個工廠,等待佇列就如同一個流水線,我們的任務就放在裡面。 執行緒分為核心執行緒和非核心執行緒,他們就如同一個個的 “工人” ,從等待佇列中獲取任務進行執行。而如果這個“工廠”已經無法接受更多的任務,那麼這個任務就會交給拒絕策略者去處理。

這裡要特別注意的是,圖中我分為兩種執行緒是為了方便理解,而在實際中, 執行緒本身並沒有核心與非核心之分 ,只有執行緒數大於核心執行緒數與小於核心執行緒數之分

例如核心執行緒數限制為3,但現在有4個執行緒正在工作:甲乙丙丁。當甲先執行完成時進入空閒狀態時,因為總數超出設定的3,那麼甲會被認為非核心執行緒被關閉。同理,如果丁先執行完成任務,則是丁被認為非核心執行緒被關閉。


ThreadPoolExecutor並不是把每個新任務都直接放到等待佇列中,而是有一套既定的規則來執行每個新任務:

  • 情況1:線上程數沒有達到核心執行緒數時,每個新任務都會建立一個新的執行緒來執行任務。
  • 情況2:當執行緒數達到核心執行緒數時,每個新任務會被放入到等待佇列中等待被執行。
  • 情況3:當等待佇列已經滿了之後,如果執行緒數沒有到達總的執行緒數上限,那麼會建立一個非核心執行緒來執行任務。
  • 情況4:當執行緒數已經到達總的執行緒數限制時,新的任務會被拒絕策略者處理,執行緒池無法執行該任務。

這裡我們可以發現對於執行緒池中的角色,有各種各樣的數量上限,具體的有以下引數:

  • 核心執行緒數corePoolSize:指定核心執行緒的數量上限;當執行緒數小於核心執行緒數,那麼執行緒是不會被銷燬的,除非通設定執行緒池的 allowCoreThreadTimeOut 引數為true,那麼核心執行緒在等待 keepAliveTime 時間之後,就會被銷燬。
  • 允許核心執行緒被回收allowCoreThreadTimeOut :是否允許核心執行緒被回收。
  • 最大執行緒數maximumPoolSize:指定執行緒總數的上限。執行緒總數=核心線層數+非核心執行緒數。當等待佇列滿了之後,新的任務會建立新的非核心執行緒來執行,直到執行緒數到達總數的上限。
  • 執行緒閒置時間keepAliveTime:執行緒空閒等待該時間後會被銷燬。非核心執行緒預設會被銷燬,而核心執行緒則需要開發者自己設定是否允許被銷燬。
  • 等待佇列BlockingQueue:存放任務的佇列。我們可以在建立佇列的時候設定佇列的長度。一般使用的佇列有LinkedBlockingQueue、ArrayBlockingQueue、SychronizeQueue、PriorityBlockingQueue等,前兩者是普通的阻塞佇列,最後一個是優先阻塞佇列,剩下的一個是沒有容量的佇列。
  • 拒絕策略者RejectExecutionHandler :執行緒池無法處理的任務就會交給他處理。

通過設定執行緒池的這些引數可以讓執行緒池擁有完全不同的特性,來適應不同的情景。通常我們在ThreadPoolExecutor的構造方法中,會傳入這些引數:

public ThreadPoolExecutor(int corePoolSize,
                          int maximumPoolSize,
                          long keepAliveTime,
                          TimeUnit unit,
                          BlockingQueue<Runnable> workQueue,
                          ThreadFactory threadFactory,
                          RejectedExecutionHandler handler) {
    ...
}

基本都是我們上面介紹過的引數,有兩個引數我們還沒見過:

  • 時間單位unit:給前面的keepAliveTime指定時間單位。
  • 執行緒工廠threadFactory: 執行緒池會通過執行緒工廠來建立新的執行緒,主要是給執行緒指定一個有意義的名字。

當然,我們也可以不用全部指定上面的引數,ThreadFactory和RejectedExecutionHandler不指定可以使用預設的引數。

配置ThreadPoolExecutor

經過概述,我們對ThreadPoolExecutor的引數以及內部結構已經非常清楚了,接下來探討一下如何合理地進行配置。

首先是核心執行緒數。核心執行緒太少,執行緒之間會互相爭奪cpu資源,多個執行緒之間進行時間片輪換,大量的執行緒切換工作會增大系統的開銷;核心執行緒太少,會導致一些cpu核心在掛起等待阻塞操作,沒法充分利用cpu資源;核心執行緒數的配置就是要均衡這兩個特點。

關於核心執行緒數的配置,有一個廣為人知的預設規則:

cpu密集型任務,設定為CPU核心數+1;
IO密集型任務,設定為CPU核心數*2;

CPU密集型任務指的是需要cpu進行大量計算的任務,這個時候我們需要儘可能地壓榨CPU的利用率。此時核心數不宜設定過大,太多的執行緒會互相搶佔cpu資源導致不斷切換執行緒,反而浪費了cpu。最理想的情況是每個CPU都在進行計算,沒有浪費。但很有可能其中的一個執行緒會突然掛起等待IO,此時額外的一個等待執行緒就可以馬上進行工作,而不必等待掛起結束。

IO密集型任務指的是任務需要頻繁進行IO操作,這些操作會導致執行緒長時間處於掛起狀態,那麼需要更多的執行緒來進行工作,不會讓cpu都處於掛起狀態,浪費資源。一般設定為cpu核心數的兩倍即可。

當然實際情況還需要根據任務具體特徵來配置,例如系統資源有限,有一些執行緒被掛在後臺持續工作,那麼這個時候就必須適當減少執行緒數,從而減少線層切換的次數。


第二是阻塞佇列的配置。

阻塞佇列有4個內建的選擇:LinkedBlockingQueue、ArrayBlockingQueue、SychronizeQueue和PriorityBlockingQueue,其次還有比較少用的DelayedWorkQueue。

如果學習過Android的Handler機制的話,會發現他們跟Handler內部的MessageQueue是非常像的。ThreadPoolExecutor中的執行緒,相當於Handler的Looper;阻塞佇列,相當於Handler的MessageQueue。他們都會去佇列中獲取新的任務,當佇列為空時,queue.poll() 方法會進行阻塞,直到下一個新的任務來臨。

LinkedBlockingQueue、ArrayBlockingQueue都是普通的阻塞佇列,尾插頭出;區別是後者在建立的時候必須指定長度。
SychronizeQueue是一個沒有容量的佇列,每一個插入到這個佇列的任務必須馬上找到可以執行的執行緒,如果沒有則拒絕執行。
PriorityBlockingQueue是具有優先順序的阻塞佇列,裡面的任務會根據設定的優先順序進行排序。所以優先順序低的任務可能會一直被優先順序高的任務頂下去而得不到執行。
DelayedWorkQueue則非常像Handler中的MessageQueue了,可以給任務設定延時。

阻塞佇列的選擇也是非常重要,一般來說必須要指定阻塞佇列的長度。如果使用無限長的佇列,那麼有可能在大量的任務到來時直接OOM,程式崩潰。


第三是執行緒總數。

在阻塞佇列滿了之後,如果執行緒總數還沒到達上限,會建立額外的執行緒來執行。這對應付突然的大量但輕量的任務的時候有奇效。通過建立更多的執行緒來提高併發效率。

但是同時也要注意,如果執行緒數量太多,會造成頻繁進行執行緒切換導致高昂的系統開銷。


第四是執行緒存活時間。

這裡一般指非核心執行緒的存活時間。這個引數的意義在於,當執行緒都進入空閒的時候,可以回收部分執行緒來減少系統資源佔用。為了提高執行緒池的響應速度,一般比較少去回收核心執行緒。


第五是執行緒工廠。

這個引數比較簡單,繼承介面然後重寫 newThread 方法即可。主要的用途在於給建立的執行緒設定一個有意義的名字。


最後一個是拒絕策略者。

ThreadPoolExecutor內建有4種策略:AbortPolicy、CallerRunsPolicy、DiscardPolicy、DiscardOldestPolicy。預設是使用AbortPolicy,我們可以在構造方法中傳入這些類的例項,在任務被拒絕的時候,會回撥他們的 rejectedExecution 方法。

  • AbortPolicy:丟棄任務並丟擲RejectedExecutionException異常。
  • DiscardPolicy:也是丟棄任務,但是不丟擲異常。
  • DiscardOldestPolicy:丟棄佇列最前面的任務,然後重新嘗試執行任務(重複此過程)
  • CallerRunsPolicy:由呼叫執行緒直接執行該任務

當然我們也可以自己實現RejectedExecutionHandler介面來自定義自己的拒絕策略。

執行緒池的使用

  • execute(Runnable command) :執行一個任務
  • Future submit(Runnable/Callable):提交一個任務,這個任務擁有返回值
  • shutDown/shutDownNow:關閉執行緒池。後者還有去嘗試中斷正在執行的任務,也就是呼叫正在執行的執行緒的interrupt方法
  • preStartAllCoreThread:提前啟動所有的執行緒
  • isShutDown:執行緒池是否被關閉
  • isTerminad:執行緒池是否被終止。區別於shutDown,這個狀態下的執行緒池沒有正在執行的任務

這些都是比較常用的api,更多的api讀者可以去閱讀api文件。

監控執行緒池

ThreadPoolExecutor中有很多引數可以提供我們參考執行緒池的執行情況:

  • largestPoolSize:執行緒池中曾經到達的最大執行緒數量
  • completedTaskCount:完成的任務個數
  • getAliveCount:獲取當前正在執行任務的執行緒數
  • getTaskCount:獲取當前任務個數
  • getPoolSize:獲取當前執行緒數

此外還有很多的get方法來獲取相關引數,提供動態的執行緒池執行情況監控,感興趣的讀者可以去閱讀相關的api。

ThreadPoolExecutor中還有提供了一些的回撥方法:

  • beforeExecute:在任務執行前被呼叫
  • afterExecute:在任務執行之後被呼叫
  • terminated:線上程池終止之後被呼叫

我們可以通過繼承ThreadPoolExecutor來重寫這些方法。

ScheduledThreadPoolExecutor

概述

ScheduledThreadPoolExecutor繼承自ThreadPoolExecutor,整體的內部結構和ThreadPoolExecutor是一樣的。有一個重點的不同就是阻塞佇列使用的是DelayedWorkQueue。他可以根據我們設定的時間延遲來對任務進行排序,讓任務按照時間順序進行執行,和MessageQueue非常像。

ScheduledThreadPoolExecutor實現了ScheduledExecutorService介面,有一系列可以設定延時任務、週期任務的api。

定時週期任務我們會想到Timer這個框架。這裡簡單做個對比:

  • Timer是系統時間敏感的,他會根據系統時間來觸發任務
  • Timer內部只有一個執行緒,可能會造成執行任務阻塞無法按時執行;而ScheduledThreadPoolExecutor可以建立多個執行緒來執行任務
  • Timer遇到異常時會直接丟擲,線層終止;而ScheduledThreadPoolExecutor有一個更加完善的異常處理機制。

引數配置

先看到ShcduledhreadPoolExecutor的構造器:

public ScheduledThreadPoolExecutor(int corePoolSize,
                                   ThreadFactory threadFactory,
                                   RejectedExecutionHandler handler) {
    super(corePoolSize, Integer.MAX_VALUE,
          DEFAULT_KEEPALIVE_MILLIS, MILLISECONDS,
          new DelayedWorkQueue(), threadFactory, handler);
}

ScheduledThreadPoolExecutor有幾個構造器,引數數量最多的是上面這個。父類是ThreadPoolExecutor,所以這裡是直接呼叫到ThreadPoolExecutor的構造器:

  • 核心執行緒數:這個由我們自己傳入引數設定
  • 執行緒數上限:這個設定Integer.MAX_VALUE,可以認為是沒有上限
  • keepAliveTime:10毫秒,基本上只要執行緒進入空閒狀態馬上就會被回收
  • 阻塞佇列:DelayedWorkQueue在上面介紹過了,可以設定延遲時間的阻塞佇列
  • 執行緒工廠和拒絕策略由開發者自定義

可以看到ShceduledThreadPoolExecutor的配置還是比較簡單的,重點在於他的延時阻塞佇列可以設定延時任務;keepAliveTime時間的設定讓空閒的執行緒馬上就會被回收。

執行緒池的使用

ScheduledThreadPoolExecutor有ThreadPoolExecutor的介面,同時還包含了一些定時任務的介面:

  • schedule:傳入一個任務和延遲的時間,在延遲時間之後開始執行任務
  • scheduleAtFixedRate:設定固定時間點指定任務。傳入兩個時間,第一次執行在延遲初始化的時間後;之後每隔指定時間執行一次。
  • scheduledWithFixedDelay:在初始延遲之後,每次執行之後延遲指定時間再次執行。

重點就是上面的三個方法的使用,其他的和ThreadPoolExecutor類似。

Executors

執行緒池配置的引數很多,框架內建了一些已經配置好引數的執行緒池,如果懶得去配置引數,可直接使用內建的執行緒池,可以使用Executors.newXXX方法來構建。

ThreadPoolExecutor有三個配置好引數的執行緒池:FixedTthreadPool、CacheThreadPool、SingleThreadExecutor。ScheduledThreadPoolExecutor有兩個配置好引數的執行緒池:ScheduledThreadPoolExecutor和SingleThreadScheduledExecutor。

我們看一下這些執行緒池:

FixedThreadPool

public static ExecutorService newFixedThreadPool(int nThreads, ThreadFactory threadFactory) {
    return new ThreadPoolExecutor(nThreads, nThreads,
                                  0L, TimeUnit.MILLISECONDS,
                                  new LinkedBlockingQueue<Runnable>(),
                                  threadFactory);
}

這類執行緒池只有核心執行緒,數量固定且不會被回收,等待佇列的長度沒有上限。

這個執行緒池的特點就是執行緒數量固定,適用於負載較重的伺服器,可以通過這種執行緒池來限制執行緒數量;執行緒不會被回收,也有比較高的響應速度。

但是等待佇列沒有上限,在任務過多時有可能發生OOM。

CacheThreadPool

public static ExecutorService newCachedThreadPool(ThreadFactory threadFactory) {
    return new ThreadPoolExecutor(0, Integer.MAX_VALUE,
                                  60L, TimeUnit.SECONDS,
                                  new SynchronousQueue<Runnable>(),
                                  threadFactory);
}

這類執行緒池沒有核心執行緒,而且執行緒數量上限為Integer.MAX_VALUE,可以認為沒有上限。等待佇列沒有長度,每一個任務到來都會分配一個執行緒來執行。

這類執行緒池的特點就是每個任務都會被馬上執行,在任務數量過大時可能會建立大量的執行緒導致系統OOM。但是在一些任務數量多但執行時間短的情景下比較適用。

SingleThreadExecutor

public static ExecutorService newSingleThreadExecutor(ThreadFactory threadFactory) {
    return new FinalizableDelegatedExecutorService
        (new ThreadPoolExecutor(1, 1,
                                0L, TimeUnit.MILLISECONDS,
                                new LinkedBlockingQueue<Runnable>(),
                                threadFactory));
}

只有一個執行緒的執行緒池,等待佇列沒有上限,每個任務都會按照順序被執行。適用於對任務執行順序有嚴格要求的場景。

ScheduledThreadPoolExecutor

public static ScheduledExecutorService newScheduledThreadPool(
        int corePoolSize, ThreadFactory threadFactory) {
    return new ScheduledThreadPoolExecutor(corePoolSize, threadFactory);
}

和ScheduledThreadPoolExecutor原生預設的構造器差別不大。

SingleThreadScheduledPoolExecutor

public static ScheduledExecutorService newSingleThreadScheduledExecutor(ThreadFactory threadFactory) {
    return new DelegatedScheduledExecutorService
        (new ScheduledThreadPoolExecutor(1, threadFactory));
}

控制執行緒只有一個。每個任務會按照順序先後被執行。

小結

可以看到,Executors只是按照一些比較大的情景方向,對執行緒池的引數進行簡單的配置。那可能會問:那我直接使用執行緒池的構造器自己設定不就完事了?

確實,阿里巴巴開發者手冊也是這樣建議的。儘量使用原生的構造器來建立執行緒池物件,這樣我們可以根據實際的情況配置出更加規範的執行緒池。Executors中的執行緒池在一些極端的情況下都可能會發生OOM,那麼我們自己配置執行緒池時就要儘量避免這個問題。

最後

關於執行緒池的使用總體上就介紹到這裡,執行緒池有非常多的優點,希望下次需要建立執行緒的時候,不會只記得 new Thread

下一篇將深入執行緒池的內部實現原理,如果瞭解過Android的Handler機制會發現兩者的設計幾乎一模一樣,也是非常有趣的。

希望文章對你有幫助。

參考文獻

  • 《Java併發程式設計的藝術》:併發程式設計必讀,作者對一些原理講的很透徹
  • 《Java核心技術卷》:這系列的書主要是講解框架的使用,不會深入原理,適合入門
  • javaGuide:javaGuide,對java知識總結得很不錯的一個部落格
  • Java併發程式設計:執行緒池的使用:部落格園上一位很優秀的博主,文章寫得通俗易懂且不失深度

全文到此,原創不易,覺得有幫助可以點贊收藏評論轉發。
筆者才疏學淺,有任何想法歡迎評論區交流指正。
如需轉載請評論區或私信交流。

另外歡迎光臨筆者的個人部落格:傳送門

相關文章