java中常見的六種執行緒池詳解

AnonyStar發表於2020-11-03

  • 之前我們介紹了執行緒池的四種拒絕策略,瞭解了執行緒池引數的含義,那麼今天我們來聊聊Java 中常見的幾種執行緒池,以及在jdk7 加入的 ForkJoin 新型執行緒池
  • 首先我們列出Java 中的六種執行緒池如下
執行緒池名稱 描述
FixedThreadPool 核心執行緒數與最大執行緒數相同
SingleThreadExecutor 一個執行緒的執行緒池
CachedThreadPool 核心執行緒為0,最大執行緒數為Integer. MAX_VALUE
ScheduledThreadPool 指定核心執行緒數的定時執行緒池
SingleThreadScheduledExecutor 單例的定時執行緒池
ForkJoinPool JDK 7 新加入的一種執行緒池
  • 在瞭解集中執行緒池時我們先來熟悉一下主要幾個類的關係,ThreadPoolExecutor 的類圖,以及 Executors 的主要方法:

  • 上面看到的類圖,方便幫助下面的理解和檢視,我們可以看到一個核心類 ExecutorService , 這是我們執行緒池都實現的基類,我們接下來說的都是它的實現類。

FixedThreadPool

  • FixedThreadPool 執行緒池的特點是它的核心執行緒數和最大執行緒數一樣,我們可以看它的實現程式碼在 Executors#newFixedThreadPool(int) 中,如下:
    public static ExecutorService newFixedThreadPool(int nThreads) {
        return new ThreadPoolExecutor(nThreads, nThreads,
                                      0L, TimeUnit.MILLISECONDS,
                                      new LinkedBlockingQueue<Runnable>());
    }

我們可以看到方法內建立執行緒呼叫的實際是 ThreadPoolExecutor 類,這是執行緒池的核心執行器,傳入的 nThread 引數作為核心執行緒數和最大執行緒數傳入,佇列採用了一個連結串列結構的有界佇列。

  • 這種執行緒池我們可以看作是固定執行緒數的執行緒池,它只有在開始初始化的時候執行緒數會從0開始建立,但是建立好後就不再銷燬,而是全部作為常駐執行緒池,這裡如果對執行緒池引數不理解的可以看之前文章 《解釋執行緒池各個引數的含義》
  • 對於這種執行緒池他的第三個和第四個引數是沒意義,它們是空閒執行緒存活時間,這裡都是常駐不存在銷燬,當執行緒處理不了時會加入到阻塞佇列,這是一個連結串列結構的有界阻塞佇列,最大長度是Integer. MAX_VALUE

SingleThreadExecutor

  • SingleThreadExecutor 執行緒的特點是它的核心執行緒數和最大執行緒數均為1,我們也可以將其任務是一個單例執行緒池,它的實現程式碼是Executors#newSingleThreadExcutor() , 如下:
    public static ExecutorService newSingleThreadExecutor() {
        return new FinalizableDelegatedExecutorService
            (new ThreadPoolExecutor(1, 1,
                                    0L, TimeUnit.MILLISECONDS,
                                    new LinkedBlockingQueue<Runnable>()));
    }

    public static ExecutorService newSingleThreadExecutor(ThreadFactory threadFactory) {
        return new FinalizableDelegatedExecutorService
            (new ThreadPoolExecutor(1, 1,
                                    0L, TimeUnit.MILLISECONDS,
                                    new LinkedBlockingQueue<Runnable>(),
                                    threadFactory));
    }
  • 上述程式碼中我們發現它有一個過載函式,傳入了一個ThreadFactory 的引數,一般在我們開發中會傳入我們自定義的執行緒建立工廠,如果不傳入則會呼叫預設的執行緒工廠
  • 我們可以看到它與 FixedThreadPool 執行緒池的區別僅僅是核心執行緒數和最大執行緒數改為了1,也就是說不管任務多少,它只會有唯一的一個執行緒去執行
  • 如果在執行過程中發生異常等導致執行緒銷燬,執行緒池也會重新建立一個執行緒來執行後續的任務
  • 這種執行緒池非常適合所有任務都需要按被提交的順序來執行的場景,是個單執行緒的序列。

CachedThreadPool

  • cachedThreadPool 執行緒池的特點是它的常駐核心執行緒數為0,正如其名字一樣,它所有的縣城都是臨時的建立,關於它的實現在 Executors#newCachedThreadPool() 中,程式碼如下:
    public static ExecutorService newCachedThreadPool() {
        return new ThreadPoolExecutor(0, Integer.MAX_VALUE,
                                      60L, TimeUnit.SECONDS,
                                      new SynchronousQueue<Runnable>());
    }

    public static ExecutorService newCachedThreadPool(ThreadFactory threadFactory) {
        return new ThreadPoolExecutor(0, Integer.MAX_VALUE,
                                      60L, TimeUnit.SECONDS,
                                      new SynchronousQueue<Runnable>(),
                                      threadFactory);
    }
  • 從上述程式碼中我們可以看到 CachedThreadPool 執行緒池中,最大執行緒數為 Integer.MAX_VALUE , 意味著他的執行緒數幾乎可以無限增加。
  • 因為建立的執行緒都是臨時執行緒,所以他們都會被銷燬,這裡空閒 執行緒銷燬時間是60秒,也就是說當執行緒在60秒內沒有任務執行則銷燬
  • 這裡我們需要注意點,它使用了 SynchronousQueue 的一個阻塞佇列來儲存任務,這個佇列是無法儲存的,因為他的容量為0,它只負責對任務的傳遞和中轉,效率會更高,因為核心執行緒都為0,這個佇列如果儲存任務不存在意義。

ScheduledThreadPool

  • ScheduledThreadPool 執行緒池是支援定時或者週期性執行任務,他的建立程式碼 Executors.newSchedsuledThreadPool(int) 中,如下所示:
    public static ScheduledExecutorService newScheduledThreadPool(int corePoolSize) {
        return new ScheduledThreadPoolExecutor(corePoolSize);
    }

    public static ScheduledExecutorService newScheduledThreadPool(
            int corePoolSize, ThreadFactory threadFactory) {
        return new ScheduledThreadPoolExecutor(corePoolSize, threadFactory);
    }
  • 我們發現這裡呼叫了 ScheduledThreadPoolExecutor 這個類的建構函式,進一步檢視發現 ScheduledThreadPoolExecutor 類是一個繼承了 ThreadPoolExecutor 的,同時實現了 ScheduledExecutorService 介面,我們看到它的幾個建構函式都是呼叫父類 ThreadPoolExecutor 的建構函式
    public ScheduledThreadPoolExecutor(int corePoolSize) {
        super(corePoolSize, Integer.MAX_VALUE, 0, NANOSECONDS,
              new DelayedWorkQueue());
    }

    public ScheduledThreadPoolExecutor(int corePoolSize,
                                       ThreadFactory threadFactory) {
        super(corePoolSize, Integer.MAX_VALUE, 0, NANOSECONDS,
              new DelayedWorkQueue(), threadFactory);
    }

    public ScheduledThreadPoolExecutor(int corePoolSize,
                                       RejectedExecutionHandler handler) {
        super(corePoolSize, Integer.MAX_VALUE, 0, NANOSECONDS,
              new DelayedWorkQueue(), handler);
    }

    public ScheduledThreadPoolExecutor(int corePoolSize,
                                       ThreadFactory threadFactory,
                                       RejectedExecutionHandler handler) {
        super(corePoolSize, Integer.MAX_VALUE, 0, NANOSECONDS,
              new DelayedWorkQueue(), threadFactory, handler);
    }
  • 從上面程式碼我們可以看到和其他執行緒池建立並沒有差異,只是這裡的任務佇列是 DelayedWorkQueue 關於阻塞丟列我們下篇文章專門說,這裡我們先建立一個週期性的執行緒池來看一下
    public static void main(String[] args) {
        ScheduledExecutorService service = Executors.newScheduledThreadPool(5);
        // 1. 延遲一定時間執行一次
        service.schedule(() ->{
            System.out.println("schedule ==> 雲棲簡碼-i-code.online");
        },2, TimeUnit.SECONDS);

        // 2. 按照固定頻率週期執行
        service.scheduleAtFixedRate(() ->{
            System.out.println("scheduleAtFixedRate ==> 雲棲簡碼-i-code.online");
        },2,3,TimeUnit.SECONDS);

        //3. 按照固定頻率週期執行
        service.scheduleWithFixedDelay(() -> {
            System.out.println("scheduleWithFixedDelay ==> 雲棲簡碼-i-code.online");
        },2,5,TimeUnit.SECONDS);

    }
  • 上面程式碼是我們簡單建立了 newScheduledThreadPool ,同時演示了裡面的三個核心方法,首先看執行的結果:

  • 首先我們看第一個方法 schedule , 它有三個引數,第一個引數是執行緒任務,第二個delay 表示任務執行延遲時長,第三個unit 表示延遲時間的單位,如上面程式碼所示就是延遲兩秒後執行任務
 public ScheduledFuture<?> schedule(Runnable command,
                                       long delay, TimeUnit unit);
  • 第二個方法是 scheduleAtFixedRate 如下, 它有四個引數,command 參數列示執行的執行緒任務 ,initialDelay 參數列示第一次執行的延遲時間,period 參數列示第一次執行之後按照多久一次的頻率來執行,最後一個引數是時間單位。如上面案例程式碼所示,表示兩秒後執行第一次,之後按每隔三秒執行一次
    public ScheduledFuture<?> scheduleAtFixedRate(Runnable command,
                                                  long initialDelay,
                                                  long period,
                                                  TimeUnit unit);
  • 第三個方法是 scheduleWithFixedDelay 如下,它與上面方法是非常類似的,也是週期性定時執行, 引數含義和上面方法一致。這個方法和 scheduleAtFixedRate 的區別主要在於時間的起點計時不同
    public ScheduledFuture<?> scheduleWithFixedDelay(Runnable command,
                                                     long initialDelay,
                                                     long delay,
                                                     TimeUnit unit);
  • scheduleAtFixedRate 是以任務開始的時間為時間起點來計時,時間到就執行第二次任務,與任務執行所花費的時間無關;而 scheduleWithFixedDelay 是以任務執行結束的時間點作為計時的開始。如下所示

SingleThreadScheduledExecutor

  • 它實際和 ScheduledThreadPool 執行緒池非常相似,它只是 ScheduledThreadPool 的一個特例,內部只有一個執行緒,它只是將 ScheduledThreadPool 的核心執行緒數設定為了 1。如原始碼所示:
    public static ScheduledExecutorService newSingleThreadScheduledExecutor() {
        return new DelegatedScheduledExecutorService
            (new ScheduledThreadPoolExecutor(1));
    }
  • 上面我們介紹了五種常見的執行緒池,對於這些執行緒池我們可以從核心執行緒數、最大執行緒數、存活時間三個維度進行一個簡單的對比,有利於我們加深對這幾種執行緒池的記憶。
FixedThreadPool SingleThreadExecutor CachedThreadPool ScheduledThreadPool SingleThreadScheduledExecutor
corePoolSize 建構函式傳入 1 0 建構函式傳入 1
maxPoolSize 同corePoolSize 1 Integer. MAX_VALUE Integer. MAX_VALUE Integer. MAX_VALUE
keepAliveTime 0 0 60 0 0

ForkJoinPool

  • ForkJoinPool 這是一個在 JDK7 引入的新新執行緒池,它的主要特點是可以充分利用多核CPU , 可以把一個任務拆分為多個子任務,這些子任務放在不同的處理器上並行執行,當這些子任務執行結束後再把這些結果合併起來,這是一種分治思想。
  • ForkJoinPool 也正如它的名字一樣,第一步進行 Fork 拆分,第二步進行 Join 合併,我們先來看一下它的類圖結構

  • ForkJoinPool 的使用也是通過呼叫 submit(ForkJoinTask<T> task) invoke(ForkJoinTask<T> task) 方法來執行指定任務了。其中任務的型別是 ForkJoinTask 類,它代表的是一個可以合併的子任務,他本身是一個抽象類,同時還有兩個常用的抽象子類 RecursiveActionRecursiveTask ,其中 RecursiveTask 表示的是有返回值型別的任務,而 RecursiveAction 則表示無返回值的任務。下面是它們的類圖:

  • 下面我們通過一個簡單的程式碼先來看一下如何使用 ForkJoinPool 執行緒池

/**
 * @url: i-code.online
 * @author: AnonyStar
 * @time: 2020/11/2 10:01
 */
public class ForkJoinApp1 {

	/**
    	目標: 列印0-200以內的數字,進行分段每個間隔為10以上,測試forkjoin
    */
    public static void main(String[] args) {
        // 建立執行緒池,
        ForkJoinPool joinPool = new ForkJoinPool();
        // 建立根任務
        SubTask subTask = new SubTask(0,200);
        // 提交任務
        joinPool.submit(subTask);
        //讓執行緒阻塞等待所有任務完成 在進行關閉
        try {
            joinPool.awaitTermination(2, TimeUnit.SECONDS);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        joinPool.shutdown();
    }
}

class  SubTask extends RecursiveAction {

    int startNum;
    int endNum;

    public SubTask(int startNum,int endNum){
        super();
        this.startNum = startNum;
        this.endNum = endNum;
    }

    @Override
    protected void compute() {

        if (endNum - startNum < 10){
            // 如果分裂的兩者差值小於10 則不再繼續,直接列印
            System.out.println(Thread.currentThread().getName()+": [startNum:"+startNum+",endNum:"+endNum+"]");
        }else {
            // 取中間值
            int middle = (startNum + endNum) / 2;
            //建立兩個子任務,以遞迴思想,
            SubTask subTask = new SubTask(startNum,middle);
            SubTask subTask1 = new SubTask(middle,endNum);
            //執行任務, fork() 表示非同步的開始執行
            subTask.fork();
            subTask1.fork();
        }
    }
}

結果:

  • 從上面的案例我們可以看到我們,建立了很多個執行緒執行,因為我測試的電腦是12執行緒的,所以這裡實際是建立了12個執行緒,也側面說明了充分呼叫了每個處理的執行緒處理能力
  • 上面案例其實我們發現很熟悉的味道,那就是以前接觸過的遞迴思想,將上面的案例影像化如下,更直觀的看到,

  • 上面的例子是無返回值的案例,下面我們來看一個典型的有返回值的案例,相信大家都聽過及很熟悉斐波那契數列,這個數列有個特點就是最後一項的結果等於前兩項的和,如: 0,1,1,2,3,5...f(n-2)+f(n-1), 即第0項為0 ,第一項為1,則第二項為 0+1=1,以此類推。我們最初的解決方法就是使用遞迴來解決,如下計算第n項的數值:
    private int num(int num){
        if (num <= 1){
            return num;
        }
        num = num(num-1) + num(num -2);
        return num;
    }
  • 從上面簡單程式碼中可以看到,當 n<=1 時返回 n , 如果n>1 則計算前一項的值f1,在計算前兩項的值f2, 再將兩者相加得到結果,這就是典型的遞迴問題,也是對應我們的ForkJoin 的工作模式,如下所示,根節點產生子任務,子任務再次衍生出子子任務,到最後在進行整合匯聚,得到結果。
  • 我們通過 ForkJoinPool 來實現斐波那契數列的計算,如下展示:

/**
 * @url: i-code.online
 * @author: AnonyStar
 * @time: 2020/11/2 10:01
 */
public class ForkJoinApp3 {

    public static void main(String[] args) throws ExecutionException, InterruptedException {
        ForkJoinPool pool = new ForkJoinPool();
        //計算第二是項的數值
        final ForkJoinTask<Integer> submit = pool.submit(new Fibonacci(20));
        // 獲取結果,這裡獲取的就是非同步任務的最終結果
        System.out.println(submit.get());

    }
}

class Fibonacci extends RecursiveTask<Integer>{

    int num;
    public Fibonacci(int num){
        this.num = num;
    }

    @Override
    protected Integer compute() {
        if (num <= 1) return num;
        //建立子任務
        Fibonacci subTask1 = new Fibonacci(num - 1);
        Fibonacci subTask2 = new Fibonacci(num - 2);
        // 執行子任務
        subTask1.fork();
        subTask2.fork();
        //獲取前兩項的結果來計算和
        return subTask1.join()+subTask2.join();
    }
}

  • 通過 ForkJoinPool 可以極大的發揮多核處理器的優勢,尤其非常適合用於遞迴的場景,例如樹的遍歷、最優路徑搜尋等場景。
  • 上面說的是ForkJoinPool 的使用上的,下面我們來說一下其內部的構造,對於我們前面說的幾種執行緒池來說,它們都是裡面只有一個佇列,所有的執行緒共享一個。但是在ForkJoinPool 中,其內部有一個共享的任務佇列,除此之外每個執行緒都有一個對應的雙端佇列Deque , 當一個執行緒中任務被Fork 分裂了,那麼分裂出來的子任務就會放入到對應的執行緒自己的Deque中,而不是放入公共佇列。這樣對於每個執行緒來說成本會降低很多,可以直接從自己執行緒的佇列中獲取任務而不需要去公共佇列中爭奪,有效的減少了執行緒間的資源競爭和切換。

  • 有一種情況,當執行緒有多個如t1,t2,t3...,在某一段時間執行緒 t1 的任務特別繁重,分裂了數十個子任務,但是執行緒 t0 此時卻無事可做,它自己的 deque 佇列為空,這時為了提高效率,t0 就會想辦法幫助 t1 執行任務,這就是“work-stealing”的含義。
  • 雙端佇列 deque 中,執行緒 t1 獲取任務的邏輯是後進先出,也就是LIFO(Last In Frist Out),而執行緒t0在“steal”偷執行緒 t1deque 中的任務的邏輯是先進先出,也就是FIFO(Fast In Frist Out),如圖所示,圖中很好的描述了兩個執行緒使用雙端佇列分別獲取任務的情景。你可以看到,使用 “work-stealing” 演算法和雙端佇列很好地平衡了各執行緒的負載。

本文由AnonyStar 釋出,可轉載但需宣告原文出處。
歡迎關注微信公賬號 :雲棲簡碼 獲取更多優質文章
更多文章關注筆者部落格 :雲棲簡碼 i-code.online

相關文章