1 什么是工作窃取模式
首先澄清一点,很多人认为工作窃取模式是JDK1.8的新特性,这不是正确的说法,而是在JDK 1.7 时,标准类库添加了 ForkJoinPool,作为对 Fork/Join 型线程池的实现,在JDK1.8时进行了优化,使之更好的被开发者所使用。
顾名思义,Fork 有 分叉 的意思,而 Join有 合并 的意思。ForkJoinPool 的功能即是如此:Fork 将大任务分割为多个小任务,然后让小任务执行,Join 是获得小任务的结果,然后进行合并,将合并的结果作为大任务的结果 —— 并且这会是一个递归的过程( 因为任务如果足够大,可以将任务多级分叉直到任务足够小,那么递归的临界值怎么指定呢,下面讲一个具体的使用)
2 算法分析
下面是《java并发编程的艺术》书中作图,非常一目了然,将一个大任务不断fork为子任务,然后子任务的结果集再不断join合并为大任务的结果
当有一个大任务(比较耗时,数据量比较大)时,可以把这个任务分割成若干互不依赖的子任务,为了减少线程竞争,把子任务分别放入不同队列,并为每个队列创建一个单独的工作线程
有的线程会提前把自己队列任务做完,为了提高效率,已经做完任务的线程会去其他队列窃取任务执行,以帮助其他未完成的线程(而不是自己等着)。此时他们访问同一个队列,为了减少窃取任务线程和被窃取任务线程之间的竞争,通常会使用双端队列。被窃取任务线程永远从双端队列头部取任务执行,窃取任务线程永远从双端队列尾部拿任务执行。其运行流程图如下:
Fork/Join 框架 与 线程池的区别
1 ThreadPoolExecutor 只能执行 Runnable 和 Callable 任务,而 ForkJoinPool 不仅可以执行 Runnable 和 Callable 任务,还可以执行 Fork/Join 型任务 —— ForkJoinTask —— 从而满足并行地实现分治算法的需要;
2 ThreadPoolExecutor 中任务的执行顺序是按照其在共享队列中的顺序来执行的,所以后面的任务需要等待前面任务执行完毕后才能执行而 ForkJoinPool 每个线程有自己的任务队列,并在此基础上实现了 Work-Stealing 的功能,使得在某些情况下 ForkJoinPool 能更大程度的提高并发效率。
3 对于一般的线程池实现 ,fork/join 框架的优势体现在对其中包含的任务的处理方式上,在一般的线程池中,如果一个线程正在执行的任务由于某些原因无法继续运行, 那么该线程会处于等待状态。
而在fork/join 框架实现中,如果某个子问题由于等待另外一个子问题的完成而无法继续运行,那么处理该子问题的线程会主动寻找其他尚未运行的子问题(窃取过来)来执行,这种方式减少了线程的等待时间,提高了性能
工作窃取算法优缺点
工作窃取算法优点:就是充分利用线程进行并行计算,减少了线程间的竞争。
工作窃取算法缺点:当双端队列里只有一个任务时,还时会存在竞争。而且该算法创建多个线程和多个双端队列,会消耗更过的系统资源。
3 代码示例
使用下面的代码计算 0,100000000的总和
package juc;
import java.util.concurrent.ForkJoinPool;
import java.util.concurrent.ForkJoinTask;
import java.util.concurrent.RecursiveTask;
public class ForkJoinTest extends RecursiveTask<Integer> {
private static final long THURSHOLD = 1000; //临界值
private static final long serialVersionUID = 6145975974734867182L;
private int start;
private int end;
public ForkJoinTest(int start,int end) {
this.start = start;
this.end = end;
}
@Override
protected Integer compute() {
int length = end - start;
//如果任务分割的足够小就计算任务
if(length <= THURSHOLD) {
int sum = 0;
for(int i = start;i <= end;i++) {
sum+=i ;
}
return sum;
} else {
//任务大于阈值,分割成两个子任务
int middle = (start + end) / 2;
ForkJoinTest left = new ForkJoinTest(start, middle );
left.fork(); //执行子任务
ForkJoinTest right = new ForkJoinTest(middle + 1, end );
right.fork(); //执行子任务
return left.join() + right.join(); //等待子任务执行完并结果合并
}
}
public static void main(String[] args) {
ForkJoinPool pool = new ForkJoinPool();
//计算 0,100000000的总和
ForkJoinTask<Integer> task = new ForkJoinTest(0,100000000);
long sum = pool.invoke(task);
System.out.println(sum);
}
}
注意每个子任务在调用fork方法时,会再次进入compute方法,继续分割或者执行子任务并返回结果,调用join方法时等待子任务执行完成并得到其结果
大家注意到test类继承了RecursiveTask类,这是因为:
Fork/Join使用两个类来完成上述工作:
- ForkJoinTask:要使用ForkJoin框架,必须先创建一个ForkJoin任务。它提供在任务中执行fork和join操作机制,在使用时,我们通常继承ForkJoinTask的子类:
RecursiveAction或RecursiveTask,两者区别是RecursiveAction用于没有返回结果的任务,RecursiveTask用于有返回结果的任务。
- ForkJoinPool:ForkJoinTask需要通过ForkJoinPool来执行。被分割出来的子任务会添加到当前工作线程所维护的双端队列中,进入队列的头部。当一个工作线程的队列里暂时没有任务时,它会随机从其他工作线程的队列的尾部窃取一个任务执行。
那么如何知道任务的执行状态呢?
ForkJoinTask在执行时可能抛出异常,但我们无法再主线程里捕捉异常,但可以通过ForkJoinTask的isCompletedAbnnormally()方法来检查任务是否已经抛出异常或已经被取消,调用getException()可以获取异常,此方法返回Throwable对象,如果任务取消则返回CancellationException。如果任务没有完成或者没有抛出异常则返回null。
4使用事项
选取一个合适的分割任务的临界值,对 ForkJoinPool 执行任务的效率有着至关重要的影响。
临界值选取过大,任务分割的不够细,则不能充分利用 CPU;
临界值选取过小,则任务分割过多,可能产生过多的子任务,导致过多的线程间的切换和加重 GC 的负担从而影响了效率。
所以,需要根据实际的应用场景选择一个合适的分割任务的临界值。
适用场景
当不同的任务的耗时相差比较大,即某些任务需要运行较长时间,而某些任务会很快的运行完成,这种情况下用 Work-Stealing 很合适;
但是如果任务的耗时很平均,则此时 Work-Stealing 并不适合,因为窃取任务时不同线程需要抢占锁,这可能会造成额外的时间消耗,而且每个线程维护双端队列也会造成更大的内存消耗。所以 ForkJoinPool 并不是 ThreadPoolExecutor 的替代品,而是作为对 ThreadPoolExecutor 的补充。