简介
分支合并框架的目的是以递归方式将可以并行的任务拆分成更小的任务。然后将每个子任务的结果合并起来生成整体的结果。他是ExecutorService接口的一个实现,他把子任务分配给线程池(ForkJoinPool)中的工作线程。
1、使用RecursiveTask
要把任务提交到这个池子,必须创建一个RecursiveTask<R>的一个子类,其中R是并行化的任务(以及所有子任务)产生的结果类型,或者如果任务不返回结果,则是RecursiveAction类型。要定义RecursiveTask,只需实现他的唯一抽象方法compute即可:
protected abstract R compute();
这个方法同时定义了将任务拆分成子任务的逻辑,以及无法在拆分或不方便在拆分时,生成单个子任务的结果的逻辑。正由于此,这个方法的实现类似于下面的伪代码。
public R compute(){
if(task < threadShold){
concurrent execution...
}else{
split task to left and right;
递归调用本方法
拆分每个子任务
等待所有子任务完成
合并每个子任务结果
}
}
compute的编写过程非常像分治算法。
2、案例
接下来我们试着使用这个模式为一个数字数组进行求和计算。
2.1、编写RecuisiveRask的实现
package com.sunrun.movieshow.concurrent.forkjoin;
import java.util.concurrent.RecursiveTask;
public class ForkJoinSumCalculator extends RecursiveTask<Long> {
// compute array
private final long[] numbers;
private final int start;
private final int end;
// 不再切分的阈值
private static final long THRESHOLD = 10_000L;
private ForkJoinSumCalculator(long[] numbers, int start, int end){
this.numbers = numbers;
this.start = start;
this.end = end;
}
// 用于创建主任务
public ForkJoinSumCalculator(long[] numbers){
this(numbers,0,numbers.length);
}
@Override
protected Long compute() {
// 计算当前处理的数组长度
int length = end - start;
// 判断是否需要切分
if(length <= THRESHOLD){
// 小于当前阈值,顺序计算结果
return computeSequentially();
}
// == 否则切分计算
ForkJoinSumCalculator leftTask = new ForkJoinSumCalculator(numbers, start, start + length / 2);
// 利用另一个ForkJoinPool线程异步执行新创建的子任务
leftTask.fork();
ForkJoinSumCalculator rightTask = new ForkJoinSumCalculator(numbers, start + length / 2, end);
// 这里也可以调用join,但比起再次分配线程,最好还是直接调用compute好些。
Long rightResult = rightTask.compute();
// 获取切分的另一个任务的结果,这一句代码放在最后,避免阻塞
Long leftResult = leftTask.join();
// 返回结算结果
return leftResult + rightResult;
}
/**
* 顺序计算结果
* @return 当前顺序计算的结果
*/
private Long computeSequentially() {
long sum = 0;
for (int i = start; i < end; i++) {
sum += numbers[i];
}
return sum;
}
}
创建ForkJoin线程池并提交我们创建的任务。
package com.sunrun.movieshow.concurrent.forkjoin;
import java.util.Arrays;
import java.util.List;
import java.util.concurrent.ForkJoinPool;
import java.util.concurrent.RecursiveTask;
import java.util.stream.LongStream;
public class ForkJoinSumCalculatorTest{
public static void main(String[] args) {
// 需要计算的数组
long n = 1_000_000;
// 1~1000000
long[] numbers = LongStream.rangeClosed(1, n).toArray();
// create ForkJoinSumCalculator instance.
ForkJoinSumCalculator task = new ForkJoinSumCalculator(numbers);
// create pool and set forkjoin calculator to it.
System.out.println(new ForkJoinPool().invoke(task));
/**
* 500000500000
*/
}
}
注意到这个计算过程会涉及到装拆箱,因此性能可能会有所降低。
总结
虽然分支合并框架还算简单易用,但是需要注意使用他的场合,避免出现难以预估的错误。
错误还好,怕是没发现产生了这种错误。异步框架的错误调试是很痛苦的。
-
对一个任务调用Join方法会阻塞调用方,直到该任务作出结果。因此,我们最好将该方法的调用放在方法的最后(如2代码所示)。否则,我们得到的版本会比原始的顺序算法更慢,因为每个子任务都必须等待另一个子任务完成才能启动;
-
不应该在RecursieTask内部使用ForkJoinPool的invoke方法。相反,你应该时钟直接调用compute或fork方法,只有顺序代码才应该用invoke来启动并行计算。
-
对子任务调用fork方法可以把它排进ForkJoinPool。同时对左边和右边的子任务调用他似乎很自然。但这样做的效率比直接对其中一个调用compute要低。这样做我们可以Wie其中的一个子任务重用同一线程,从而避免在线程池中多分配一个任务造成的开销,因此我们对rightTask调用的是compute方法。
-
调试使用分支/合并框架的并行计算比较麻烦:因为compute的线程并不是概念上的调用方,后者是调用fork的那个。
-
JIT编译器优化问题:分支/合并框架需要预热或者说执行即便才会被JIT编译器优化。这就是为什么在测量性能之前要跑几遍程序的原因。
最后,我们必须选择一个标准,来决定任务是要进一步拆分还是已经小到可以顺序求值。这是一个经验的问题,没有一个定型的标准,比如我们的程序以数组长度为10000进行划分,当前的计算数组长度小于等于该值时,就不再进行切分,而直接顺序计算结果。