六種常見的線程池含ForkJoinPool(Java 8+)

六種常見的線程池含ForkJoinPool(Java 8+)

前言

在之前的文章 線程池使用及源碼分析 中有提到過一部分,本章再進行詳細的介紹,6 種常見的線程池如下:

  • FixedThreadPool
  • CachedThreadPool
  • ScheduledThreadPool
  • SingleThreadExecutor
  • SingleThreadScheduledExecutor
  • ForkJoinPool

1.FixedThreadPool

構造函數如下:

參數 nThreads: the number of threads in the pool

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

核心線程數和最大線程一樣,都是 nThreads,可以將它看成是固定線程數的線程池,就算任務數超過了任務隊列(workQueue)的最大限制,也不會創建新的線程來進行處理,而是會採取拒絕策略。

2.CachedThreadPool

構造函數如下:

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

緩存線程池,它的特點在於線程數是幾乎可以無限增加的(實際最大可以達到 Integer.MAX_VALUE,爲 231 -1 ,這個數非常大,所以基本不可能達到)。

而當線程閒置時還可以對線程進行回收,60秒後自動進行回收。也就是說該線程池的線程數量不是固定不變的,當然它也有一個用於存儲提交任務的隊列,但這個隊列是 SynchronousQueue,隊列的容量爲0,實際不存儲任何任務,它只負責對任務進行中轉和傳遞,所以效率比較高。

示例:

public class CachedThreadPoolDemo {

   static ExecutorService executorService = Executors.newCachedThreadPool();//伸縮性,60s後回收

    public static void main(String[] args) {
        for (int i = 0; i < 100; i++) {
            executorService.execute(()->{
                System.out.println(Thread.currentThread().getName()+"執行");
            });
        }
    }
}

執行結果

pool-1-thread-1執行
pool-1-thread-3執行
pool-1-thread-2執行
pool-1-thread-4執行
pool-1-thread-5執行
pool-1-thread-6執行
pool-1-thread-7執行
pool-1-thread-8執行
pool-1-thread-9執行
pool-1-thread-10執行
pool-1-thread-11執行
pool-1-thread-12執行
pool-1-thread-13執行
pool-1-thread-14執行
...

循環提交 100 個任務給線程池執行,每個任務執行100毫秒,因爲 for 循環執行是非常快的,導致第一個任務還沒有執行完,那麼線程池會繼續創建線程來執行後續提交的任務。

而當任務執行完之後,假設沒有新的任務了,那麼大量的閒置線程又會造成內存資源的浪費,這時線程池就會檢測線程在 60 秒內有沒有可執行任務,如果沒有就會被銷燬,最終線程數量會減爲 0。

3.ScheduledThreadPool

它支持定時或週期性執行任務。比如每隔 10 秒鐘執行一次任務,而實現這種功能的方法主要有 3 種,如代碼所示:

  • service.schedule(new Runnable(), 1, TimeUnit.SECONDS);
  • service.scheduleAtFixedRate(new Runnable(), 1, 1, TimeUnit.SECONDS);
  • service.scheduleWithFixedDelay(new Runnable(), 1, 1, TimeUnit.SECONDS);

三種方法的區別

  • 第一種方法 schedule 比較簡單,表示延遲指定時間後執行一次任務,如果代碼中設置參數爲 1 秒,也就是 1 秒後執行一次任務後就結束。
  • 第二種方法 scheduleAtFixedRate 表示以固定的頻率執行任務,它的第二個參數 initialDelay 表示第一次延時時間,第三個參數 period 表示週期,也就是第一次延時後每次延時多長時間執行一次任務。
  • 第三種方法 scheduleWithFixedDelay 與第二種方法類似,也是週期執行任務,區別在於對週期的定義,之前的 scheduleAtFixedRate 是以任務開始的時間爲時間起點開始計時,時間到就開始執行第二次任務,而不管任務需要花多久執行;而 scheduleWithFixedDelay 方法以任務結束的時間爲下一次循環的時間起點開始計時。

前面兩種測試

public class ScheduledThreadPoolDemo {

    static ScheduledExecutorService service = Executors.newScheduledThreadPool(10);

    public static void main(String[] args) throws InterruptedException {
        service.schedule(() -> {
            System.out.println("定時線程執行1:" + LocalDateTime.now());
        }, 1, TimeUnit.SECONDS);


        service.scheduleAtFixedRate(() -> {
            System.out.println("定時線程執行2:" + LocalDateTime.now());
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }, 1, 1, TimeUnit.SECONDS);

    }
}

執行結果

定時線程執行2:2020-06-24T11:27:02.726
定時線程執行1:2020-06-24T11:27:02.726
定時線程執行2:2020-06-24T11:27:03.699
定時線程執行2:2020-06-24T11:27:04.699
定時線程執行2:2020-06-24T11:27:05.700
定時線程執行2:2020-06-24T11:27:06.700
定時線程執行2:2020-06-24T11:27:07.699

可以看到第一種只執行一次,第二種每一秒執行一次。

第三種測試

public class ScheduledThreadPoolDemo {

    static ScheduledExecutorService service = Executors.newScheduledThreadPool(10);

    public static void main(String[] args) throws InterruptedException {
        service.scheduleWithFixedDelay(() -> {
            System.out.println("定時線程執行3:" + LocalDateTime.now());
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }, 1, 1, TimeUnit.SECONDS);
    }
}

執行結果

定時線程執行3:2020-06-24T11:32:30.871
定時線程執行3:2020-06-24T11:32:32.872
定時線程執行3:2020-06-24T11:32:34.873
定時線程執行3:2020-06-24T11:32:36.873
定時線程執行3:2020-06-24T11:32:38.874
定時線程執行3:2020-06-24T11:32:40.874

每 2 秒執行一次,和第二種(scheduleAtFixedRate)不一樣的,第二種(scheduleAtFixedRate)是不論線程執行是否完成,都是每 1 秒執行一次,而 scheduleWithFixedDelay 必須等上一次任務執行完成。

4.SingleThreadExecutor

構造函數如下:

        return new FinalizableDelegatedExecutorService
            (new ThreadPoolExecutor(1, 1,
                                    0L, TimeUnit.MILLISECONDS,
                                    new LinkedBlockingQueue<Runnable>()));

它會使用唯一的線程去執行任務,原理和 FixedThreadPool 是一樣的,只不過這裏線程只有一個,如果線程在執行任務的過程中發生異常,線程池也會重新創建一個線程來執行後續的任務。這種線程池由於只有一個線程,所以非常適合用於所有任務都需要按被提交的順序依次執行的場景,而前幾種線程池不一定能夠保障任務的執行順序等於被提交的順序,因爲它們是多線程並行執行的。

5.SingleThreadScheduledExecutor

相關創建的源碼

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

它實際和第三種 ScheduledThreadPool 線程池非常相似,它只是 ScheduledThreadPool 的一個特例,內部只有一個線程。

6.五種線程池對比

線程池 核心線程數 最大線程數 空閒線程存活時間
FixedThreadPool 構造器傳入 構造器傳入 0
CachedThreadPool 0 Integer.MAX_VALUE 60秒
ScheduledThreadPool 構造器傳入 Integer.MAX_VALUE 0
SingleThreadExecutor 1 1 0
SingleThreadScheduledExecutor 1 Integer.MAX_VALUE 0

7.ForkJoinPool

ForkJoinPool 線程池在 JDK 8 加入,主要用法和之前的線程池是相同的,也是把任務交給線程池去執行,線程池中也有任務隊列來存放任務,和之前的五種線程池不同的是,它非常適合執行可以分解子任務的任務,比如樹的遍歷,歸併排序,或者其他一些遞歸場景。
[外鏈圖片轉存失敗,源站可能有防盜鏈機制,建議將圖片保存下來直接上傳(img-1Z6pHkti-1592980328694)(G:\workspace\csdn\learn-document\java\concurrent\image-20200624114720538.png)]

採用的分治思想,可以結合 歸併排序算法 進行學習,參考 數據結構與算法|第九章:排序-中

如圖所示,我們有一個 Task,這個 Task 可以產生三個子任務,三個子任務並行執行完畢後將結果彙總給 Result,比如說主任務需要執行非常繁重的計算任務,我們就可以把計算拆分成三個部分,這三個部分是互不影響相互獨立的,這樣就可以利用 CPU 的多核優勢,並行計算,然後將結果進行彙總。這裏面主要涉及兩個步驟,第一步是拆分也就是 Fork,第二步是彙總也就是 Join,到這裏我們應該已經瞭解到 ForkJoinPool 線程池名字的由來了。

7.1 斐波那契數列

遞歸相關的基礎知識可以參考 數據結構與算法|第七章:遞歸

這個數列的特點就是後一項的結果等於前兩項的和,第 0 項是 0,第 1 項是 1,那麼第 2 項就是 0+1=1,以此類推。

0、1、1、2、3、5、8、13…

遞推公式如下:

f(n) = f(n-1)+f(n-2);

終止條件:n<=1

根據遞推公式的僞代碼如下:

        protected Integer f(int n) {
            if (n <= 1) {
                return n;
            }
            int a = f(n - 1);
            int b = f(n - 2);
            return a + b;
        }

7.2 ForkJoinPool 代碼實現

public class ForkJoinPoolDemo {

    public static void main(String[] args) throws ExecutionException, InterruptedException {
        ForkJoinPool forkJoinPool = new ForkJoinPool();
        for (int i = 0; i < 10; i++) {
            ForkJoinTask task = forkJoinPool.submit(new Fibonacci(i));
            System.out.println(task.get());
        }
    }

    static class Fibonacci extends RecursiveTask<Integer> {

        int n;

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

        @Override
        protected Integer compute() {
            if (n <= 1) {
                return n;
            }
            Fibonacci fib1 = new Fibonacci(n - 1);
            fib1.fork();
            Fibonacci fib2 = new Fibonacci(n - 2);
            fib2.fork();
            return fib1.join() + fib2.join();
        }
    }

}

執行結果:

0
1
1
2
3
5
8
13
21
34

對比歸併算法,fork 的過程就相當於 拆分 的過程,join 的過程就相當於 合併 的過程。

7.3 ForkJoinPool 中的任務隊列

前面五種線程池,線程使用都是的同一個任務隊列(workQueue),但是 ForkJoinPool 線程池中每個線程都有自己獨立的任務隊列。

ForkJoinPool 線程池內部除了有一個共用的任務隊列之外,每個線程還有一個對應的雙端隊列 deque,這時一旦線程中的任務被 Fork 分裂了,分裂出來的子任務放入線程自己的 deque 裏,而不是放入公共的任務隊列中。如果此時有三個子任務放入線程 t1 的 deque 隊列中,對於線程 t1 而言獲取任務的成本就降低了,可以直接在自己的任務隊列中獲取而不必去公共隊列中爭搶也不會發生阻塞( steal 情況除外),減少了線程間的競爭和切換,是非常高效的。

deque 雙端隊列

deque 是一種具有 隊列 的性質的數據結構。

[外鏈圖片轉存失敗,源站可能有防盜鏈機制,建議將圖片保存下來直接上傳(img-eNYrj7QH-1592980328696)(G:\workspace\csdn\learn-document\java\concurrent\image-20200624142043501.png)]
work-stealing 是什麼?

假設此時線程有多個任務,線程 t1 的任務特別繁重,分裂了數十個子任務,但是 t0 此時卻無事可做,它自己的 deque 隊列爲空,這時爲了提高效率,t0 就會想辦法幫助 t1 執行任務,這就是 work-stealing
在這裏插入圖片描述
雙端隊列 deque 中,線程 t1 獲取任務的邏輯是後進先出(棧的特點),也就是LIFO(Last In Frist Out),而線程 t0 在 steal 偷線程 t1 的 deque 中的任務的邏輯是先進先出(隊列的特點),也就是FIFO(Fast In Frist Out),如圖所示,圖中很好的描述了兩個線程使用雙端隊列分別獲取任務的情景。你可以看到,使用 work-stealing 算法和雙端隊列很好地平衡了各線程的負載。

8.參考

  • 《Java 併發編程 78 講》- 徐隆曦
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章