1. ForkJoin框架概述
ForkJoin模式先把一个大任务分解成许多个独立的子任务,然后开启多个线程并行去处理这些子任务。有可能子任务还是很大而需要进一步分解,最终得到足够小的任务。ForkJoin模式的任务分解和执行过程大致如下图所示。
ForkJoin模式借助于现代计算机多核的优势并行处理数据。通常情况下,ForkJoin模式将分解出来的子任务放入双端队列中,然后几个启动线程从双端队列中获取任务并执行。子任务执行的结果放到一个队列中,各个线程从队列中获取数据,然后进行局部结果的合并,得到最终结果。
2. ForkJoin框架
JUC包提供了一套ForkJoin框架的实现。具体以ForkJoinPool线程池的形式提供,并且该线程池在Java 8的Lambda并行流框架中充当着底层框架的角色。JUC包的ForkJoin框架包含如下组件:
- ForkJoinPool:执行任务的线程池,继承了AbstractExecutorService类
- ForkJoinWorkerThread:执行任务的工作线程(ForkJoinPool线程池中的线程),每个线程都维护者一个内部队列,用于存放“内部任务”该类继承了Thread类
- ForkJoinTask:用于ForkJoinPool的任务抽象类,实现了Future接口
- WorkQueue:在ForkJoin框架里面,有一个WorkQueue[]
分为两种:1.有工作线程绑定的任务队列:通常在WorkQueue数组的奇数位索引上,里面的任务,由工作线程产生,比如:工作线程执行过程中,fork出的新任务;2.没有工作线程绑定的任务队列:通常在WorkQueue数组的偶数位索引上,这些队列里的任务,通常都是由其它线程提交的 - RecurisveTask:带返回结果的递归执行任务,是ForkJoinTask的子类,在子任务带返回结果时使用
- RecurisveAction:不返回结果的递归执行任务,是ForkJoinTask的子类,在子任务不带返回结果时使用
因为ForkJoinTask比较复杂,并且其抽象方法比较多,故在日常使用时一般不会直接继承ForkJoinTask来实现自定义的任务类,而是通过继承ForkJoinTask两个子类RecurisveTask或者RecurisveAction之一去实现自定义任务类,自定义任务类需要实现这些子类的compute()方法,该方法的执行流程一般如下:
if 任务足够小
直接返回结果
else
分割成n个子任务
依次调用每个子任务的fork方法执行子任务
依次调用每个子任务的join方法,等待子任务完成,然后合并执行结果
3. ForkJoin框架使用实践
假设需要计算0~10000的累加求和,可以使用ForkJoin框架完成,首先需要设计一个可以递归执行的异步任务子类。
3.1 可递归执行的异步任务类AccumulateTask
public class AccumulateTask extends RecursiveTask<Integer> {
private static final int THRESHOLD = 100;
// 累加的起始编号
private int start;
// 累加的结束编号
private int end;
public AccumulateTask(int start, int end) {
this.start = start;
this.end = end;
}
@Override
protected Integer compute() {
int sum = 0;
// 判断任务的规模:若规模小则可以直接计算
boolean canCompute = (end - sum) <= THRESHOLD;
// 若任务足够小,则可以直接计算
if (canCompute) {
for (int i = start; i <= end; i++) {
sum += i;
}
System.out.println(Thread.currentThread().getName() + ":执行任务,计算 " + start + " 到 " + end + " 的和,结果是:" + sum);
} else {
// 任务过大,需要切割,Recursive递归计算
System.out.println(Thread.currentThread().getName() + ":切割任务:将 " + start + " 到 " + end + " 的和一分为二");
int middle = (start + end) / 2;
// 切割成两个任务
AccumulateTask task1 = new AccumulateTask(start, middle);
AccumulateTask task2 = new AccumulateTask(middle + 1, end);
// 依次调用每个子任务的fork()方法执行子任务
task1.fork();
task2.fork();
// 依次调用每个子任务的join()方法合并执行结果
Integer leftResult = task1.join();
Integer rightResult = task2.join();
// 合并子任务执行结果
sum = leftResult + rightResult;
}
return sum;
}
}
自定义的异步任务子类AccumulateTask继承自RecursiveTask,每一次执行可以携带返回值。AccumulateTask通过THRESHOLD常量设置子任务分割的阈值。并在它compute()方法中进行阈值判断,判断的逻辑如下:
- 若当前的计算规模(这里为求和的数字个数)大于THRESHOLD,则当前子任务需要进一步分解,若当前的计算规模没有大于THRESHOLD,则直接计算(这里为求和)
- 如果子任务可以直接执行,就进行求和操作,并返回结果。如果任务进行了分解,就需要等待所以的子任务执行完毕,然后对各个分割结果求和。如果一个任务分解为多个子任务(含两个),就依次调用每个子任务的fork()方法执行子任务,然后依次调用每个子任务的join()方法合并执行结果
3.2 使用ForkJoinPool调度AccumulateTask()
使用ForkJoinPool调度AccumulateTask()的示例代码如下:
public class ForkJoinTest {
public static void main(String[] args) {
ForkJoinPool forkJoinPool = new ForkJoinPool(4);
// 创建一个累加任务,计算由1到1000的和
AccumulateTask accumulateTask = new AccumulateTask(1, 1000);
ForkJoinTask<Integer> future = forkJoinPool.submit(accumulateTask);
Integer sum = null;
try {
sum = future.get();
} catch (InterruptedException e) {
e.printStackTrace();
} catch (ExecutionException e) {
e.printStackTrace();
} finally {
forkJoinPool.shutdown();
}
System.out.println(Thread.currentThread().getName() + ":最终的计算结果:" + sum);
}
}
执行以上代码,部分结果如下:
ForkJoinPool-1-worker-3:切割任务:将 1 到 1000 的和一分为二
ForkJoinPool-1-worker-3:切割任务:将 1 到 500 的和一分为二
ForkJoinPool-1-worker-5:切割任务:将 501 到 1000 的和一分为二
ForkJoinPool-1-worker-7:切割任务:将 1 到 250 的和一分为二
ForkJoinPool-1-worker-5:切割任务:将 501 到 750 的和一分为二
ForkJoinPool-1-worker-7:切割任务:将 1 到 125 的和一分为二
ForkJoinPool-1-worker-5:切割任务:将 501 到 625 的和一分为二
ForkJoinPool-1-worker-1:切割任务:将 126 到 250 的和一分为二
ForkJoinPool-1-worker-5:执行任务,计算 501 到 563 的和,结果是:33516
ForkJoinPool-1-worker-1:执行任务,计算 126 到 188 的和,结果是:9891
ForkJoinPool-1-worker-7:执行任务,计算 1 到 63 的和,结果是:2016
ForkJoinPool-1-worker-5:执行任务,计算 564 到 625 的和,结果是:36859
ForkJoinPool-1-worker-1:执行任务,计算 189 到 250 的和,结果是:13609
ForkJoinPool-1-worker-7:执行任务,计算 64 到 125 的和,结果是:5859
ForkJoinPool-1-worker-5:切割任务:将 626 到 750 的和一分为二
ForkJoinPool-1-worker-7:切割任务:将 751 到 1000 的和一分为二
ForkJoinPool-1-worker-5:执行任务,计算 626 到 688 的和,结果是:41391
ForkJoinPool-1-worker-1:切割任务:将 251 到 500 的和一分为二
ForkJoinPool-1-worker-5:执行任务,计算 689 到 750 的和,结果是:44609
ForkJoinPool-1-worker-7:切割任务:将 751 到 875 的和一分为二
ForkJoinPool-1-worker-5:切割任务:将 876 到 1000 的和一分为二
ForkJoinPool-1-worker-7:执行任务,计算 751 到 813 的和,结果是:49266
ForkJoinPool-1-worker-5:执行任务,计算 876 到 938 的和,结果是:57141
ForkJoinPool-1-worker-1:切割任务:将 251 到 375 的和一分为二
ForkJoinPool-1-worker-5:执行任务,计算 939 到 1000 的和,结果是:60109
ForkJoinPool-1-worker-7:执行任务,计算 814 到 875 的和,结果是:52359
ForkJoinPool-1-worker-1:执行任务,计算 251 到 313 的和,结果是:17766
ForkJoinPool-1-worker-5:执行任务,计算 314 到 375 的和,结果是:21359
ForkJoinPool-1-worker-7:切割任务:将 376 到 500 的和一分为二
ForkJoinPool-1-worker-7:执行任务,计算 376 到 438 的和,结果是:25641
ForkJoinPool-1-worker-5:执行任务,计算 439 到 500 的和,结果是:29109
main:最终的计算结果:500500
Process finished with exit code 0
4. ForkJoin框架的核心API
ForkJoin框架的核心是ForkJoinPool线程池。该线程池使用一个无锁的栈来管理空闲线程,如果一个工作线程暂时取不到可用的任务,则可能被挂起,而挂起的线程将被压入由ForkJoinPool维护的栈中,将有新任务到来时,再从栈中唤醒这些线程。
4.1 ForkJoinPool的构造器
public ForkJoinPool(int parallelism, // 并行度,默认为CPU核心数,最小为1
ForkJoinWorkerThreadFactory factory, // 线程创建工厂
UncaughtExceptionHandler handler, // 异常处理程序
boolean asyncMode) // 是否为异步模式
{
this(parallelism, factory, handler, asyncMode,
0, MAX_CAP, 1, null, DEFAULT_KEEPALIVE, TimeUnit.MILLISECONDS);
}
对以上构造器的4个参数具体介绍如下:
- parallelism:可并行级别
ForkJoin框架将依赖 parallelism 设定的级别决定框架内并行执行的线程数量。并行的每一个任务都会有一个线程进行处理。但 parallelism 属性并不是ForkJoin框架中最大的线程数量。该属性和ThreadPoolExecutor线程池中的corePoolSize、maxmumPoolSize属性有区别,因为ForkJoinPool的结构和工作方式与ThreadPoolExecutor完全不一样。ForkJoin框架中可存在的线程数量和 parallelism 参数值并不是绝对关联的 - factory:线程创建工厂
当ForkJoin框架创建一个新的线程时,同样会用到线程创建工厂。只不过这个线程工厂不再需要实现ThreadFactory接口,而是需要实现ForkJoinWorkerThreadFactory接口。后者是一个函数式接口,只需要实现一个名叫newThread()的方法。在ForkJoin()框架中有一个默认的ForkJoinWorkerThreadFactory接口实现DefaultForkJoinWorkerThreadFactory - handler:异常 捕获处理程序
当执行的任务中出现异常,并从任务中被抛出时,就会被handler捕获 - asyncMode:异步模式
asyncMode参数表示任务是否为异步模式,其默认值为false。如果asyncMode为true,就表示子任务的执行遵循FIFO(先进先出)顺序,并且子任务不能被合并;如果asyncMode为false,就表示子任务的执行遵循LIFO(后进先出)顺序,并且子任务可以被合并。虽然从字面意思来看asyncMode是指异步模式,它并不是指ForkJoin框架的调度模式采用是同步模式还是异步模式工作,仅仅指任务的调度方式。ForkJoin框架中为每一个独立工作的线程准备了对应的待执行任务队列,这个任务队列是使用数组进行组合的双向队列。asyncMode模式的主要意思指的是待执行任务可以使用FIFO(先进先出)的工作模式,也可以使用LIFO(后进先出)的工作模式,工作模式为FIFO(先进先出)的任务适用于工作线程只负责运行异步任务,不需要合并结果的异步任务。
ForkJoinPool无参数的,默认构造器如下:
public ForkJoinPool() {
this(Math.min(MAX_CAP, Runtime.getRuntime().availableProcessors()),
defaultForkJoinWorkerThreadFactory, null, false,
0, MAX_CAP, 1, null, DEFAULT_KEEPALIVE, TimeUnit.MILLISECONDS);
}
该构造器的parallelism值为CPU核数;factory为defaultForkJoinWorkerThreadFactory默认的线程工厂;异常捕获处理程序handler为null,表示不进行异常处理;异步模式asyncMode值为false,使用LIFO(后进先出)的,可以合并子任务的模式。
4.2 向ForkJoinPool綫程池提交任务的方式
可以向ForkJoinPool线程池提交以下两类任务:
- 外部任务(External/Submissions Task)提交
向ForkJoinPool提交外部任务有三种方式:方式一是调用invoke()方法,该方法提交任务后线程会等待,等待任务计算完成返回结果;方式二是调用execute()方法提交一个任务来异步执行,无返回结果;方式三是调用submit()方法提交一个任务,并且会返回一个ForkJoinTask实现,之后适当的时候可以通过ForkJoinTask实例获取执行结果。 - 子任务(Worker Task)提交
向ForkJoinPool提交子任务的方法相对比较简单,由任务实例的fork()方法完成。当任务被分割之后,内部会调用ForkJoinPool.WorkQueue.push()方法直接把任务放到内部队列中等待被执行。
4.3 工作窃取算法
ForkJoinPool线程池的任务分为“外部任务”和“内部任务”,两种任务的存放为止不同:
- 外部任务存放在ForkJoinPool的全局队列中
- 子任务会作为“内部任务”放到内部队列中,ForkJoinPool线程池中的每个线程都维护着一个内部队列,用于存放这些“内部任务”
由于ForkJoinPool线程池通常有多个工作线程,与之相对应的就会有多个任务队列,这就会出现任务分配不均衡的问题:有的队列任务多、有的队列没有任务,一直空闲。那么有没有一种机制帮忙将任务从繁忙的线程分摊给空闲的线程呢?答案是使用工作窃取算法。
工作窃取算法的核心思想是:工作线程自己的活干完之后,会去看别人有没有没干完的活,如果有就拿过来帮忙干。工作窃取算法的主要逻辑:每个线程拥有一个双端队列(本地队列),用于存放需要执行的任务,当自己的队列没有任务时,可以从其他线程的任务队列中获得一个任务继续执行。
在实际进行任务窃取操作的时候,操作线程会进行其他线程的任务队列的扫描和任务出队尝试。为什么说是尝试?因为完全有可能操作失败,主要原因是并行执行肯定设计线程安全的问题,假如在窃取过程中该任务已经开始执行,那么任务的窃取操作就会失败。
如何尽可能避免在任务窃取中发生的线程安全问题?一种简单的优化方法是:在线程自己的本地队列采用LIFO(后进先出)策略,窃取其他任务队列的任务时采用FIFO(先进先出)策略。简单来说,获取自己队列的任务时从头开始,窃取其他队列的任务时从尾开始。由于窃取的动作十分快速,会大量降低这种冲突。也是一种优化方式。
4.4 ForkJoin框架原理
ForkJoin框架的核心原理大致如下:
- ForkJoin框架的线程池ForkJoinPool的任务分为“外部任务”和“内部任务”
- “外部任务”放在ForkJoinPool的全局队列中
- ForkJoinPool池中的每个线程都维护着一个任务队列,用于存放“内部任务”,线程切割任务得到的子任务会作为“内部任务”放到内部队列中
- 当工作线程想要拿到子任务的计算结果时,先判断子任务有没有完成,如果没有完成,再判断子任务有没有被其他线程“窃取”,如果子任务没有被窃取,就由本线程来完成;一旦子任务被窃取了,就去执行本线程“内部队列”的其他任务,或者扫描其他的任务队列并窃取任务
- 当工作线程完成其“内部任务”,处于空闲状态时,就会扫描其他的任务队列窃取任务,尽可能不会阻塞等待
总之,ForkJoin线程在等待一个任务完成时,要么自己来完成这个任务,要么其他线程窃取这个任务的情况下,去执行其他任务,是不会阻塞等待的。从而避免资源浪费,除非所有任务队列都为空。
工作窃取算法的优点如下:
- 线程是不会因为等待某个子任务的执行或者没有内部任务要执行而被阻塞等待,挂起的,而是会扫描所有的队列窃取任务,直到所有队列都为空时才会被挂起
- ForkJoin框架为每个线程维护着一个内部任务以及一个全局的任务队列,而且任务队列都是双向队列,可从首尾两端来获取任务,极大地减少了竞争的可能性,提高并行的性能。
ForkJoinPool适合需要“分而治之”的场景,特别是分治之后递归调用的函数。例如快速排序、二分搜索、大整数除法、矩阵乘法、棋盘覆盖、归并排序、线性时间选择、汉诺塔问题等。ForkJoinPool适合调度的任务为CPU密集型任务,如果任务存在I/O操作、线程同步操作、sleep()睡眠等较长时间阻塞的情况,最好配置使用ManagedBlocker进行阻塞管理。总体来说,ForkJoinPool不适合进行IO密集型、混合型的任务调用。