ForkJoinPool 类是位于 java.util.concurrent 包下的一个类。它可以将一个大的任务拆分成多个子任务进行并行处理,最后将子任务结果合并成最后的计算结果,并进行输出。
一、应用
1. 累加
import java.util.*;
import java.util.concurrent.*;
public class ForkJoinSum {
/**
* 这是一个简单的Join/Fork计算过程,将1—1001数字相加
*/
private static final Integer MAX = 200;
static class MySumTask extends RecursiveTask<Integer> {
// 子任务开始计算的值
private Integer startValue;
// 子任务结束计算的值
private Integer endValue;
public MySumTask(Integer startValue , Integer endValue) {
this.startValue = startValue;
this.endValue = endValue;
}
@Override
protected Integer compute() {
// 如果条件成立,说明这个任务所需要计算的数值分为足够小了
// 可以正式进行累加计算了
if(endValue - startValue < MAX) {
System.out.println("开始计算的部分:startValue = " + startValue + ";endValue = " + endValue);
Integer totalValue = 0;
for(int index = this.startValue ; index <= this.endValue ; index++) {
totalValue += index;
}
return totalValue;
}
// 否则再进行任务拆分,拆分成两个任务
else {
MySumTask subTask1 = new MySumTask(startValue, (startValue + endValue) / 2);
subTask1.fork();
MySumTask subTask2 = new MySumTask((startValue + endValue) / 2 + 1 , endValue);
subTask2.fork();
return subTask1.join() + subTask2.join();
}
}
}
public static void main(String[] args) {
// 这是Fork/Join框架的线程池
ForkJoinPool pool = new ForkJoinPool();
ForkJoinTask<Integer> taskFuture = pool.submit(new MySumTask(1,1001));
try {
Integer result = taskFuture.get();
System.out.println("result = " + result);
} catch (InterruptedException | ExecutionException e) {
e.printStackTrace(System.out);
}
}
}
运行得到的结果是:
result = 501501
首先是通过 ForkJoinPool
来提交子任务 MySumTask
得到一个 ForkJoinTask
对象,然后调用 forkJoinTask.get()
方法开启任务。这个子任务 MySumTask
就是自定义的求和子任务,继承自 RecursiveTask
,重写它的 compute()
方法,在这个方法里面进行具体的求和逻辑:如果起始和末尾间隔大于阈值,则继续分割区间并创建子任务调用 fork()
方法,然后返回子任务的 join()
方法执行结果;否则对这个区间内所有的数求和并返回结果。
RecursiveTask 和 RecursiveAction:
- RecursiveTask:递归有返回值的ForkJoinTask子类;
- RecursiveAction:递归无返回值的ForkJoinTask子类;
2. 排序
import java.util.*;
import java.util.concurrent.*;
public class ForkJoinDemo {
private static int MAX = 100;
private static int inits[] = new int[MAX];
static {
Random r = new Random();
for(int index = 1 ; index <= MAX ; index++) {
inits[index - 1] = r.nextInt(100);
}
}
public static void main(String[] args) throws Exception {
// 正式开始
long beginTime = System.currentTimeMillis();
ForkJoinPool pool = new ForkJoinPool();
MySortTask task = new MySortTask(inits);
ForkJoinTask<int[]> taskResult = pool.submit(task);
try {
System.out.println(Arrays.toString(taskResult.get()));
} catch (InterruptedException | ExecutionException e) {
e.printStackTrace(System.out);
}
long endTime = System.currentTimeMillis();
System.out.println("耗时=" + (endTime - beginTime));
}
/**
* 单个排序的子任务
* @author yinwenjie
*/
static class MySortTask extends RecursiveTask<int[]> {
private int source[];
public MySortTask(int source[]) {
this.source = source;
}
/* (non-Javadoc)
* @see java.util.concurrent.RecursiveTask#compute()
*/
@Override
protected int[] compute() {
int sourceLen = source.length;
// 如果条件成立,说明任务中要进行排序的集合还不够小
if(sourceLen > 2) {
int midIndex = sourceLen / 2;
// 拆分成两个子任务
MySortTask task1 = new MySortTask(Arrays.copyOf(source, midIndex));
task1.fork(); //要调用 fork() 方法
MySortTask task2 = new MySortTask(Arrays.copyOfRange(source, midIndex , sourceLen));
task2.fork();
// 将两个有序的数组,合并成一个有序的数组,
int result1[] = task1.join(); //要调用 join() 方法
int result2[] = task2.join();
int mer[] = joinInts(result1 , result2);
return mer; //返回排序后的数组
}
// 否则说明集合中只有一个或者两个元素,可以进行这两个元素的比较排序了
else {
// 如果条件成立,说明数组中只有一个元素,或者是数组中的元素都已经排列好位置了
if(sourceLen == 1
|| source[0] <= source[1]) {
return source;
} else {
int targetp[] = new int[sourceLen];
targetp[0] = source[1];
targetp[1] = source[0];
return targetp;
}
}
}
/**
* 这个方法用于合并两个有序集合
* @param array1
* @param array2
*/
private static int[] joinInts(int array1[] , int array2[]) {
int destInts[] = new int[array1.length + array2.length];
int array1Len = array1.length;
int array2Len = array2.length;
int destLen = destInts.length;
// 只需要以新的集合destInts的长度为标准,遍历一次即可
for(int index = 0 , array1Index = 0 , array2Index = 0 ; index < destLen ; index++) {
int value1 = array1Index >= array1Len?Integer.MAX_VALUE:array1[array1Index];
int value2 = array2Index >= array2Len?Integer.MAX_VALUE:array2[array2Index];
// 如果条件成立,说明应该取数组array1中的值
if(value1 < value2) {
array1Index++;
destInts[index] = value1;
}
// 否则取数组array2中的值
else {
array2Index++;
destInts[index] = value2;
}
}
return destInts;
}
}
}
运行得到的结果是:
[1, 2, 2, 2, 4, 6, 7, 7, 8, 9, 11, 14, 14, 14, 15, 16, 17, 19, 20, 21, 23, 23, 24, 26, 28, 29, 33, 33, 33, 34, 35, 37, 38, 39, 40, 42, 44, 44, 44, 47, 48, 48, 48, 49, 49, 50, 51, 52, 53, 53, 53, 54, 54, 55, 56, 56, 56, 57, 58, 58, 59, 59, 60, 61, 62, 62, 63, 65, 67, 68, 68, 69, 70, 70, 72, 72, 73, 73, 75, 75, 77, 77, 78, 78, 81, 81, 82, 82, 83, 87, 88, 88, 89, 90, 92, 93, 98, 99, 99, 99]
耗时=29
同样重点是这个 MySortTask
子任务,看它的 compute()
方法:如果子任务的排序区间长度大于2则继续分割成两个子区间并对这两个子区间执行排序,然后合并两个区间的元素;否则如果区间只有一个元素直接返回,如果有两个元素则对两个元素排序并返回。这其实就是归并排序的思想。
注意合并两个区间的元素遍历两个数组,从中取出头部较小的值放入已排好序的结果中。
其实上面这两种应用场景也可以使用 ExecutorService
+ Callable
来实现,不过这样就要手动去计算并分割子任务数量。没有 ForkJoinPool 来的方便。此外还可以使用并行流(JDK8+,并行流底层还是Fork/Join框架,只是任务拆分优化得很好)来实现:
public static void main(String[] args) {
Instant start = Instant.now();
long result = LongStream.rangeClosed(0, 10000000L).parallel().reduce(0, Long::sum);
Instant end = Instant.now();
System.out.println("耗时:" + Duration.between(start, end).toMillis() + "ms");
System.out.println("结果为:" + result); // 打印结果500500
}
二、原理
ForkJoin主要适用于:计算密集型任务,且任务可被分解执行
。不建议使用Block IO以及额外的同步、锁等,这种阻塞操作,会导致ForkJoin的并发能力受限,而且严重时会导致框架的线程池调度失效。
特性:
- ForkJoinPool内部仍为线程池模式,且没有调度线程。
- Pool中在存取任务、worker线程执行和stealing任务时,几乎没有使用到任何的显式锁,其内部
主要使用Unsafe + CAS
;所有标记状态、计数等可能被并发访问的字段,都经过CAS操作。尽管JDK中已经存在很多并发API,比如ArrayBlockingQueue等,但是ForkJoinPool并没有直接使用它们,而是继续CAS重建了相关语义。 - 此外,因为ForkJoin中没有调度线程,比如均衡分配任务、唤醒等Master线程;所以所有的提交任务的线程、Worker线程,都会在各自的时机上,承担一定的调度职能,worker线程的stealing机制潜在引入并发问题,ForkJoinPool的设计巧妙之处,就是在没有使用Lock的情况下,能够达到并发安全。通过源码发现,Pool中很多实现,都是刻意避免并发(线程的join),而不是lock来兼容和支持并发。
- ForkJoinPool框架中,并没有太多的、复杂的数据结构,用于保存比如线程池状态、任务列表和状态等。而是使用简单类型的字段,通过位运算来实现很多的状态标记,通过使用CAS原子性更新实现类似于锁状态(qlock)。
工作窃取算法(work-stealing)
大任务被分割为独立的子任务,并且子任务分别放到不同的队列里
,并为每个队列创建一个线程来执行队列里的任务
。假设线程A优先把分配到自己队列里的任务执行完毕,此时如果线程E对应的队列里还有任务等待执行,空闲的线程A会窃取线程E队列里任务执行,并且为了减少窃取任务时线程A和被窃取任务线程E之间的发生竞争
,窃取任务的线程A会从队列的尾部获取任务执行,被窃取任务线程E会从队列的头部获取任务执行。
工作窃取算法(work-stealing)的优点:线程间的竞争很少,充分利用线程进行并行计算,但是在任务队列里只有一个任务时,也可能会存在竞争情况。
ForkJoinPool:
内部使用使用一个可以按需扩容的数组表示线程池
:WorkQueue[] workerQueues,此数据总是2倍扩容,其实际最大容量与“ parallelism”保持一致。WorkQueue实例中持有一个Worker线程和Task队列
,可以简单认为一个WorkQueue就是一个工作线程单元;
- 在任务提交时(submit/execute),如果workQueues尚未达到“并发级别”,即当前线程池未满,则直接创建新的WorkerQueue(worker线程)并由其执行当前任务。此时还有workerQueues扩容问题。
- 如果workerQueues容量达到阈值(即线程池满),将随机(ThreadLocalRandom)从workerQueues中找个workQueue并
将此task添加到其任务队列中
,之所以随机查找workQueues,也是为了避免并发提交时,submission线程并发操作workQueues的概率;有一种特殊的情况,就是随机找到的这个workQueue可能为null,因为此workQueue持有的工作线程因为各种原因(比如空闲超时,异常退出)被注销了(deregister),此时将会创建一个新的WorkQueue实例并补偿在此位置。 - ForkJoinPool内部并没有scheduler线程。
- 我们可以通过shutdown/shutdownNow来关闭Pool,此后Pool将不会接收新的submission;此时将会触发所有worker线程的退出操作(deregister):修改线程状态、唤醒阻塞、清空本地任务列表等。
WorkQueue:
- 逻辑上的worker线程,内部持有实际的
ForkJoinWorkerThread
和Task
列表。同时,还持有pool的引用。 ForkJoinWorkerThread
工作线程,类似于线程池中的worker线程。Task列表
,作为WorkQueue的本地任务队列,此任务队列,是一个Deque,但是并没有使用JDK中已有的LinkedBlockDeque,而是使用ArrayBlockingQueue的思想(循环数组) + CAS实现的简单双端队列
,最终由WorkQueue支持三种操作:poll、push、pop用于操作此Task列表;其中poll和push有当前ForkJoinWorkerThread操作,poll使用FIFO方式弹出任务并执行,push有submission线程添加任务到队列尾部,此外当前线程窃取操作也会执行push;对于pop,将有其他worker线程窃取任务时,从队列尾部窃取(LIFO)。- WorkQueue对象,将有submission线程提交任务时创建。此外,Fork子任务时,优先将子任务添加到本地队列,同时也会触发线程池容量检测,如果workQueues容量未满,也会创建新的worker线程和workQueue对象。(参见signalWorker())
ForkJoinWorkerThread:
- 同ExecutorService中的worker线程,守护线程。有WorkQueue引用并持有,同时也持有workQueue和pool的引用。
- 在submission线程提交任务时,如果线程池容量尚未达到并发级别上限,将会优先创建新的worker线程并由其执行task。在其他worker线程中fork操作时,优先将子任务添加到本地任务列表,并检测线程池容量(即workQueues大小,当然源码中有单独的便捷字段来控制),如果未达到上线,也将会触发创建新的worker线程。
- worker线程启动后,将会优先检测其workQueue中的本地队列是否有亟待执行的任务,如果有则逐个执行。直到任务队列全部执行完毕,开始steal。
- steal操作,也是ForkJoinPool的核心特性;当worker线程的本地任务队列执行完毕后,触发stealing;从Pool的workQueues的随机位置开始,按照一定的节奏推进(跳)(随机 + 跳,主要目的就是避免与其他worker线程的并发冲突,因为stealing操作可能在多个worker线程同时发生),检测遇到的每个workQueue的本地队列,如果有剩余任务,则使用workQueue.pop窃取一个;直到遍历完workQueues,如果还没有steal到任何任务,这说明整个线程池中任务数量相对较少(饥饿),为了避免整个线程池都处于盲目的、无休止的steal,此时worker线程将会被“钝化”(park)一段时间,并等待submission线程或者其他worker线程fork操作唤醒;如果一个worker线程IDLE一段时间之后(2秒),将会被释放销毁。(scan(),tryRelease())。
- worker线程在执行任何任务时,都会尝试捕捉异常,并将异常作为Futrue的结果,在使用ForkJoinTask.get()时重新抛出。
- 由此可见,我们不能再ForkJoinTask中使用时间不可控的block操作,假如所有的ForkJoinTask的work线程都阻塞,整个线程池也将完全失效(即不能fork新任务触发唤醒,worker线程也无法触发其他线程的唤醒)。
THE END.