Java 并发包里提供了一种叫做 Fork/Join 的并行计算框架,用来支持分治。
1. 分治任务模型
分治任务模型可分为两个阶段:一个阶段是任务分解,也就是将任务迭代地分解为子任务,直至子任务可以直接计算出结果;另一个阶段是结果合并,即逐层合并子任务的执行结果,直至获得最终结果。下图是一个简化的分治任务模型图。
任务和分解后的子任务具有相似性,这种相似性往往体现在任务和子任务的算法是相同的,但是计算的数据规模是不同的。具备这种相似性的问题,我们往往都采用递归算法。
2. Fork/Join 的使用
Fork/Join 计算框架主要包含两部分,一部分是分治任务的线程池 ForkJoinPool,另一部分是分治任务 ForkJoinTask。这两部分的关系类似于 ThreadPoolExecutor 和 Runnable 的关系。
ForkJoinTask 是一个抽象类,最核心的是 fork() 方法和 join() 方法,其中 fork()
方法会异步地执行一个子任务,而 join()
方法则会阻塞当前线程来等待子任务的执行结果。
ForkJoinTask 有两个子类——RecursiveAction
和 RecursiveTask
,它们都是用递归的方式来处理分治任务的。区别是 RecursiveAction 定义的 compute() 没有返回值,而 RecursiveTask 定义的 compute() 方法是有返回值的。这两个子类也是抽象类,在使用的时候,需要你定义子类去扩展。
用 Fork / Join 实现斐波那契数列计算 对比 传统递归 :
public class TestForkJoin {
public static void main(String[] args) {
// testNew();
testOld();
}
static void testNew() {
long start = System.currentTimeMillis();
ForkJoinPool pool = new ForkJoinPool(3);
Fib f = new Fib(30);
Integer result = pool.invoke(f);
System.out.println(result);
long end = System.currentTimeMillis();
System.out.println("耗时: " + (end - start) + " ms");
}
static void testOld() {
long start = System.currentTimeMillis();
Fib f = new Fib(30);
Integer result = f.oldCompute();
System.out.println(result);
long end = System.currentTimeMillis();
System.out.println("耗时: " + (end - start) + " ms");
}
static class Fib extends RecursiveTask<Integer> {
int n;
public Fib(int n) {
this.n = n;
}
@Override
protected Integer compute() {
if (n <= 1) {
return n;
}
Fib f1 = new Fib(n - 1);
f1.fork();
Fib f2 = new Fib(n - 2);
return f2.compute() + f1.join();
//不能反过来写,否则会阻塞在join
// return f1.join()+f2.compute();
}
public Integer oldCompute() {
if (n <= 1) {
return n;
}
Fib f1 = new Fib(n - 1);
Fib f2 = new Fib(n - 2);
return f1.compute() + f2.compute();
}
}
}
3. ForkJoinPool 工作原理
ThreadPoolExecutor 本质上是一个生产者 - 消费者模式的实现,内部有一个任务队列,这个任务队列是生产者和消费者通信的媒介;ThreadPoolExecutor 可以有多个工作线程,但是这些工作线程都共享一个任务队列。
ForkJoinPool 本质上也是一个生产者 - 消费者的实现,而 ForkJoinPool 内部有多个任务队列,当我们通过 ForkJoinPool 的invoke()
或者 submit()
方法提交任务时,ForkJoinPool 根据一定的路由规则把任务提交到一个任务队列中,如果任务在执行过程中会创建出子任务,那么子任务会提交到工作线程对应的任务队列中。
ForkJoinPool 支持一种叫做“任务窃取”的机制,如果工作线程空闲了,那它可以“窃取”其他工作任务队列里的任务。
ForkJoinPool 中的任务队列采用的是双端队列,工作线程正常获取任务和“窃取任务”分别是从任务队列不同的端消费,这样能避免很多不必要的数据竞争。
思考题:
对于一个 CPU 密集型计算程序,在单核 CPU 上,使用 Fork/Join 并行计算框架是否能够提高性能呢?
单核CPU同一时间只能处理一个线程,所以理论上,纯cpu密集型计算任务单线程就够了。多线程的话,线程上下文切换带来的线程现场保存和恢复也会带来额外开销。
参考资料:王宝令----Java并发编程实战