分支/合并框架的目的是以递归方式将可以并行的任务拆分成更小的任务,然后将每个子任务的结果合并起来生成整体结果。
它是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编译器优化。这就是为什么在测量性能之前跑几遍程序很重要,我们的测试框架就是这么做的。同时还要知道,编译器内置的优化可能会为顺序版本带来一些优势(例如执行死码分析——删去从未被使用的计算)。
- 对于分支/合并拆分策略还有最后一点补充:你必须选择一个标准,来决定任务是要进一步拆分还是已小到可以顺序求值。