多線程系列(二十一) -ForkJoin使用詳解

一、摘要

從 JDK 1.7 開始,引入了一種新的 Fork/Join 線程池框架,它可以把一個大任務拆成多個小任務並行執行,最後彙總執行結果。

比如當前要計算一個數組的和,最簡單的辦法就是用一個循環在一個線程中完成,但是當數組特別大的時候,這種執行效率比較差,例如下面的示例代碼。

long sum = 0;
for (int i = 0; i < array.length; i++) {
    sum += array[i];
}
System.out.println("彙總結果:" + sum);

還有一種辦法,就是將數組進行拆分,比如拆分成 4 個部分,用 4 個線程並行執行,分別計算,最後進行彙總,這樣執行效率會顯著提升。

如果拆分之後的部分還是很大,可以繼續拆,直到滿足最小顆粒度,再進行計算,這個過程可以反覆“裂變”成一系列小任務,這個就是 Fork/Join 的工作原理。

Fork/Join 採用的是分而治之的基本思想,分而治之就是將一個複雜的任務,按照規定的閾值劃分成多個簡單的小任務,然後將這些小任務的執行結果再進行彙總返回,得到最終的執行結果。分而治之的思想在大數據領域應用非常廣泛。

下面我們一起來看看 Fork/Join 的具體用法。

二、ForkJoin 用法介紹

以計算 2000 個數字組成的數組爲例,進行並行求和, Fork/Join 簡單的應用示例如下:

public class ForkJoinTest {

    public static void main(String[] args) throws Exception {
        // 創建2000個數組成的數組
        long[] array = new long[2000];
        // 記錄for循環彙總計算的值
        long sourceSum = 0;
        for (int i = 0; i < array.length; i++) {
            array[i] = i;
            sourceSum += array[i];
        }
        System.out.println("for循環彙總計算的值: " + sourceSum);

        System.out.println("---------------");

        // fork/join彙總計算的值
        ForkJoinPool forkJoinPool = new ForkJoinPool();
        ForkJoinTask<Long> taskFuture = forkJoinPool.submit(new SumTask(array, 0, array.length));
        System.out.println("fork/join彙總計算的值: " + taskFuture.get());
    }
}
public class SumTask extends RecursiveTask<Long> {

    /**
     * 最小任務數組最大容量
     */
    private static final int THRESHOLD = 500;

    private long[] array;
    private int start;
    private int end;

    public SumTask(long[] array, int start, int end) {
        this.array = array;
        this.start = start;
        this.end = end;
    }

    @Override
    protected Long compute() {
        // 檢查任務是否足夠小,如果任務足夠小,直接計算
        if (end - start <= THRESHOLD) {
            long sum = 0;
            for (int i = start; i < end; i++) {
                sum += this.array[i];
            }
            return sum;
        }
        // 任務太大,一分爲二
        int middle = (end + start) / 2;
        // 拆分執行
        SumTask leftTask = new SumTask(this.array, start, middle);
        leftTask.fork();
        SumTask rightTask = new SumTask(this.array, middle, end);
        rightTask.fork();
        System.out.println("進行任務拆分,leftTask數組區間:" + start + "," + middle + ";rightTask數組區間:" + middle + "," + end);
        // 彙總結果
        return leftTask.join() +  rightTask.join();
    }
}

輸出結果如下:

for循環彙總計算的值: 1999000
---------------
進行任務拆分,leftTask數組區間:0,1000;rightTask數組區間:1000,2000
進行任務拆分,leftTask數組區間:1000,1500;rightTask數組區間:1500,2000
進行任務拆分,leftTask數組區間:0,500;rightTask數組區間:500,1000
fork/join彙總計算的值: 1999000

從日誌上可以清晰的看到,for 循環方式彙總計算的結果與Fork/Join方式彙總計算的結果一致。

因爲最小任務數組最大容量設置爲500,所以Fork/Join對數組進行了三次拆分,過程如下:

  • 第一次拆分,將0 ~ 2000數組拆分成0 ~ 10001000 ~ 2000數組
  • 第二次拆分,將0 ~ 1000數組拆分成0 ~ 500500 ~ 1000數組
  • 第三次拆分,將1000 ~ 2000數組拆分成1000 ~ 15001500 ~ 2000數組
  • 最後合併計算,將拆分後的最小任務計算結果進行合併處理,並返回最終結果

當數組量越大的時候,採用Fork/Join這種方式來計算,程序執行效率優勢非常明顯。

三、ForkJoin 框架原理

從上面的用例可以看出,Fork/Join框架的使用包含兩個核心類ForkJoinPoolForkJoinTask,它們之間的分工如下:

  • ForkJoinPool是一個負責執行任務的線程池,內部使用了一個無限隊列來保存需要執行的任務,而執行任務的線程數量則是通過構造函數傳入,如果沒有傳入指定的線程數量,默認取當前計算機可用的 CPU 核心量
  • ForkJoinTask是一個負責任務的拆分和合並計算結果的抽象類,通過它可以完成將大任務分解成多個小任務計算,最後將各個任務執行結果進行彙總處理

正如上文所說,Fork/Join框架採用的是分而治之的思想,會將一個超大的任務進行分解,按照設定的閾值分解成多個小任務計算,最後將各個計算結果進行彙總。它的應用場景非常多,比如大整數乘法、二分搜索、大數組快速排序等等。

有個地方可能需要注意一下,ForkJoinPool線程池和ThreadPoolExecutor線程池,兩者實現原理是不一樣的。

兩者最明顯的區別在於:ThreadPoolExecutor中的線程無法向任務隊列中再添加一個任務並在等待該任務完成之後再繼續執行;而ForkJoinPool可以實現這一點,它能夠讓其中的線程創建新的任務添加到隊列中,並掛起當前的任務,此時線程繼續從隊列中選擇子任務執行。

因此在 JDK 1.7 中,ForkJoinPool線程池的實現是一個全新的類,並沒有複用ThreadPoolExecutor線程池的實現邏輯,兩者用途不同。

3.1、ForkJoinPool

ForkJoinPoolFork/Join框架中負責任務執行的線程池,核心構造方法源碼如下:

/**
 * 核心構造方法
 * @param parallelism   可並行執行的線程數量
 * @param factory       創建線程的工廠   
 * @param handler       異常捕獲處理器
 * @param asyncMode     任務隊列模式,true:先進先出的工作模式,false:先進後出的工作模式
 */
public ForkJoinPool(int parallelism,
                    ForkJoinWorkerThreadFactory factory,
                    UncaughtExceptionHandler handler,
                    boolean asyncMode) {
    this(checkParallelism(parallelism),
            checkFactory(factory),
            handler,
            asyncMode ? FIFO_QUEUE : LIFO_QUEUE,
            "ForkJoinPool-" + nextPoolId() + "-worker-");
    checkPermission();
}

默認無參的構造方法,源碼如下:

public ForkJoinPool() {
    this(Math.min(MAX_CAP, Runtime.getRuntime().availableProcessors()),
         defaultForkJoinWorkerThreadFactory, null, false);
}

默認構造方法創建ForkJoinPool線程池,關鍵參數設置如下:

  • parallelism:取的是當前計算機可用的 CPU 數量
  • factory:採用的是默認DefaultForkJoinWorkerThreadFactory類,其中ForkJoinWorkerThreadFork/Join框架中負責真正執行任務的線程
  • asyncMode:參數設置的是false,也就是說存在隊列的任務採用的是先進後出的方式工作

其次,也可以使用Executors工具類來創建ForkJoinPool,例如下面這種方式:

// 創建一個 ForkJoinPool 線程池
ExecutorService forkJoinPool = Executors.newWorkStealingPool();

ThreadPoolExecutor線程池一樣,ForkJoinPool也實現了ExecutorExecutorService接口,支持通過execute()submit()等方式提交任務。

不過,正如上面所說,ForkJoinPoolThreadPoolExecutor在實現上是不一樣的:

  • ThreadPoolExecutor中,多個線程都共有一個阻塞任務隊列
  • ForkJoinPool中每一個線程都有一個自己的任務隊列,當線程發現自己的隊列裏沒有任務了,就會到別的線程的隊列裏獲取任務執行。

這樣設計的目的主要是充分利用線程實現並行計算的效果,減少線程之間的競爭。

比如線程 A 負責處理隊列 A 裏面的任務,線程 B 負責處理隊列 B 裏面的任務,兩者如果隊列裏面的任務數差不多,執行的時候互相不干擾,此時的計算性能是最佳的;假如線程 A 的任務執行完畢,發現線程 B 中的隊列數還有一半沒有執行,線程 A 會主動從線程 B 的隊列裏獲取任務執行。

在這時它們會同時訪問同一個隊列,爲了減少線程 A 和線程 B 之間的競爭,通常會使用雙端隊列,線程 B 從雙端隊列的頭部拿任務執行,而線程 A 從雙端隊列的尾部拿任務執行,確保兩者不會從同一端獲取任務,可以顯著加快任務的執行速度。

Fork/Join框架中負責執行任務的線程ForkJoinWorkerThread,部分源碼如下:

public class ForkJoinWorkerThread extends Thread {
    
    // 所在的線程池
    final ForkJoinPool pool;

    // 當前線程下的任務隊列
    final ForkJoinPool.WorkQueue workQueue;

    // 初始化時的構造方法
    protected ForkJoinWorkerThread(ForkJoinPool pool) {
        // Use a placeholder until a useful name can be set in registerWorker
        super("aForkJoinWorkerThread");
        this.pool = pool;
        this.workQueue = pool.registerWorker(this);
    }
}

3.2、ForkJoinTask

ForkJoinTaskFork/Join框架中負責任務分解和合並計算的抽象類,它實現了Future接口,因此可以直接作爲任務類提交到線程池中。

同時,它還包括兩個主要方法:fork()join(),分別表示任務的分拆與合併。

可以使用下圖來表示這個過程。

ForkJoinTask部分方法,源碼如下:

public abstract class ForkJoinTask<V> implements Future<V>, Serializable {
    
    // 將任務推送到任務隊列
    public final ForkJoinTask<V> fork() {
        Thread t;
        if ((t = Thread.currentThread()) instanceof ForkJoinWorkerThread)
            ((ForkJoinWorkerThread)t).workQueue.push(this);
        else
            ForkJoinPool.common.externalPush(this);
        return this;
    }

    // 等待任務的執行結果
    public final V join() {
        int s;
        if ((s = doJoin() & DONE_MASK) != NORMAL)
            reportException(s);
        return getRawResult();
    }
}

在 JDK 中,ForkJoinTask有三個常用的子類實現,分別如下:

  • RecursiveAction:用於沒有返回結果的任務
  • RecursiveTask:用於有返回結果的任務
  • CountedCompleter:在任務完成執行後,觸發自定義的鉤子函數

我們最上面介紹的用例,使用的就是RecursiveTask子類,通常用於有返回值的任務計算。

ForkJoinTask其實是利用了遞歸算法來實現任務的拆分,將拆分後的子任務提交到線程池的任務隊列中進行執行,最後將各個拆分後的任務計算結果進行彙總,得到最終的任務結果。

四、小結

整體上,ForkJoinPool可以看成是對ThreadPoolExecutor線程池的一種補充,在工作線程中存放了任務隊列,充分利用線程進行並行計算,進一步提升了線程的併發執行性能。

通過ForkJoinPoolForkJoinTask搭配使用,將超大計算任務拆分成多個互不干擾的小任務,提交給線程池進行計算,最後將各個任務計算結果進行彙總處理,得到跟單線程執行一致的結果,當計算任務越大,Fork/Join框架執行任務的效率,優勢更突出。

但是並不是所有的任務都適合採用Fork/Join框架來處理,比如讀寫數據文件這種 IO 密集型的任務就不合適,因爲磁盤 IO、網絡 IO 的操作特點就是等待,容易造成線程阻塞。

五、參考

1.https://www.liaoxuefeng.com/wiki/1252599548343744/1306581226487842

2.https://juejin.cn/post/6986899215163064333

3.https://developer.aliyun.com/article/806887

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章