前言
Fork/Join框架依赖于分治的思想,在了解Fork/Join之前需要先了解分治的思想和线程池技术(ThreadPoolExecutor)在分治思想下难以解决的问题
一、分治法
字面上的解释是“分而治之”,就是把一个复杂的问题分成两个或更多的相同或相似的子问题,直到最后子问题可以简单的直接求解,原问题的解即子问题的解的合并。
举个例子,现在我们要计算从1累加到10000的值,利用分治法我们会这么解决,将1到10000之间的数字按照【最多计算1000个数的和】这一标准来划分成不同的“小问题”,即在每个组中求和,并将所有组的结果相加。下面的图很好地解释了这一过程:
转换成代码:
@Test
public void test1() {
long num = 10000;
long split = 1000;
long res = calculate(1, num, split);
System.out.println("结果是:" + res);
}
private long calculate(long begin, long end, long split) {
long sum = 0;
if (end - begin > split) {
//如果大于任务分割值,则继续分治
long rs1 = calculate(begin, (begin+end)/2, split);
long rs2 = calculate((begin+end)/2+ 1, end, split);
//解的合并
return rs1 + rs2;
} else {
//如果小于任务分割值,则进行计算
for (long i = begin; i <= end; i++) {
sum += i;
}
return sum;
}
}
二、使用多线程的方式来提高分治法的效率
上面的例子有什么不好呢?
很明显,上面的例子是同步执行的,即先算 【1+…+625】 再算 【626+…+1250】 然后将两个结果相加,接着继续算 【1251+…+1875】 …。
这样就有可以提升的空间,我开启两个线程,一个计算【1+…+625】,一个计算【626+…+1250】,这样更快一些(单核CPU,暂时不考虑线程上下文切换的带来的效率损失),按照这种方式用ThreadPoolExecutor来提升下:
public class Task implements Callable<Long> {
private long begin;
private long end;
private long limit;
private ThreadPoolExecutor threadPoolExecutor;
public Task(long begin, long end, long limit,ThreadPoolExecutor threadPoolExecutor) {
this.begin = begin;
this.end = end;
this.limit = limit;
this.threadPoolExecutor = threadPoolExecutor;
}
@Override
public Long call() throws Exception {
return calculate(begin, end, limit);
}
public long calculate(long begin, long end, long limit) throws ExecutionException, InterruptedException {
long sum = 0;
if (end - begin > limit) {
//分治
Future<Long> res1 = threadPoolExecutor.submit(new Task(begin, (begin+end)/2, limit,threadPoolExecutor));
Future<Long> res2 = threadPoolExecutor.submit(new Task((begin+end)/2 + 1, end, limit,threadPoolExecutor));
long res = 0;
res = res1.get() + res2.get();
return res;
} else {
for (long i = begin; i <= end; i++) {
sum += i;
}
return sum;
}
}
public static void main(String[] args) throws ExecutionException, InterruptedException {
//无界线程池
ThreadPoolExecutor threadPoolExecutor = new ThreadPoolExecutor(0, Integer.MAX_VALUE, 60, TimeUnit.SECONDS, new SynchronousQueue<>(), new ThreadFactory() {
private AtomicLong num = new AtomicLong();
@Override
public Thread newThread(Runnable r) {
return new Thread(r, "thread_test_" + num.getAndIncrement());
}
});
long begin = System.currentTimeMillis();
FutureTask<Long> task = new FutureTask<>(new Task(1, 10000, 1000,threadPoolExecutor));
new Thread(task).start();
//监控线程池线程数量
while (true){
System.out.println("当前线程池线程数量为:"+threadPoolExecutor.getPoolSize());
if(task.isDone()){
break;
}
}
System.out.println("结果是:" + task.get());
long end = System.currentTimeMillis();
System.out.println("cost time:"+(end-begin));
}
}
注意下,里面有这么行代码:
res = res1.get() + res2.get();
这里res1和res2线程会一直阻塞到其子任务线程任务结束才会结束,这样就会有一个比较难解决的问题,就是一旦子任务过多,会导致很多线程由于其子线程没有结束而一直阻塞,从而导致系统资源被大量占用。
以上文中的例子为例,假如在可以直接执行的子任务返回结果之前利用Thread.sleep方法模拟子任务的长时间运行,如下面的代码
for (long i = begin; i <= end; i++) {
sum += i;
}
//模拟子任务长时间运行
Thread.sleep(20000);
return sum;
我们可以发现,线程池中会有30个线程,其中16个线程做的任务是:
for (long i = begin; i <= end; i++) {
sum += i;
}
//模拟子任务长时间运行
Thread.sleep(20000);
return sum;
而其他的线程都在等子线程返回,这样会占用大量系统资源。下面一张图可以很好地解释这种现象:
即所有的任务都在等待其直系子任务全部返回结果。
三、Fork/Join框架如何利用多线程来处理分治代码
3.1、优点
Fork/Join框架的线程池在一个线程正在等待他创建的子线程运行的时候,如果当前线程如果完成了自己的任务后,就会寻找还没有被运行的任务并且运行他们,这样就是和Executors这个方式最大的区别,更加有效的使用了线程的资源和功能
3.2、基本类和接口
- ForkJoinPool 线程池维护类,ForkJoinPool继承了AbstractExecutorService类
- ForkJoinTast 任务抽象类
- RecursiveTask 具体任务类,需要返回结果
- RecursiveAction 具体任务类,无需返回结果
3.2.1、ForkJoinPool
- execute(ForkJoinTask) 执行任务(不阻塞),无法获取任务返回值
- submit(ForkJoinTask) 执行任务(不阻塞),可以获取任务返回值
- invoke(ForkJoinTask) 执行任务(阻塞),可以获取任务返回值
3.2.2、RecursiveTask
- T compute() 完成任务
- V join() 返回计算结果
- ForkJoinTask fork() 在当前任务正在运行的池中异步执行此任务
- invokeAll(ForkJoinTask<?>… tasks) 将多个任务分配给其他线程,但是会留一个线程给自己
3.2.3、例子
还是上面提出的问题,我们来使用Fork/Join的方式解决
package com.lg;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ForkJoinPool;
import java.util.concurrent.ForkJoinTask;
import java.util.concurrent.RecursiveTask;
/**
* @description:
* @author: lige
* @create: 2019-01-12
**/
public class Test3 {
public static void main(String[] args) throws ExecutionException, InterruptedException {
CalculateTask task = new CalculateTask(1, 1000000000, 1000);
ForkJoinPool pool = new ForkJoinPool();
//一直阻塞到任务完成
Long res = pool.invoke(task);
System.out.println("res is " + res);
//不会阻塞
pool.submit(task);
if (task.isDone()) {
System.out.println("res2 is " + task.get());
}
}
}
class CalculateTask extends RecursiveTask<Long> {
private long begin;
private long end;
private long split;
public CalculateTask(long begin, long end, long split) {
this.begin = begin;
this.end = end;
this.split = split;
}
@Override
protected Long compute() {
if (end - begin > split) {
CalculateTask task1 = new CalculateTask(begin, (begin + end) / 2, split);
CalculateTask task2 = new CalculateTask((begin + end) / 2 + 1, end, split);
//这里应该使用invokeAll,使用下面的fork的方式,相当于当前线程把task1分配其他线程,把task2分配给其他线程,但是自己却没有任务执行
invokeAll(task1,task2);
//task1.fork();
//task2.fork();
return (task1.join() + task2.join());
} else {
long res = 0;
for (long i = begin; i <= end; i++) {
res += i;
}
return res;
}
}
}
3.3、使用场景
最佳应用场景:多核、多内存、可以分割计算再合并的计算密集型任务
3.4、一些最佳实践
- 在实际应用时,使用多个ForkJoinPool是没有什么意义的。正是出于这个原因,一般来说来它实例化一次,然后把实例保存在静态字段,使之成为单例,这样就可以在软件中任何部分方便地重用了。
- 对一个任务调用join方法会阻塞调用方,直到该任务做出结果。因此,有必要在两个子任务的计算都开始之后再调用它。否则,你得到的版本会比原始的顺序算法更慢更复杂,因为每个子任务都必须等待另一个子任务完成才能启动。
- 注意,在complete方法中如果需要将多个子任务分配给其他线程,需要调用invokeAll方法,不然当前线程将任务分配完成后,自己却没分配到任务,造成线程利用率低下,具体可以参考【https://www.dutycode.com/fork_join_1.html】这篇文章的解释