本文已参与「新人创作礼」活动,一起开启掘金创作之路。
概念
Fork/Join框架是Java7提供了的一个用于并行执行任务的框架,是一个把大任务分割成若干个小任务,最终汇总每个小任务结果后得到大任务结果的框架,这种开发方法也叫 分治编程。分治编程可以极大地利用CPU资源,提高任务执行的效率,也是目前与多线程有关的前沿技术。
普通线程池实现分治
注意:存在线程阻塞等问题
使用普通的线程池实现
- 我们往一个线程池提交了一个大任务,规定好任务切割的阀值。
- 由池中线程(假设是线程A)执行大任务,发现大任务的大小大于阀值,于是切割成两个子任务,并调用 submit() 提交到线程池,得到返回的子任务的 Future。
- 线程A就调用 返回的 Future 的 get() 方法阻塞等待子任务的执行结果。 池中的其他线程(除线程A外,线程A被阻塞)执行两个子任务,然后判断子任务的大小有没有超过阀值,如果超过,则按照步骤2继续切割,否则,才计算并返回结果。
嘿,好像一切都很美好。真的吗?别忘了, 每一个切割任务的线程(如线程A)都被阻塞了,直到其子任务完成,才能继续往下运行 。如果任务太大了,需要切割多次,那么就会有多个线程被阻塞,性能将会急速下降。更糟糕的是,如果你的线程池的线程数量是有上限的,极可能会造成池中所有线程被阻塞,线程池无法执行任务。
普通的线程池实现例子
来看一个例子,体会一下吧!下面的例子是将 1+2+...+10 的任务 分割成相加的个数不能超过3(即两端的差不能大于2)的多个子任务。
//普通线程池下实现的分治效果测试
public class CommonThreadPoolTest {
//固定大小的线程池,池中线程数量为3
static ExecutorService fixPoolExcutor = Executors.newFixedThreadPool(3);
public static void main(String[] args) throws InterruptedException, ExecutionException {
//计算 1+2+...+10 的结果
CountTaskCallable task = new CountTaskCallable(1,10);
//提交主人翁
Future<Integer> future = fixPoolExcutor.submit(task);
System.out.println("计算的结果:"+future.get());
}
}
class CountTaskCallable implements Callable<Integer> {
//设置阀值为2
private static final int THRESHOLD = 2;
private int start;
private int end;
public CountTaskCallable(int start, int end) {
super();
this.start = start;
this.end = end;
}
@Override
public Integer call() throws Exception {
int sum = 0;
//判断任务的大小是否超过阀值
boolean canCompute = (end - start) <= THRESHOLD;
if (canCompute) {
for (int i = start; i <= end; i++) {
sum += i;
}
} else {
System.out.println("切割的任务:"+start+"加到"+end+" 执行此任务的线程是 "+Thread.currentThread().getName());
int middle = (start + end) / 2;
CountTaskCallable leftTaskCallable = new CountTaskCallable(start, middle);
CountTaskCallable rightTaskCallable = new CountTaskCallable(middle + 1, end);
// 将子任务提交到线程池中
Future<Integer> leftFuture = CommonThreadPoolTest.fixPoolExcutor.submit(leftTaskCallable);
Future<Integer> rightFuture = CommonThreadPoolTest.fixPoolExcutor.submit(rightTaskCallable);
//阻塞等待子任务的执行结果
int leftResult = leftFuture.get();
int rightResult = rightFuture.get();
// 合并子任务的执行结果
sum = leftResult + rightResult;
}
return sum;
}
}
运行结果
切割的任务:1加到10 执行此任务的线程是 pool-1-thread-1
切割的任务:1加到5 执行此任务的线程是 pool-1-thread-2
切割的任务:6加到10 执行此任务的线程是 pool-1-thread-3
池的线程只有三个,当任务分割了三次后,池中的线程也就都被阻塞了,无法再执行任何任务,一直卡着动不了。
Fork-Join 框架使用工作窃取算法
算法描述
- Fork-Join 框架的线程池ForkJoinPool 的任务分为“外部任务” 和 “内部任务”。
- “外部任务”是放在 ForkJoinPool 的全局队列里;
- ForkJoinPool 池中的每个线程都维护着一个内部队列,用于存放“内部任务”。
- 线程切割任务得到的子任务就会作为“内部任务”放到内部队列中。
- 当此线程要想要拿到子任务的计算结果时,先判断子任务没有完成,如果没有完成,则再判断子任务有没有被其他线程“窃取”,一旦子任务被窃取了则去执行本线程“内部队列”的其他任务,或者扫描其他的任务队列,窃取任务,如果子任务没有被窃取,则由本线程来完成。
- 最后,当线程完成了其“内部任务”,处于空闲的状态时,就会去扫描其他的任务队列,窃取任务,尽可能地
总之,ForkJoin线程在等待一个任务的完成时,要么自己来完成这个任务,或者在其他线程窃取了这个任务的情况下,去执行其他任务,是不会阻塞等待,从而避免浪费资源,除非是所有任务队列都为空。
优点:
-
线程是不会因为等待某个子任务的完成或者没有内部任务要执行而被阻塞等待、挂起,而是会扫描所有的队列,窃取任务,直到所有队列都为空时,才会被挂起。 就如上面所说的。
-
Fork-Join 框架在多CPU的环境下,能提供很好的并行性能。在使用普通线程池的情况下,当CPU不再是性能瓶颈时,能并行地运行多个线程,然而却因为要互斥访问一个任务队列而导致性能提高不上去。而 Fork-Join 框架为每个线程为维护着一个内部任务队列,以及一个全局的任务队列,而且任务队列都是双向队列,可从首尾两端来获取任务,极大地减少了竞争的可能性,提高并行的性能。
Fork-Join 框架的使用
介绍
JDK7引入的Fork/Join有三个核心类:
- ForkJoinPool: 执行任务的线程池,继承了 AbstractExecutorService 类。
- ForkJoinWorkerThread: 执行任务的工作线程(即 ForkJoinPool 线程池里的线程)。每个线程都维护着一个内部队列,用于存放“内部任务”。继承了 Thread 类。
- ForkJoinTask: 一个用于ForkJoinPool的任务抽象类。实现了 Future 接口
因为ForkJoinTask比较复杂,抽象方法比较多,日常使用时一般不会继承ForkJoinTask来实现自定义的任务,而是继承ForkJoinTask的两个子类,实现 compute() 方法:
RecursiveTask: 子任务带返回结果时使用
RecursiveAction: 子任务不带返回结果时使用
compute 方法的实现模式一般是:
if 任务足够小
直接返回结果
else
分割成N个子任务
依次调用每个子任务的fork方法执行子任务
依次调用每个子任务的join方法合并执行结果
例子
public class CountTest {
public static void main(String[] args) throws InterruptedException, ExecutionException {
ForkJoinPool forkJoinPool = new ForkJoinPool();
//创建一个计算任务,计算 由1加到12
CountTask countTask = new CountTask(1, 12);
Future<Integer> future = forkJoinPool.submit(countTask);
System.out.println("最终的计算结果:"+future.get());
}
}
class CountTask extends RecursiveTask<Integer>{
private static final int THRESHOLD = 2;
private int start;
private int end;
public CountTask(int start, int end) {
this.start = start;
this.end = end;
}
@Override
protected Integer compute() {
int sum = 0;
boolean canCompute = (end - start) <= THRESHOLD;
if(canCompute){//任务已经足够小,可以直接计算,并返回结果
for(int i = start;i<=end;i++){
sum += i;
}
System.out.println("执行计算任务,计算 "+start+"到 "+end+"的和 ,结果是:"+sum+" 执行此任务的线程:"+Thread.currentThread().getName());
}else{ //任务过大,需要切割
System.out.println("任务过大,切割的任务: "+start+"加到 "+end+"的和 执行此任务的线程:"+Thread.currentThread().getName());
int middle = (start+end)/2;
//切割成两个子任务
CountTask leftTask = new CountTask(start, middle);
CountTask rightTask = new CountTask(middle+1, end);
//执行子任务
leftTask.fork();
rightTask.fork();
//等待子任务的完成,并获取执行结果
int leftResult = leftTask.join();
int rightResult = rightTask.join();
//合并子任务
sum = leftResult+rightResult;
}
return sum;
}
}
运行结果:
任务过大,切割的任务: 1加到 12的和 执行此任务的线程:ForkJoinPool-1-worker-1
任务过大,切割的任务: 7加到 12的和 执行此任务的线程:ForkJoinPool-1-worker-3
任务过大,切割的任务: 1加到 6的和 执行此任务的线程:ForkJoinPool-1-worker-2
执行计算任务,计算 7到 9的和 ,结果是:24 执行此任务的线程:ForkJoinPool-1-worker-3
执行计算任务,计算 1到 3的和 ,结果是:6 执行此任务的线程:ForkJoinPool-1-worker-1
执行计算任务,计算 4到 6的和 ,结果是:15 执行此任务的线程:ForkJoinPool-1-worker-1
执行计算任务,计算 10到 12的和 ,结果是:33 执行此任务的线程:ForkJoinPool-1-worker-3
最终的计算结果:78