Java8实战-分支/合并框架实例

版权声明:本文为博主原创文章,未经博主允许不得转载。 https://blog.csdn.net/xiaoyao880609/article/details/82977926

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

猜你喜欢

转载自blog.csdn.net/xiaoyao880609/article/details/82977926