- ForkJoin属于高并发设计模式之一
- 其中高并发设计模式:线程安全的单例实现、Master-Worker模式、ForkJoin模式、生产者和消费者模式、Future模式
1. 定义
是一种分而治之的思想,把大任务进行拆解,直到任务的规模足够小,可以使用简单、直接的方式进行 。
2. 原理
ForkJoin模式先是把 大任务分解成多个独立的子任务,然后开启多个线程并行去处理这些子任务,指导拆到边界值为止。
ForkJoin模式借助多核的优势处理数据,通常情况下,ForkJoin模式分解出来的子任务放入双端队列中,然后几个启动线程从双端队列中获取任务执行。子任务执行的结果放到一个队列中,各个线程从队列中获取数据,然后进行局部数据的合并,并得到最终结果。
3. ForkJoin框架
JUC是以ForkJoinPool线程池的形式提供,并且该线程池在Java8中的Lambada并行流框架中充当着底层框架的角色,JUC包的ForkJoin框架包含着如下组件:
- ForkJoinPool:执行任务的线程池,继承了AbstractExecutorService类。
- ForkJoinWorkerThread:执行任务的工作线程(ForkJoinPool线程池中的线程)。每个线程都维护着一个内部队列,用于存放“内部任务”该类继承了Thread类。
- ForkJoinTask:用于ForkJoinPool的任务抽象类,实现了Future接口。
- RecursiveTask:带返回结果的递归执行任务,是ForkJoinTask的子类,在子任务带返回结果时使用。
ForkJoinTask因其复杂、抽象方法多,一般不会直接继承ForkJoinTask来实现自定义的任务类
而是通过继承其的两个子类RecursiveTask或者RecursiveAction之一去实现自定义任务类,自定义类需要实现这些子类的compute方法,该方法的一般流程如下:
4. ForkJoin框架使用实战
假设需要计算0~100的累加求和,可以使用ForkJoin框架完成。首先需要设计一个可以递归执行的异步任务子类
- 可递归执行的异步任务类AccumulateTask
package com.crazymakercircle.designmodel.forkjoin;
// 省略import
public class AccumulateTask extends RecursiveTask<Integer>
{
private static final int THRESHOLD = 2;
//累加的起始编号
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 - start) <= THRESHOLD;
//若任务已经足够小,则可以直接计算
if (canCompute)
{
//直接计算并返回结果,Recursive结束
for (int i = start; i <= end; i++)
{
sum += i;
}
Print.tcfo("执行任务,计算" + start + "到" +
end + "的和,结果是:" + sum);
} else
{
//任务过大,需要切割,Recursive 递归计算
Print.tcfo("切割任务:将" + start + "到" + end + "的和一分为二");
int middle = (start + end) / 2;
//切割成两个子任务
AccumulateTask lTask = new AccumulateTask(start, middle);
AccumulateTask rTask = new AccumulateTask(middle + 1, end);
//依次调用每个子任务的fork()方法执行子任务
lTask.fork();
rTask.fork();
//等待子任务完成,依次调用每个子任务的join()方法合并执行结果
int leftResult = lTask.join();
int rightResult = rTask.join();
//合并子任务执行结果
sum = leftResult + rightResult;
}
return sum;
}
}
任务进行了分解,就需要等待所有的子任务执行完毕、然后对各个分解结果求和。如果一个任务分解为多个子任务(含两个),就依次调用每个子任务的fork()方法执行子任务,然后依次调用每个子任务的join()方法合并执行结果。
- 使用ForkJoinPool调度AccumulateTask()
package com.crazymakercircle.designmodel.forkjoin;
// 省略import
public class ForkJoinTest
{
@org.junit.Test
public void testAccumulateTask()
{
ForkJoinPool forkJoinPool = new ForkJoinPool();
//创建一个累加任务,计算由1加到10
AccumulateTask countTask = new AccumulateTask(1, 100);
Future<Integer> future = forkJoinPool.submit(countTask);
Integer sum = future.get(1, TimeUnit.SECONDS);
Print.tcfo("最终的计算结果:" + sum);
//预期的结果为5050
Assert.assertTrue(sum == 5050);
}
执行以上用例,部分结果如下:
[ForkJoinPool-1-worker-1]:切割任务:将1到100的和一分为二
[ForkJoinPool-1-worker-3]:切割任务:将51到100的和一分为二
[ForkJoinPool-1-worker-2]:切割任务:将1到50的和一分为二
[ForkJoinPool-1-worker-1]:切割任务:将1到25的和一分为二
[ForkJoinPool-1-worker-1]:切割任务:将1到13的和一分为二
[ForkJoinPool-1-worker-1]:执行任务,计算1到7的和,结果是:28
[ForkJoinPool-1-worker-5]:切割任务:将14到25的和一分为二
[ForkJoinPool-1-worker-3]:切割任务:将51到75的和一分为二
...
[ForkJoinPool-1-worker-6]:切割任务:将76到88的和一分为二
[ForkJoinPool-1-worker-2]:切割任务:将89到100的和一分为二
[ForkJoinPool-1-worker-6]:执行任务,计算76到82的和,结果是:553
[ForkJoinPool-1-worker-2]:执行任务,计算89到94的和,结果是:549
[ForkJoinPool-1-worker-5]:执行任务,计算83到88的和,结果是:513
[ForkJoinPool-1-worker-4]:执行任务,计算95到100的和,结果是:585
[main|ForkJoinTest.testAccumulateTask]:最终的计算结果:5050
5. ForkJoin框架 的核心API
ForkJoin框架的核心是ForkJoinPool线程池。 该线程池使用一个无锁的栈来管理线程。
如果一个工作线程暂时取不到可用的任务,则可能被挂起 ,而挂起的线程被压入有ForkJoin维护的栈中,待有新任务到来时,再从栈中唤醒这些线程。
5.1 ForkJoinPool构造器
public ForkJoinPool(int parallelism,//并行度,默认为CPU数,最小为1
ForkJoinWorkerThreadFactory factory, //线程创建工厂
UncaughtExceptionHandler handler, //异常处理程序
boolean asyncMode) //是否为异步模式
{
this(checkParallelism(parallelism),
checkFactory(factory),
handler,
asyncMode ? FIFO_QUEUE : LIFO_QUEUE,
"ForkJoinPool-" + nextPoolId() + "-worker-");
checkPermission();
}
(1)parallelism:
可并行级别ForkJoin框架将依据parallelism设定的级别决定框架内并行执行的线程数量。
并行的每一个任务都会有一个线程进行处理,但parallelism属性并不是ForkJoin框架中最大的线程数量,该属性和ThreadPoolExecutor线程池中的corePoolSize、maximumPoolSize属性有区别,因为ForkJoinPool的结构和工作方式与ThreadPoolExecutor完全不一样。
ForkJoin框架中可存在的线程数量和parallelism参数值并不是绝对关联的。
(2)factory:
线程创建工厂当ForkJoin框架创建一个新的线程时,同样会用到线程创建工厂。
只不过这个线程工厂不再需要实现ThreadFactory接口,而是需要实现ForkJoinWorkerThreadFactory接口。
后者是一个函数式接口,只需要实现一个名叫newThread()的方法。
在ForkJoin框架中有一个默认的ForkJoinWorkerThreadFactory接口实现DefaultForkJoinWorkerThreadFactory。
(3)handler:
异常捕获处理程序当执行的任务中出现异常,并从任务中被抛出时,就会被handler捕获。
(4)asyncMode:异步模式
asyncMode参数表示任务是否为异步模式,其默认值为false。
如果asyncMode为true,就表示子任务的执行遵循FIFO(先进先出)顺序,并且子任务不能被合并;如果asyncMode为false,就表示子任务的执行遵循LIFO(后进先出)顺序,并且子任务可以被合并。
虽然从字面意思来看asyncMode是指异步模式,它并不是指ForkJoin框架的调度模式采用是同步模式还是异步模式工作,仅仅指任务的调度方式。
ForkJoin框架中为每一个独立工作的线程准备了对应的待执行任务队列,这个任务队列是使用数组进行组合的双向队列。
asyncMode模式的主要意思指的是待执行任务可以使用FIFO(先进先出)的工作模式,也可以使用LIFO(后进先出)的工作模式,工作模式为FIFO(先进先出)的任务适用于工作线程只负责运行异步事件,不需要合并结果的异步任务。
ForkJoinPool无参数的、默认的构造器如下:
static final int MAX_CAP = 0x7fff; //并行度常量 32767
public ForkJoinPool() {
this(Math.min(MAX_CAP, Runtime.getRuntime().availableProcessors()),
defaultForkJoinWorkerThreadFactory, null, false);
}
该构造器的parallelism值为CPU核数;
factory值为defaultForkJoinWorkerThreadFactory默认的线程工厂;
异常捕获处理程序handler值为null,表示不进行异常处理;
异步模式asyncMode值为false,使用LIFO(后进先出)的、可以合并子任务的模式。
5.2 ForkJoinPool的common通用池
很多场景可以直接使用ForkJoinPool定义的common通用池,调用ForkJoinPool.commonPool()方法可以获取该ForkJoin线程池,该线程池通过makeCommonPool()来构造,具体的代码如下
private static ForkJoinPool makeCommonPool() {
int parallelism = -1;
ForkJoinWorkerThreadFactory factory = null;
UncaughtExceptionHandler handler = null;
try {
//并行度
String pp = System.getProperty(
"java.util.concurrent.ForkJoinPool.common.parallelism");
//线程工厂
String fp = System.getProperty(
"java.util.concurrent.ForkJoinPool.common.threadFactory");
//异常处理类
String hp = System.getProperty(
"java.util.concurrent.ForkJoinPool.common.exceptionHandler");
if (pp != null)parallelism = Integer.parseInt(pp);
if (fp != null) factory = ((ForkJoinWorkerThreadFactory)
ClassLoader.getSystemClassLoader().loadClass(fp).newInstance());
if (hp != null)handler = ((UncaughtExceptionHandler)
ClassLoader.getSystemClassLoader().loadClass(hp).newInstance());
} catch (Exception ignore) {
}
if (factory == null) {
if (System.getSecurityManager() == null)
factory = defaultForkJoinWorkerThreadFactory;
else // use security-managed default
factory = new InnocuousForkJoinWorkerThreadFactory();
}
//默认并行度为cores-1
if (parallelism < 0 &&
(parallelism = Runtime.getRuntime().availableProcessors()-1) <= 0)
parallelism = 1;
if (parallelism > MAX_CAP) parallelism = MAX_CAP;
return new ForkJoinPool(parallelism, factory, handler, LIFO_QUEUE,
"ForkJoinPool.commonPool-worker-");
}
使用common池的优点是可以通过指定系统属性的方式定义“并行度、线程工厂和异常处理类”,并且common池使用的是同步模式,也就是说可以支持任务合并。
通过系统属性的方式指定parallelism值的示例如下:
System.setProperty("java.util.concurrent.ForkJoinPool.common.parallelism", "8");
除此之外,还可以通过Java指令选项的方式指定parallelism值,具体的选项为:
-Djava.util.concurrent.ForkJoinPool.common.parallelism=8
可以向ForkJoinPool线程池提交以下两类任务:
(1)外部任务(External/Submissions Task)提交
向ForkJoinPool提交外部任务有三种方式:
方式一是调用invoke()方法,该方法提交任务后线程会等待,等到任务计算完毕返回结果;
方式二是调用execute()方法提交一个任务来异步执行,无返回结果;
方式三是调用submit()方法提交一个任务,并且会返回一个ForkJoinTask实例,之后适当的时候可通过ForkJoinTask实例获取执行结果。
(2)子任务(Worker Task)提交
向ForkJoinPool提交子任务的方法相对比较简单,由任务实例的fork()方法完成。
当任务被分割之后,内部会调用ForkJoinPool.WorkQueue.push()方法直接把任务放到内部队列中等待被执行。
6. 工作窃取算法
ForkJoinPool线程池的任务分为“外部任务”和“内部任务”,两种任务的存放位置不同:
(1)外部任务存放在ForkJoinPool的全局队列中。
(2)子任务会作为“内部任务”放到内部队列中,ForkJoinPool池中的每个线程都维护着一个内部队列,用于存放这些“内部任务”。
由于ForkJoinPool线程池通常有多个工作线程,与之相对应的就会有多个任务队列,这就会出现任务分配不均衡的问题:有的队列任务多,忙得不停,有的队列没有任务,一直空闲。那么有没有一种机制帮忙将任务从繁忙的线程分摊给空闲的线程呢?答案是使用工作窃取算法。
工作窃取算法的核心思想是:工作线程自己的活干完了之后,会去看看别人有没有没干完的活,如果有就拿过来帮忙干。
工作窃取算法的主要逻辑:每个线程拥有一个双端队列(本地队列),用于存放需要执行的任务,当自己的队列没有任务时,可以从其他线程的任务队列中获得一个任务继续执行,如图8-6所示。
但在执行过程中会遇到线程安全的问题,假如在窃取过程中该任务已经执行,那么窃取操作就会失败。
如何尽量避免在任务窃取中发生的线程安全问题呢?
一种简单的优化方法是:在线程自己的本地队列采取LIFO(后进先出)策略,窃取其他任务队列的任务时采用FIFO(先进先出)策略。
简单来说,获取自己队列的任务时从头开始,窃取其他队列的任务时从尾开始。由于窃取的动作十分快速,会大量降低这种冲突,也是一种优化方式,如图8-7所示。
7. ForkJoin框架原理
ForkJoin框架的核心原理大致如下:
(1)ForkJoin框架的线程池ForkJoinPool的任务分为“外部任务”和“内部任务”。
(2)“外部任务”放在ForkJoinPool的全局队列中。
(3)ForkJoinPool池中的每个线程都维护着一个任务队列,用于存放“内部任务”,线程切割任务得到的子任务会作为“内部任务”放到内部队列中。
(4)当工作线程想要拿到子任务的计算结果时,先判断子任务有没有完成,如果没有完成,再判断子任务有没有被其他线程“窃取”,如果子任务没有被窃取,就由本线程来完成;一旦子任务被窃取了,就去执行本线程“内部队列”的其他任务,或者扫描其他的任务队列并窃取任务。
(5)当工作线程完成其“内部任务”,处于空闲状态时,就会扫描其他的任务队列窃取任务,尽可能不会阻塞等待。
总之,ForkJoin线程在等待一个任务完成时,要么自己来完成这个任务,要么在其他线程窃取了这个任务的情况下,去执行其他任务,是不会阻塞等待的,从而避免资源浪费,除非所有任务队列都为空。
工作窃取算法的优点如下:
(1)线程是不会因为等待某个子任务的执行或者没有内部任务要执行而被阻塞等待、挂起的,而是会扫描所有的队列窃取任务,直到所有队列都为空时才会被挂起。
(2)ForkJoin框架为每个线程维护着一个内部任务队列以及一个全局的任务队列,而且任务队列都是双向队列,可从首尾两端来获取任务,极大地减少了竞争的可能性,提高并行的性能。
ForkJoinPool适合需要“分而治之”的场景,特别是分治之后递归调用的函数,例如快速排序、二分搜索、大整
数乘法、矩阵乘法、棋盘覆盖、归并排序、线性时间选择、汉诺塔问题等。
ForkJoinPool适合调度的任务为CPU密集型任务,如果任务存在I/O操作、线程同步操作、sleep()睡眠等较长时间阻塞的情况,最好配合使用ManagedBlocker进行阻塞管理。
总体来说,ForkJoinPool不适合进行IO密集型、混合型的任务调度。
来源:书籍《Java高并发核心编程.卷2》