最近看了网上的某公开课,其中有讲到forkjoin框架。在这之前,我丝毫没听说过这个东西,很好奇是什么东东。于是,就顺道研究了一番。
总感觉这个东西,用的地方很少,也有可能是我才疏学浅。好吧,反正问了身边一堆猿,没有一个知道的。
因此,我也没有那么深入的去了解底层,只是大概的了解了其工作原理,并分析了下它和普通的for循环以及JDK8的stream流之间的性能对比(稍后会说明其中踩到的坑)。
一、forkjoin介绍
forkjoin是JDK7提供的并行执行任务的框架。 并行怎么理解呢,就是可以充分利用多核CPU的计算能力,让多个CPU同时进行任务的执行,从而使单位时间内执行的任务数尽量多,因此表现上就提高了执行效率。
它的主要思想就是,先把任务拆分成一个个小任务,然后再把所有任务汇总起来,简而言之就是分而治之。如果你了解过hadoop的MapReduce,就能理解这种思想了。不了解也没关系,下面画一张图,你就能明白了。
上边的任务拆分为多个子任务的过程就是fork,下边结果的归并操作就是join。(注意子任务和多线程不是一个概念,而是一个线程下会有多个子任务)
另外,forkjoin有一个工作窃取的概念。简单理解,就是一个工作线程下会维护一个包含多个子任务的双端队列。而对于每个工作线程来说,会从头部到尾部依次执行任务。这时,总会有一些线程执行的速度较快,很快就把所有任务消耗完了。那这个时候怎么办呢,总不能空等着吧,多浪费资源啊。
于是,先做完任务的工作线程会从其他未完成任务的线程尾部依次获取任务去执行。这样就可以充分利用CPU的资源。这个非常好理解,就比如有个妹子程序员做任务比较慢,那么其他猿就可以帮她分担一些任务,这简直是双赢的局面啊,妹子开心了,你也开心了。
二、实操测试性能
话不多说,先上代码,计算的是从0加到10亿的结果。
public class ForkJoinWork extends RecursiveTask {
private long start;
private long end;
//临界点
private static final long THRESHOLD = 1_0000L;
public ForkJoinWork(long start, long end) {
this.start = start;
this.end = end;
}
@Override
protected Long compute() {
long len = end - start;
//不大于临界值直接计算结果
if(len < THRESHOLD){
long sum = 0L;
for (long i = start; i <= end; i++) {
sum += i;
}
return sum;
}else{
//大于临界值时,拆分为两个子任务
Long mid = (start + end) /2;
ForkJoinWork task1 = new ForkJoinWork(start,mid);
ForkJoinWork task2 = new ForkJoinWork(mid+1,end);
task1.fork();
task2.fork();
//合并计算
return task1.join() + task2.join();
}
}
}
public class ForkJoinTest {
public static void main(String[] args) throws Exception{
long start = 0L;
long end = 10_0000_0000L;
testSum(start,end);
testForkJoin(start,end);
testStream(start,end);
}
/**
* 普通for循环 - 1273ms
* @param start
* @param end
*/
public static void testSum(Long start,Long end){
long l = System.currentTimeMillis();
long sum = 0L;
for (long i = start; i <= end ; i++) {
sum += i;
}
long l1 = System.currentTimeMillis();
System.out.println("普通for循环结果:"+sum+",耗时:"+(l1-l));
}
/**
* forkjoin方式 - 917ms
* @param start
* @param end
* @throws Exception
*/
public static void testForkJoin(long start,long end) throws Exception{
long l = System.currentTimeMillis();
ForkJoinPool forkJoinPool = new ForkJoinPool();
ForkJoinWork task = new ForkJoinWork(start,end);
long invoke = forkJoinPool.invoke(task);
long l1 = System.currentTimeMillis();
System.out.println("forkjoin结果:"+invoke+",耗时:"+(l1-l));
}
/**
* stream流 - 676ms
* @param start
* @param end
*/
public static void testStream(Long start,Long end){
long l = System.currentTimeMillis();
long reduce = LongStream.rangeClosed(start, end).parallel().reduce(0, (x, y) -> x + y);
long l1 = System.currentTimeMillis();
System.out.println("stream流结果:"+reduce+",耗时:"+(l1-l));
}
}
这里解释下,首先我们需要创建一个ForkJoinTask,自定义一个类来继承ForkJoinTask的子类RecursiveTask,这是为了拿到返回值。另外还有一个子类RecursiveAction是不带返回值的,这里我们暂时用不到。
然后,需要创建一个ForkJoinPool来执行task,最后调用invoke方法来获取最终执行的结果。它还有两种执行方式,execute和submit。这里不展开,感兴趣的可以自行查看源码。
铛铛,重点来了。
我测试了下比较传统的普通for循环,来对比forkjoin的执行速度。计算的是从0加到10亿,在我的win7电脑上确实是forkjoin计算速度快。这时,坑来了,同样的代码,没有任何改动,我搬到mac电脑上,计算结果却大大超出我的意外——forkjoin竟然比for循环慢了一倍,对的没错,执行时间是for循环的二倍。
这就让我特别头大了,这到底是什么原因呢。经过多次测试,终于搞明白了。forkjoin这个框架针对的是大任务执行,效率才会明显的看出来有提升,于是我把总数调大到20亿。
另外还有个关键点,通过设置不同的临界点值,会有不同的结果。逐渐的加大临界点值,效率会进一步提升。比如,我分别把THRESHOLD设置为1万,10万和100万,执行时间会逐步缩短,并且会比for循环时间短。感兴趣的,可自己手动操作一下,感受这个微妙的变化。
因此,最终修改为从0加到20亿,临界值设置为100万,就出现了以下结果:
普通for循环结果:2000000001000000000,耗时:1273
forkjoin结果:2000000001000000000,耗时:917
stream流结果:2000000001000000000,耗时:676
可以明显看出来,forkjoin确实是比for循环快的。当然,逐步的再加大总数到100亿或者更大,然后调整合适的临界值,这种对比会更加明显。(就是心疼电脑会冒烟,不敢这样测试)
最后,说下JDK8提供的Stream流计算,可以看到,这个计算速度是三种方式中最快的。奈你forkjoin再牛逼,通常还是比不过Stream的,从这个方法parallel的名字就看出来,也是并行计算。所以,这也是我感觉forkjoin好像没什么存在感的原因,Stream不香吗。(当然,也有可能是forkjoin还有更牛逼的功能待我去发掘。)