多執行緒系列(二十一) -ForkJoin使用詳解

程序员志哥發表於2024-03-18

一、摘要

從 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

相關文章