多线程系列(二十一) -ForkJoin使用详解
一、摘要
从 JDK 1.7 开始,引入了一种新的 Fork/Join 线程池框架,它可以把一个大任务拆成多个小任务并行执行,最后汇总执行结果。
比如当前要计算一个数组的和,最简单的办法就是用一个循环在一个线程中完成,但是当数组特别大的时候,这种执行效率比较差,例如下面的示例代码。
long sum = 0;
for (int i = 0; i < array.length; i++) {
sum += array[i];
}
System.out.println("汇总结果:" + sum);
还有一种办法,就是将数组进行拆分,比如拆分成 4 个部分,用 4 个线程并行执行,分别计算,最后进行汇总,这样执行效率会显著提升。
如果拆分之后的部分还是很大,可以继续拆,直到满足最小颗粒度,再进行计算,这个过程可以反复“裂变”成一系列小任务,这个就是 Fork/Join 的工作原理。
Fork/Join 采用的是分而治之的基本思想,分而治之就是将一个复杂的任务,按照规定的阈值划分成多个简单的小任务,然后将这些小任务的执行结果再进行汇总返回,得到最终的执行结果。分而治之的思想在大数据领域应用非常广泛。
下面我们一起来看看 Fork/Join 的具体用法。
二、ForkJoin 用法介绍
以计算 2000 个数字组成的数组为例,进行并行求和, Fork/Join 简单的应用示例如下:
public class ForkJoinTest {
public static void main(String[] args) throws Exception {
// 创建2000个数组成的数组
long[] array = new long[2000];
// 记录for循环汇总计算的值
long sourceSum = 0;
for (int i = 0; i < array.length; i++) {
array[i] = i;
sourceSum += array[i];
}
System.out.println("for循环汇总计算的值: " + sourceSum);
System.out.println("---------------");
// fork/join汇总计算的值
ForkJoinPool forkJoinPool = new ForkJoinPool();
ForkJoinTask<Long> taskFuture = forkJoinPool.submit(new SumTask(array, 0, array.length));
System.out.println("fork/join汇总计算的值: " + taskFuture.get());
}
}
public class SumTask extends RecursiveTask<Long> {
/**
* 最小任务数组最大容量
*/
private static final int THRESHOLD = 500;
private long[] array;
private int start;
private int end;
public SumTask(long[] array, int start, int end) {
this.array = array;
this.start = start;
this.end = end;
}
@Override
protected Long compute() {
// 检查任务是否足够小,如果任务足够小,直接计算
if (end - start <= THRESHOLD) {
long sum = 0;
for (int i = start; i < end; i++) {
sum += this.array[i];
}
return sum;
}
// 任务太大,一分为二
int middle = (end + start) / 2;
// 拆分执行
SumTask leftTask = new SumTask(this.array, start, middle);
leftTask.fork();
SumTask rightTask = new SumTask(this.array, middle, end);
rightTask.fork();
System.out.println("进行任务拆分,leftTask数组区间:" + start + "," + middle + ";rightTask数组区间:" + middle + "," + end);
// 汇总结果
return leftTask.join() + rightTask.join();
}
}
输出结果如下:
for循环汇总计算的值: 1999000
---------------
进行任务拆分,leftTask数组区间:0,1000;rightTask数组区间:1000,2000
进行任务拆分,leftTask数组区间:1000,1500;rightTask数组区间:1500,2000
进行任务拆分,leftTask数组区间:0,500;rightTask数组区间:500,1000
fork/join汇总计算的值: 1999000
从日志上可以清晰的看到,for 循环方式汇总计算的结果与Fork/Join
方式汇总计算的结果一致。
因为最小任务数组最大容量设置为500
,所以Fork/Join
对数组进行了三次拆分,过程如下:
- 第一次拆分,将
0 ~ 2000
数组拆分成0 ~ 1000
和1000 ~ 2000
数组 - 第二次拆分,将
0 ~ 1000
数组拆分成0 ~ 500
和500 ~ 1000
数组 - 第三次拆分,将
1000 ~ 2000
数组拆分成1000 ~ 1500
和1500 ~ 2000
数组 - 最后合并计算,将拆分后的最小任务计算结果进行合并处理,并返回最终结果
当数组量越大的时候,采用Fork/Join
这种方式来计算,程序执行效率优势非常明显。
三、ForkJoin 框架原理
从上面的用例可以看出,Fork/Join
框架的使用包含两个核心类ForkJoinPool
和ForkJoinTask
,它们之间的分工如下:
ForkJoinPool
是一个负责执行任务的线程池,内部使用了一个无限队列来保存需要执行的任务,而执行任务的线程数量则是通过构造函数传入,如果没有传入指定的线程数量,默认取当前计算机可用的 CPU 核心量ForkJoinTask
是一个负责任务的拆分和合并计算结果的抽象类,通过它可以完成将大任务分解成多个小任务计算,最后将各个任务执行结果进行汇总处理
正如上文所说,Fork/Join
框架采用的是分而治之的思想,会将一个超大的任务进行分解,按照设定的阈值分解成多个小任务计算,最后将各个计算结果进行汇总。它的应用场景非常多,比如大整数乘法、二分搜索、大数组快速排序等等。
有个地方可能需要注意一下,ForkJoinPool
线程池和ThreadPoolExecutor
线程池,两者实现原理是不一样的。
两者最明显的区别在于:ThreadPoolExecutor
中的线程无法向任务队列中再添加一个任务并在等待该任务完成之后再继续执行;而ForkJoinPool
可以实现这一点,它能够让其中的线程创建新的任务添加到队列中,并挂起当前的任务,此时线程继续从队列中选择子任务执行。
因此在 JDK 1.7 中,ForkJoinPool
线程池的实现是一个全新的类,并没有复用ThreadPoolExecutor
线程池的实现逻辑,两者用途不同。
3.1、ForkJoinPool
ForkJoinPool
是Fork/Join
框架中负责任务执行的线程池,核心构造方法源码如下:
/**
* 核心构造方法
* @param parallelism 可并行执行的线程数量
* @param factory 创建线程的工厂
* @param handler 异常捕获处理器
* @param asyncMode 任务队列模式,true:先进先出的工作模式,false:先进后出的工作模式
*/
public ForkJoinPool(int parallelism,
ForkJoinWorkerThreadFactory factory,
UncaughtExceptionHandler handler,
boolean asyncMode) {
this(checkParallelism(parallelism),
checkFactory(factory),
handler,
asyncMode ? FIFO_QUEUE : LIFO_QUEUE,
"ForkJoinPool-" + nextPoolId() + "-worker-");
checkPermission();
}
默认无参的构造方法,源码如下:
public ForkJoinPool() {
this(Math.min(MAX_CAP, Runtime.getRuntime().availableProcessors()),
defaultForkJoinWorkerThreadFactory, null, false);
}
默认构造方法创建ForkJoinPool
线程池,关键参数设置如下:
parallelism
:取的是当前计算机可用的 CPU 数量factory
:采用的是默认DefaultForkJoinWorkerThreadFactory
类,其中ForkJoinWorkerThread
是Fork/Join
框架中负责真正执行任务的线程asyncMode
:参数设置的是false
,也就是说存在队列的任务采用的是先进后出的方式工作
其次,也可以使用Executors
工具类来创建ForkJoinPool
,例如下面这种方式:
// 创建一个 ForkJoinPool 线程池
ExecutorService forkJoinPool = Executors.newWorkStealingPool();
与ThreadPoolExecutor
线程池一样,ForkJoinPool
也实现了Executor
和ExecutorService
接口,支持通过execute()
和submit()
等方式提交任务。
不过,正如上面所说,ForkJoinPool
和ThreadPoolExecutor
在实现上是不一样的:
- 在
ThreadPoolExecutor
中,多个线程都共有一个阻塞任务队列 - 而
ForkJoinPool
中每一个线程都有一个自己的任务队列,当线程发现自己的队列里没有任务了,就会到别的线程的队列里获取任务执行。
这样设计的目的主要是充分利用线程实现并行计算的效果,减少线程之间的竞争。
比如线程 A 负责处理队列 A 里面的任务,线程 B 负责处理队列 B 里面的任务,两者如果队列里面的任务数差不多,执行的时候互相不干扰,此时的计算性能是最佳的;假如线程 A 的任务执行完毕,发现线程 B 中的队列数还有一半没有执行,线程 A 会主动从线程 B 的队列里获取任务执行。
在这时它们会同时访问同一个队列,为了减少线程 A 和线程 B 之间的竞争,通常会使用双端队列,线程 B 从双端队列的头部拿任务执行,而线程 A 从双端队列的尾部拿任务执行,确保两者不会从同一端获取任务,可以显著加快任务的执行速度。
Fork/Join
框架中负责执行任务的线程ForkJoinWorkerThread
,部分源码如下:
public class ForkJoinWorkerThread extends Thread {
// 所在的线程池
final ForkJoinPool pool;
// 当前线程下的任务队列
final ForkJoinPool.WorkQueue workQueue;
// 初始化时的构造方法
protected ForkJoinWorkerThread(ForkJoinPool pool) {
// Use a placeholder until a useful name can be set in registerWorker
super("aForkJoinWorkerThread");
this.pool = pool;
this.workQueue = pool.registerWorker(this);
}
}
3.2、ForkJoinTask
ForkJoinTask
是Fork/Join
框架中负责任务分解和合并计算的抽象类,它实现了Future
接口,因此可以直接作为任务类提交到线程池中。
同时,它还包括两个主要方法:fork()
和join()
,分别表示任务的分拆与合并。
可以使用下图来表示这个过程。
ForkJoinTask
部分方法,源码如下:
public abstract class ForkJoinTask<V> implements Future<V>, Serializable {
// 将任务推送到任务队列
public final ForkJoinTask<V> fork() {
Thread t;
if ((t = Thread.currentThread()) instanceof ForkJoinWorkerThread)
((ForkJoinWorkerThread)t).workQueue.push(this);
else
ForkJoinPool.common.externalPush(this);
return this;
}
// 等待任务的执行结果
public final V join() {
int s;
if ((s = doJoin() & DONE_MASK) != NORMAL)
reportException(s);
return getRawResult();
}
}
在 JDK 中,ForkJoinTask
有三个常用的子类实现,分别如下:
RecursiveAction
:用于没有返回结果的任务RecursiveTask
:用于有返回结果的任务CountedCompleter
:在任务完成执行后,触发自定义的钩子函数
我们最上面介绍的用例,使用的就是RecursiveTask
子类,通常用于有返回值的任务计算。
ForkJoinTask
其实是利用了递归算法来实现任务的拆分,将拆分后的子任务提交到线程池的任务队列中进行执行,最后将各个拆分后的任务计算结果进行汇总,得到最终的任务结果。
四、小结
整体上,ForkJoinPool
可以看成是对ThreadPoolExecutor
线程池的一种补充,在工作线程中存放了任务队列,充分利用线程进行并行计算,进一步提升了线程的并发执行性能。
通过ForkJoinPool
和ForkJoinTask
搭配使用,将超大计算任务拆分成多个互不干扰的小任务,提交给线程池进行计算,最后将各个任务计算结果进行汇总处理,得到跟单线程执行一致的结果,当计算任务越大,Fork/Join
框架执行任务的效率,优势更突出。
但是并不是所有的任务都适合采用Fork/Join
框架来处理,比如读写数据文件这种 IO 密集型的任务就不合适,因为磁盘 IO、网络 IO 的操作特点就是等待,容易造成线程阻塞。
五、参考
1.https://www.liaoxuefeng.com/wiki/1252599548343744/1306581226487842
2.https://juejin.cn/post/6986899215163064333
3.https://developer.aliyun.com/article/806887
多线程系列(二十一) -ForkJoin使用详解的更多相关文章
- Velocity魔法堂系列二:VTL语法详解
一.前言 Velocity作为历史悠久的模板引擎不单单可以替代JSP作为Java Web的服务端网页模板引擎,而且可以作为普通文本的模板引擎来增强服务端程序文本处理能力.而且Velocity被移植到不 ...
- Zookeeper系列二:分布式架构详解、分布式技术详解、分布式事务
一.分布式架构详解 1.分布式发展历程 1.1 单点集中式 特点:App.DB.FileServer都部署在一台机器上.并且访问请求量较少 1.2 应用服务和数据服务拆分 特点:App.DB.Fi ...
- Solr系列二:solr-部署详解(solr两种部署模式介绍、独立服务器模式详解、SolrCloud分布式集群模式详解)
一.solr两种部署模式介绍 Standalone Server 独立服务器模式:适用于数据规模不大的场景 SolrCloud 分布式集群模式:适用于数据规模大,高可靠.高可用.高并发的场景 二.独 ...
- Maven系列二setting.xml 配置详解
文件存放位置 全局配置: ${M2_HOME}/conf/settings.xml 用户配置: ${user.home}/.m2/settings.xml note:用户配置优先于全局配置.${use ...
- Linux学习之CentOS(二十一)--Linux系统启动详解
在这篇随笔里面将对Linux系统的启动进行一个详细的解释!我的实验机器是CentOS6.4,当然对于现有的Linux发行版本,其系统的启动基本上都是一样的! 首先我们来看下Linux系统启动的几个 ...
- Java 虚拟机系列二:垃圾收集机制详解,动图帮你理解
前言 上篇文章已经给大家介绍了 JVM 的架构和运行时数据区 (内存区域),本篇文章将给大家介绍 JVM 的重点内容--垃圾收集.众所周知,相比 C / C++ 等语言,Java 可以省去手动管理内存 ...
- Java多线程(三)—— synchronized关键字详解
一.多线程的同步 1.为什么要引入同步机制 在多线程环境中,可能会有两个甚至更多的线程试图同时访问一个有限的资源.必须对这种潜在资源冲突进行预防. 解决方法:在线程使用一个资源时为其加锁即可. 访问资 ...
- Java8初体验(二)Stream语法详解(转)
本文转自http://ifeve.com/stream/ Java8初体验(二)Stream语法详解 感谢同事[天锦]的投稿.投稿请联系 tengfei@ifeve.com上篇文章Java8初体验(一 ...
- Java8初体验(二)Stream语法详解---符合人的思维模式,数据源--》stream-->干什么事(具体怎么做,就交给Stream)--》聚合
Function.identity()是什么? // 将Stream转换成容器或Map Stream<String> stream = Stream.of("I", & ...
- 深入浅出Mybatis系列(四)---配置详解之typeAliases别名(mybatis源码篇)
上篇文章<深入浅出Mybatis系列(三)---配置详解之properties与environments(mybatis源码篇)> 介绍了properties与environments, ...
随机推荐
- 小知识:后台执行Oracle创建索引免受会话中断影响
因为客户环境的堡垒机经常会莫名的断开连接,也不是简单的超时,因为有时候即使你一直在操作,也可能会断. 这样对于操作一些耗时长且中途中断可能会导致异常的操作就很危险,而最简单的避免方法就是将其写到脚本中 ...
- 4.if语句--《Python编程:从入门到实践》
4.1 检查多个条件 1.使用 and 检查多个条件 2.使用 or 检查多个条件 4.2 检查特定值是否包含在列表中 使用 in 检查特定值是否在列表中 >>> req ...
- 【MFC学习二】CFileDialog导出文件
用CFileDialog导出文件,用户可指定文件名后缀等,感觉操作上比上文的 BROWSEINFO 更加人性化. //将数据项写入CSV文件 int PutCSVItemLine(FILE *file ...
- Java-将Oracle中某个表的数据导出成数据文件
在做数据开发或者ETL工作中,经常会遇到需要在客户端将Oracle数据库中的某些表的数据导出,提供给其他人员使用的情况. 下面介绍我之前实施的一种方法:(该方法不是最好的办法,但是可以勉强使 ...
- Linux sed输出文件内容的某几行
命令: sed -n "开始行,结束行p" 文件名 sed -n '70,75p' 文件名 # 输出第70行到第75行的内容 sed -n '6p;26 ...
- JS leetcode 有多少小于当前数字的数字 解题分析,你应该了解的桶排序
壹 ❀ 引 刷题其实一直没断,只是这两天懒得整理题目...那么今天来记录一道前天做的题,题目本身不难,不过拓展起来还是有些东西可以讲,题目来自leetcode有多少小于当前数字的数字,题目描述如下: ...
- 深入 Nginx 之架构篇[转]
前言 最近在读 Nginx 相关的书籍,做一下读书笔记. Nginx 作为业界知名的高性能服务器,被广泛的应用.它的高性能正是由于其优秀的架构设计,其架构主要包括这几点:模块化设计.事件驱动架构.请求 ...
- idea 报错: Unable to import maven project: See logs for details
错误再现: idea 工具日志: 1) No implementation for org.apache.maven.model.path.PathTranslator was bound. whil ...
- CentOS7 开机网卡加载失败
服务器CentOS7一开,发现web服务无法访问.最终用ifconfig发现,网卡没有加载,连个IP地址都没有. 这时使用命令 service network restart 试图重启服务器网络.不料 ...
- Java集合框架学习(十) LinkedHashMap详解
LinkedHashMap介绍 1.Key和Value都允许null: 2.维护key的插入顺序: 3.非线程安全: 4.Key重复会覆盖.Value允许重复. 类定义 public class Li ...