分支/合併框架的目的是以遞歸方式將可以並行的任務拆分成更小的任務,然後將每個子任務的結果合併起來生成整體結果。
它是ExecutorService接口的一個實現,它把子任務分配給線程池(稱爲ForkJoinPool)中的工作線程。
要把任務提交到這個池,必須創建RecursiveTask<R>的一個子類,其中R是並行化任務(以及所有子任務)產生的結果類型,或者如果任務不返回結果,則是RecursiveAction類型(當然它可能會更新其他非局部機構)。
用分支/合併框架執行並行求和實例:
import java.util.concurrent.ForkJoinPool;
import java.util.concurrent.ForkJoinTask;
import java.util.concurrent.RecursiveTask;
import java.util.stream.LongStream;
public class ForkJoinSumCalculator extends RecursiveTask<Long> {// 繼承RecursiveTask來創建可以用於分支/合併框架的任務
private static final long serialVersionUID = 1L;
private final long[] numbers;// 要求和的數組
private final int start;// 子任務處理的數組的起始
private final int end; // 和終止位置
public static final long THRESHOLD = 10_000;// 不再將任務分解爲子任務的數組大小
// 公共構造函數用於創建主任務
public ForkJoinSumCalculator(long[] numbers) {
this(numbers, 0, numbers.length);
}
//私有構造函數用於以遞歸方式爲主任務創建子任務
private ForkJoinSumCalculator(long[] numbers, int start, int end) {
this.numbers = numbers;
this.start = start;
this.end = end;
}
@Override
protected Long compute() {// 覆蓋RecursiveTask抽象方法
int length = end - start;// 該任務負責求和的部分的大小
if (length <= THRESHOLD) {
return computeSequentially();
}
ForkJoinSumCalculator leftTask = new ForkJoinSumCalculator(numbers, start, start + length/2);// 創建一個子任務來爲數組的前一半求和
leftTask.fork();// 利用另一個ForkJoinPool線程異步執行新創建的子任務
ForkJoinSumCalculator rightTask = new ForkJoinSumCalculator(numbers, start + length/2, end);// 創建一個任務爲數組的後一半求和
Long rightResult = rightTask.compute();// 同步執行第二個子任務,有可能允許進一步遞歸劃分
Long leftResult = leftTask.join();// 讀取第一個子任務的結果,如果尚未完成就等待
return leftResult + rightResult;// 該任務的結果是兩個子任務結果的組合
}
// 在子任務不再可分時計算結果的簡單算法
private long computeSequentially() {
long sum = 0;
for (int i = start; i < end; i++) {
sum += numbers[i];
}
return sum;
}
public static void main(String[] args) {
long[] numbers = LongStream.rangeClosed(1, 10_000_000L).toArray();
ForkJoinTask<Long> task = new ForkJoinSumCalculator(numbers);
Long sum = new ForkJoinPool().invoke(task);
System.out.println(sum);
}
}
使用分支/合併框架的最佳做法:
- 對一個任務調用join方法會阻塞調用方,直到該任務做出結果。因此,有必要在兩個子任務的計算都開始之後再調用它。否則,你得到的版本會比原始的順序算法更慢更復雜,因爲每個子任務都必須等待另一個子任務完成才能啓動。
- 不應該在RecursiveTask內部使用ForkJoinPool的invoke方法。相反,你應該始終直接調用compute或fork方法,只有順序代碼才應該用invoke來啓動並行計算。
- 對子任務調用fork方法可以把它排進ForkJoinPool。同時對左邊和右邊的子任務調用它似乎很自然,但這樣做的效率要比直接對其中一個調用compute低。這樣做你可以爲其中一個子任務重用同一線程,從而避免在線程池中多分配一個任務造成的開銷。
- 調試使用分支/合併框架的並行計算可能有點棘手。特別是你平常都在你喜歡的IDE裏面看棧跟蹤(stack trace)來找問題,但放在分支合併計算上就不行了,因爲調用compute的線程並不是概念上的調用方,後者是調用fork的那個。
- 和並行流一樣,你不應理所當然地認爲在多核處理器上使用分支/合併框架就比順序計算快。我們已經說過,一個任務可以分解成多個獨立的子任務,才能讓性能在並行化時有所提升。所有這些子任務的運行時間都應該比分出新任務所花的時間長;一個慣用方法是把輸入/輸出放在一個子任務裏,計算放在另一個裏,這樣計算就可以和輸入/輸出同時進行。此外,在比較同一算法的順序和並行版本的性能時還有別的因素要考慮。就像任何其他Java代碼一樣,分支/合併框架需要“預熱”或者說要執行幾遍纔會被JIT編譯器優化。這就是爲什麼在測量性能之前跑幾遍程序很重要,我們的測試框架就是這麼做的。同時還要知道,編譯器內置的優化可能會爲順序版本帶來一些優勢(例如執行死碼分析——刪去從未被使用的計算)。
- 對於分支/合併拆分策略還有最後一點補充:你必須選擇一個標準,來決定任務是要進一步拆分還是已小到可以順序求值。