Java的 分支合并框架 是以递归方式将可以并行的任务拆分成更小的任务,然后将每个子任务的结果合并起来生成整体结果。它是ExecutorService接口的一个实现,它把子任务分配给线程池(称为ForkJoinPool)中的工作线程。
该框架由 JDK1.7 提供,思想类似于 分治法,只不过是并行执行。要使用它首先需要创建 RecursiveTask<V> 的一个子类,然后定义任务和子任务,提交到 ForkJoinPool 工作线程池中进行并行任务。要定义 RecursiveTask,只需要实现一个抽象方发即可:
/**
* The main computation performed by this task.
*/
protected abstract V compute();
这个方法同时定义了将任务拆分成子任务的逻辑,以及无法在拆分或不方便再拆分时生成单个子任务结果的逻辑。
递归拆分过程:
- fork():利用另一个 ForkJoinPool 线程异步执行新创建的子任务
- join():读取第一个子任务的结果,尚未完成就等待
这里列一个经典的并行求和的实例:
import java.util.concurrent.ForkJoinPool;
import java.util.concurrent.RecursiveTask;
public class ForkJoin extends RecursiveTask<Long> {
/**
* 不再分解子任务的数组大小
*/
private final long THRESHOLD = 10_000;
/**
* 子任务处理的数组和起始位置
*/
private long[] numbers;
private int start;
private int end;
public ForkJoin(long[] numbers, int start, int end) {
this.numbers = numbers;
this.start = start;
this.end = end;
}
@Override
protected Long compute() {
if (end - start <= this.THRESHOLD) {
// 不再分解子任务。计算数组和
long sum = 0;
for (int i = start; i < end; i++) {
sum += numbers[i];
}
return sum;
}
int mid = (start + end) / 2;
// 1、创建一个子任务求数组前半部分的和
ForkJoin leftTask = new ForkJoin(numbers, start, mid);
// 2、创建另一个子任务求数组后半部分的和
ForkJoin rightTask = new ForkJoin(numbers, mid, end);
// 3、异步执行其中一个子任务,同步执行第二个子任务
leftTask.fork();
// 4、join方法会阻塞调用方,因此要在两个任务都开始后调用
return rightTask.compute() + leftTask.join();
}
}
public static void main(String[] args) {
long nums[] = new long[10_000_000];
for(long i = 1; i < nums.length; i++) {
nums[(int) (i - 1)] = i;
}
ForkJoin forkJoin = new ForkJoin(nums, 0, nums.length);
long start = System.currentTimeMillis();
long result = new ForkJoinPool().invoke(forkJoin);
long end = System.currentTimeMillis();
System.out.println("fork/join result: " + result);
System.out.println("fork/join cost: " + (end - start) + "ms");
start = System.currentTimeMillis();
long sum = 0;
for (long l : nums) {
sum += l;
}
end = System.currentTimeMillis();
System.out.println("order result: " + sum);
System.out.println("order cost: " + (end - start) + "ms");
}
上述执行结果是
fork/join result: 49999995000000
fork/join cost: 20ms
order result: 49999995000000
order cost: 11ms
出乎意料的是并行计算的结果比顺序执行还要慢???
不要着急,我们还需要注意以下几点:
- 对一个任务调用 join 方法会阻塞调用方,直到任务结束才做出结果,因此,有必要在两子任务都开始计算后再调用。(代码第4步)
- 不应在内部使用 ForkJoinPool().invoke(forkJoin),而是直接调用 compute() 或 fork()。
- 对子任务调用 fork() 方法可以让它进入 ForkJoinPool。但不应同时两边都调用,这样做的效率比对其中一个直接调用 compute() 效率低。因为本可以为其中一子任务重用同一个线程,避免多分配一个任务线程的开销。
// 不建议这样使用
leftTask.fork();
rightTask.fork();
- 不要理所当然认为使用 fork/join 框架 就比 顺序计算快,一个任务可以分解成多个独立的子任务,才能让性能在并行化时有所提升。所有这些子任务的运行时间都应该比分出新任务所花的时间长;一个惯用方法是把输入/输出放在一个子任务里,计算放在另一个里,这样计算就可以和输入/输出同时进行。此外,在比较同一算法的顺序和并行版本的性能时还有别的因素要考虑。就像任何其他Java代码一样,分支/合并框架需要“预热”或者说要执行几遍才会被JIT编译器优化。这就是为什么在测量性能之前跑几遍程序很重要。同时还要知道,编译器内置的优化可能会为顺序版本带来一些优势。
工作窃取
上述代码中我们定义了一个常量
private final long THRESHOLD = 10_000;
表示数组中最多包含10,000个项目时就不再创建子任务了。对于 THRESHOLD 的定义我们应慎重决定,否则会因为子任务分解过多造成资源浪费或任务分解过少导致执行效率低下。
但大多数情况下,分出大量的小任务一般来说都是一个好的选择。因为理想情况下,划分并行任务时, 应该让每个任务都用完全相同的时间完成,让所有的CPU内核都同样繁忙。但实际中,每个子任务所花的时间可能天差地别,要么是因为划分策略效率低,要么是有不可预知的原因,比如磁盘访问慢,或是需要和外部服务协调执行。这就造成了多个工作线程中有些执行完成任务后会闲置下来,而有些未完成任务的会一直执行下去,从而因为任务分配不均匀而造成资源浪费。
因此,在 Fork/Join 框架中引入了一个叫 工作窃取 的思想来解决上述问题:
在 ForkJoinPool 线程池中,每个线程都为分配给它的任务保存一个双向链式队列(Deque)。当前线程,每完成一个任务,就会从队列头上取出下一个任务开始执行。
然而,因为上述的某些原因,有些工作线程会早早完成任务而空闲下来,有些线程仍在继续工作。此时,那些闲下来的线程,会随机的从仍在工作的线程的尾部“偷走“”一个任务继续工作下去,直至所有任务全部完成。
这就是为什么要划成许多小任务而不是少数几个大任务,这有助于更好地在工作线程之间平衡负载。