Java8實戰-分支/合併框架實例

分支/合併框架的目的是以遞歸方式將可以並行的任務拆分成更小的任務,然後將每個子任務的結果合併起來生成整體結果。
它是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編譯器優化。這就是爲什麼在測量性能之前跑幾遍程序很重要,我們的測試框架就是這麼做的。同時還要知道,編譯器內置的優化可能會爲順序版本帶來一些優勢(例如執行死碼分析——刪去從未被使用的計算)。
  • 對於分支/合併拆分策略還有最後一點補充:你必須選擇一個標準,來決定任務是要進一步拆分還是已小到可以順序求值。
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章