Java Fork/Join框架 任务拆分 并行执行
文章目录
一. 概述
Stream中的parallel并行流就是使用的Fork/Join框架,Fork/Join框架是jdk 1.7推出的,用于并行执行任务,它的思想就是讲一个大任务分割成若干小任务,最终汇总每个小任务的结果得到这个大任务的结果。采用的是分治法,分而治之。
这种思想和hadoop中的MapReduce很像(input --> split --> map --> reduce --> output)
Fork/Join框架要完成两件事情:
1.任务分割:首先Fork/Join框架需要把大的任务分割成足够小的子任务,如果子任务比较大的话还要对子任务进行继续分割
2.执行任务并合并结果:分割的子任务分别放到双端队列里,然后几个启动线程分别从双端队列里获取任务执行。子任务执行完的结果都放在另外一个队列里,启动一个线程从队列里取数据,然后合并这些数据。
二. 关于工作窃取模式
工作窃取(work-stealing)算法是指某个线程从其他队列里窃取任务来执行
1. 执行过程
把一个大任务分割为若干互不依赖的子任务,为了减少线程间的竞争,于是把这些子任务分别放到不同的队列里,并为每个队列创建一个单独的线程来执行队列里的任务,线程和队列一一对应,比如A线程负责处理A队列里的任务。
某一线程或者某些线程中的任务执行完成了,为了不浪费改线程资源,于是它就去其他线程的队列里窃取一个任务来执行。而在这时它们会访问同一个队列,所以为了减少窃取任务线程和被窃取任务线程之间的竞争,通常会使用双端队列,被窃取任务线程永远从双端队列的头部拿任务执行,而窃取任务的线程永远从双端队列的尾部拿任务执行。
2. 优缺点
优点:
工作窃取算法的优点是充分利用线程进行并行计算,减少了线程等待的时间,提高了性能,并减少了线程间的竞争
缺点:
- 任务划分会消耗系统资源
- 是在某些情况下还是存在竞争,比如双端队列里只有一个任务时。并且消耗了更多的系统资源,比如创建多个线程和多个双端队列。
三. Fork/Join框架实现原理
在Java的Fork/Join框架中,使用两个类(ForkJoinTask、ForkJoinPool)完成任务划分和结果求和。
1.ForkJoinTask
ForkJoinTask代表运行在ForkJoinPool中的任务 。要使用Fork/Join框架,首先需要创建一个ForkJoin任务。该类提供了在任务中执行fork和join的机制。通常情况下我们不需要直接集成ForkJoinTask类,只需要继承它的子类
主要方法:
fork() 在当前线程运行的线程池中安排一个异步执行。简单的理解就是再创建一个子任务
join() 当任务完成的时候返回计算结果
invoke() 开始执行任务,如果必要,等待计算完成
子类:
RecursiveAction 一个递归无结果的ForkJoinTask(没有返回值)
RecursiveTask 一个递归有结果的ForkJoinTask(有返回值)
2.ForkJoinPool
ForkJoinPool在线程池家族中的位置如下:
- ForkJoinPool由ForkJoinTask数组和ForkJoinWorkerThread数组组成,ForkJoinTask数组负责将存放程序提交给ForkJoinPool,而ForkJoinWorkerThread负责执行这些任务。
- ForkJoinPool 的每个工作线程都维护着一个工作队列(WorkQueue),这是一个双端队列(Deque),里面存放的对象是任务(ForkJoinTask)。
- 任务分割出的子任务会添加到当前工作线程所维护的双端队列中,进入队列的头部。当一个工作线程的队列里暂时没有任务时,它会随机从其他工作线程的队列的尾部获取一个任务(工作窃取算法)。
总的理解就是,一个线程池中有着一个个工作线程,每个工作线程负责一个WorkQueue工作队列,每个WorkQueue是一个个的ForkJoin任务。每次执行任务,pop(执行)任务和(push)加入新任务都是从头部,而”工作窃取“则是从这个WorkQueue的尾部窃取
3.Fork/Join框架的实现原理
从一个实例去理解Fork/Join框架
计算1-100000000L累加的和
第一步:实现ForkJoinTask 执行任务RecursiveTask:有返回值 RecursiveAction:无返回值,这一步主要就是实现compute方法,任务拆分和计算的逻辑
public class ForkJoinCalculator extends RecursiveTask<Long>{
//起始值
private long start;
//结束值
private long end;
//任务划分的临界值
private static final long THRESHOLD=100000;
//构造方法
public ForkJoinCalculator(long start, long end) {
this.start = start;
this.end = end;
}
//这个方法的逻辑就是如果起始值和结束值之间的差值小于等于临界值就直接进行计算;否则就拆分子任务同时压入线程队列
@Override
protected Long compute() {
if((end-start)<=THRESHOLD){
long sum=0;
for (long i=start;i<=end;i++){
sum=sum+i;
}
return sum;
}else {
long middle=(start+end)/2;
ForkJoinCalculator sumTaskleft = new ForkJoinCalculator(start, middle);
//拆分子任务同时压入线程队列
sumTaskleft.fork();
ForkJoinCalculator sumTaskright = new ForkJoinCalculator(middle+1, end);
//拆分子任务同时压入线程队列
sumTaskright.fork();
//合并子任务计算结果并且返回
return sumTaskleft.join()+sumTaskright.join();
}
}
}
第二步:将ForkJoinTask任务提交到ForkJoinPool线程池中去,可以使用submit()方法或者invoke()方法。submit:无返回值,invoke:有返回值。
public static void main(String[] args){
Instant start=Instant.now();
//创建一个任务
ForkJoinTask<Long> forkJoinTask=new ForkJoinCalculator(0,100000000);
//创建一个线程池
ForkJoinPool forkJoinPool=new ForkJoinPool();
// 也可以使用公用的线程池 ForkJoinPool.commonPool():
// pool = ForkJoinPool.commonPool()
//提交任务到线程池中去
Long result= forkJoinPool.invoke(forkJoinTask);
Instant end=Instant.now();
System.out.println("计算结果:"+result);
System.out.println("消耗时间:"+Duration.between(start,end).toMillis());
}
打印结果:
计算结果:5000000050000000
消耗时间:215
3.1 ForkJointTask中的fork()方法
调用ForkJoinTask的fork方法时,程序会把任务放在ForkJoinWorkerThreadTask,push到workQueue中,异步地执行这个任务,然后立即返回结果
public final ForkJoinTask<V> fork() {
Thread t;
//如果当前线程是工作线程,就把当前ForkJoinTask加入到工作线程维护的工作队列中
if ((t = Thread.currentThread()) instanceof ForkJoinWorkerThread)
((ForkJoinWorkerThread)t).workQueue.push(this);
else
//否则就重新提交任务
ForkJoinPool.common.externalPush(this);
return this;
}
push方法把当前任务存放在ForkJoinTask数组队列里。然后再调用ForkJoinPool的signalWork()方法唤醒或创建一个工作线程来执行任务。代码如下:
final void push(ForkJoinTask<?> task) {
ForkJoinTask<?>[] a; ForkJoinPool p;
int b = base, s = top, n;
if ((a = array) != null) { // ignore if queue removed
int m = a.length - 1; // fenced write for task visibility
U.putOrderedObject(a, ((m & s) << ASHIFT) + ABASE, task);
U.putOrderedInt(this, QTOP, s + 1);
if ((n = s - b) <= 1) {
if ((p = pool) != null)
p.signalWork(p.workQueues, this);
}
else if (n >= m)
growArray();
}
}
3.1 ForkJointTask中的join()方法
Join方法的主要作用是阻塞当前线程并等待获取结果
public final V join() {
int s;
if ((s = doJoin() & DONE_MASK) != NORMAL)
reportException(s);
return getRawResult();
}
它首先调用doJoin方法,通过doJoin()方法得到当前任务的状态来判断返回什么结果,任务状态有4种:
- 已完成(NORMAL):如果任务状态是已完成,则直接返回任务结果。
- 被取消(CANCELLED):如果任务状态是被取消,则直接抛出CancellationException
- 信号(SIGNAL)
- 出现异常(EXCEPTIONAL):如果任务状态是抛出异常,则直接抛出对应的异常
doJoin()方法:
private int doJoin() {
int s; Thread t; ForkJoinWorkerThread wt; ForkJoinPool.WorkQueue w;
return (s = status) < 0 ? s :
((t = Thread.currentThread()) instanceof ForkJoinWorkerThread) ?
(w = (wt = (ForkJoinWorkerThread)t).workQueue).
tryUnpush(this) && (s = doExec()) < 0 ? s :
wt.pool.awaitJoin(w, this, 0L) :
externalAwaitDone();
}
final int doExec() {
int s; boolean completed;
if ((s = status) >= 0) {
try {
completed = exec();
} catch (Throwable rex) {
return setExceptionalCompletion(rex);
}
if (completed)
s = setCompletion(NORMAL);
}
return s;
}
在doJoin()方法里,首先通过查看任务的状态,看任务是否已经执行完成,如果执行完成,则直接返回任务状态;如果没有执行完,则从任务数组里取出任务并执行。如果任务顺利执行完成,则设置任务状态为NORMAL,如果出现异常,则记录异常,并将任务状态设置为EXCEPTIONAL。
3.3 ForkJoinWorkerThread 工作线程
ForkJoinWorkerThread 类中有两个重要的属性, ForkJoinPool pool和ForkJoinPool.WorkQueue workQueue,可以知道当前工作线程输入那个线程池以及当前工作线程维护的任务队列。
final ForkJoinPool pool; // the pool this thread works in
final ForkJoinPool.WorkQueue workQueue; // work-stealing mechanics
四.其他
1.使用Fork/join框架处理一些大型任务应该注意些什么?
- 使用尽可能少的线程池 - 在大多数情况下,最好的决定是为每个应用程序或系统使用一个线程池
- 如果不需要特定调整**,请使用默认的公共线程池**
- 使用合理的阈值将ForkJoingTask拆分为子任务,过多的拆分任务也是一种性能消耗
- 避免在 ForkJoingTasks中出现 任何阻塞
2.使用ThreadPoolExecutor和ForkJoinPool的性能差异 ?
- ForkJoinPool能够实现工作窃取(Work Stealing),在该线程池的每个线程中会维护一个队列来存放需要被执行的任务。当线程自身队列中的任务都执行完毕后,它会从别的线程中拿到未被执行的任务并帮助它执行。因此,提高了线程的利用率,从而提高了整体性能。
- 对于ForkJoinPool,还有一个因素会影响它的性能,就是停止进行任务分割的那个阈值。比如在之前的快速排序中,当剩下的元素数量小于10的时候,就会停止子任务的创建。