1.1 概述
Fork/Join框架是Java7提供了的一个用于并行执行任务的框架。ForkJoinPool是Java中提供了一个线程池,特点是用来执行分治任务。主题思想是将大任务分解为小任务,然后继续将小任务分解,直至能够直接解决为止,然后再依次将任务的结果合并。
1.2 原理详解
ForkJoinPool是自java7开始,jvm提供的一个用于并行执行的任务框架。其主旨是将大任务分成若干小任务,之后再并行对这些小任务进行计算,最终汇总这些任务的结果。得到最终的结果。其广泛用在java8的stream中。
这个描述实际上比较接近于单机版的map-reduce。都是采用了分治算法,将大的任务拆分到可执行的任务,之后并行执行,最终合并结果集。区别就在于ForkJoin机制可能只能在单个jvm上运行,而map-reduce则是在集群上执行。此外,ForkJoinPool采取工作窃取算法,以避免工作线程由于拆分了任务之后的join等待过程。这样处于空闲的工作线程将从其他工作线程的队列中主动去窃取任务来执行。这里涉及到的两个基本知识点是分治法和工作窃取。
1.2.1 分治法
分治法的基本思想是一个规模为N的问题分解为K个规模较小的子问题,这些子问题的相互独立且与原问题的性质相同,求出子问题的解之后,将这些解合并,就可以得到原有问题的解。是一种分目标完成的程序算法。简单的问题,可以用二分法来完成。
二分法,就是我们之前在检索的时候经常用到的Binary Search 。这样可以迅速将时间复杂度从O(n)降低到O(log n)。那么对应到ForkJoinPool对问题的处理也如此。基本原理如下图:
这只是一个简化版本的Fork-Join,实际上我们在日常工作中的应用可能比这要复杂很多。但是基本原理类似。这样就将一个大的任务,通过fork方法不断拆解,直到能够计算为止,之后,再将这些结果用join合并。这样逐次递归,就得到了我们想要的结果。这就是再ForkJoinPool中的分治法。
1.2.2 工作窃取
工作窃取是指当某个线程的任务队列中没有可执行任务的时候,从其他线程的任务队列中窃取任务来执行,以充分利用工作线程的计算能力,减少线程由于获取不到任务而造成的空闲浪费。在ForkJoinpool中,工作任务的队列都采用双端队列Deque容器。我们知道,在通常使用队列的过程中,我们都在队尾插入,而在队头消费以实现FIFO。而为了实现工作窃取。一般我们会改成工作线程在工作队列上LIFO,而窃取其他线程的任务的时候,从队列头部取获取。示意图如下:
工作线程worker1、worker2以及worker3都从taskQueue的尾部popping获取task,而任务也从尾部Pushing,当worker3队列中没有任务的时候,就会从其他线程的队列中取stealing,这样就使得worker3不会由于没有任务而空闲。这就是工作窃取算法的基本原理。
可以想象,要是不使用工作窃取算法,那么我们在不断fork的过程中,可能某些worker就会一直处于join的等待中。
1.2.3 ForkJoinPool
Java提供了ForkJoinPool来支持将一个任务拆分成多个“小任务”并行计算,再把多个“小任务”的结果合成总的计算结果。
ForkJoinPool是ExecutorService的实现类,因此是一种特殊的线程池。ForkJoinPool提供了如下两个常用的构造器。
// 创建一个包含parallelism个并行线程的ForkJoinPool
public ForkJoinPool(int parallelism)
//以Runtime.getRuntime().availableProcessors()的返回值作为parallelism来创建ForkJoinPool
public ForkJoinPool()
创建ForkJoinPool实例后,可以调用ForkJoinPool的submit(ForkJoinTask task) 或者invoke(ForkJoinTask task) 来执行指定任务。其中ForkJoinTask代表一个可以并行、合并的任务。ForkJoinTask是一个抽象类,它有两个抽象子类:RecursiveAction和RecursiveTask。
RecursiveTask代表有返回值的任务
RecursiveAction代表没有返回值的任务。
1、执行分治任务的有两个相关类:
- RecursiveTask
带有返回值的任务
- RecursiveAction
不带返回值的任务
根据需要,继承这两个相关类之一,重写compute()方法,在compute()方法中分解任务,并且将任务结果合并
2、调用总task
有两种调用方式:
-
task.fork()
直接调用task.fork()会利用ForkJoinPool.common来执行任务,ForkJoinPool.common是ForkJoinPool的一个静态变量线程池。 -
新建ForkJoinPool执行
ForkJoinPool fjp = new ForkJoinPool(4); T result = fjp.invoke(task);
1.2.3.1 RecursiveAction
下面以一个没有返回值的大任务为例,介绍一下RecursiveAction的用法
大任务是:打印0-100的数值。
小任务是:每次只能打印20个数值。
public class RaskDemo extends RecursiveAction {
/**
* 每个"小任务"最多只打印20个数
*/
private static final int MAX = 20;
private int start;
private int end;
public RaskDemo(int start, int end) {
this.start = start;
this.end = end;
}
@Override
protected void compute() {
//当end-start的值小于MAX时,开始打印
if((end-start) < MAX) {
for(int i= start; i<end;i++) {
System.out.println(Thread.currentThread().getName()+"i的值"+i);
}
}else {
// 将大任务分解成两个小任务
int middle = (start + end) / 2;
RaskDemo left = new RaskDemo(start, middle);
RaskDemo right = new RaskDemo(middle, end);
left.fork();
right.fork();
}
}
}
-------------------------------------------------------------------------------
public static void main(String[] args) throws Exception{
// 创建包含Runtime.getRuntime().availableProcessors()返回值作为个数的并行线程的ForkJoinPool
ForkJoinPool forkJoinPool = new ForkJoinPool();
// 提交可分解的PrintTask任务
forkJoinPool.submit(new RaskDemo(0, 1000));
//阻塞当前线程直到 ForkJoinPool 中所有的任务都执行结束
forkJoinPool.awaitTermination(2, TimeUnit.SECONDS);
// 关闭线程池
forkJoinPool.shutdown();
}
运行结果
从上面结果来看,ForkJoinPool启动了四个线程来执行这个打印任务,我的计算机的CPU是四核的。大家还可以看到程序虽然打印了0-999这一千个数字,但是并不是连续打印的,这是因为程序将这个打印任务进行了分解,分解后的任务会并行执行,所以不会按顺序打印。
1.2.3.2 RecursiveTask
下面以一个有返回值的大任务为例,介绍一下RecursiveTask的用法。
大任务是:计算随机的1000个数字的和。
小任务是:每次只能70个数值的和。
public class RecursiveTaskDemo extends RecursiveTask<Integer> {
/**
* 每个"小任务"最多只打印70个数
*/
private static final int MAX = 70;
private int arr[];
private int start;
private int end;
public RecursiveTaskDemo(int[] arr, int start, int end) {
this.arr = arr;
this.start = start;
this.end = end;
}
@Override
protected Integer compute() {
int sum = 0;
// 当end-start的值小于MAX时候,开始打印
if((end - start) < MAX) {
for (int i = start; i < end; i++) {
sum += arr[i];
}
return sum;
}else {
System.err.println("=====任务分解======");
// 将大任务分解成两个小任务
int middle = (start + end) / 2;
RecursiveTaskDemo left = new RecursiveTaskDemo(arr, start, middle);
RecursiveTaskDemo right = new RecursiveTaskDemo(arr, middle, end);
// 并行执行两个小任务
left.fork();
right.fork();
// 把两个小任务累加的结果合并起来
return left.join() + right.join();
}
}
}
------------------------------------------------------------------------------
@Test
public void dfs() throws Exception{
int arr[] = new int[1000];
Random random = new Random();
int total = 0;
// 初始化100个数字元素
for (int i = 0; i < arr.length; i++) {
int temp = random.nextInt(100);
// 对数组元素赋值,并将数组元素的值添加到total总和中
total += (arr[i] = temp);
}
System.out.println("初始化时的总和=" + total);
// 创建包含Runtime.getRuntime().availableProcessors()返回值作为个数的并行线程的ForkJoinPool
ForkJoinPool forkJoinPool = new ForkJoinPool();
// 提交可分解的PrintTask任务
// Future<Integer> future = forkJoinPool.submit(new RecursiveTaskDemo(arr, 0, arr.length));
// System.out.println("计算出来的总和="+future.get());
Integer integer = forkJoinPool.invoke( new RecursiveTaskDemo(arr, 0, arr.length) );
System.out.println("计算出来的总和=" + integer);
// 关闭线程池
forkJoinPool.shutdown();
}
计算结果
从上面结果来看,ForkJoinPool将任务分解了15次,程序通过SumTask计算出来的结果,和初始化数组时统计出来的总和是相等的,这表明计算结果一切正常。
1.3 总结
1.3.1 ForkJoinPool的原理
任务窃取:
ForkJoinPool中内置了一个WorkQueue数组,一个WorkQueue对应着一个线程。当一个线程执行完其对应的WorkQueue中的任务中时,会窃取其他WorkQueue中的任务来执行!
其中WorkQueue是一个双端队列,当前线程会从一端获取和添加任务,而其他线程会从队列的另一端来窃取任务,进一步降低了冲突。
1.3.2 流程
第一步分割任务
首先我们需要有一个fork类来把大任务分割成子任务,有可能子任务还是很大,所以还需要不停的分割,直到分割出的子任务足够小。
第二步执行任务并合并结果
分割的子任务分别放在双端队列里,然后几个启动线程分别从双端队列里获取任务执行。子任务执行完的结果都统一放在一个队列里,启动一个线程从队列里拿数据,然后合并这些数据。
能够轻松的利用多个 CPU 提供的计算资源来协作完成一个复杂的计算任务,提高运行效率!
1.3.3 Java8新的写法
/**
*并行流 与 顺序流
*/
@Test
public void test03() {
Instant start = Instant.now();
LongStream.rangeClosed( 0,110 )
//并行流
.parallel()
.reduce( 0,Long::sum );
LongStream.rangeClosed( 0,110 )
//顺序流
.sequential()
.reduce( 0,Long::sum );
Instant end = Instant.now();
System.out.println("耗费时间"+ Duration.between( start,end ).toMillis());
}