本文目录导读:

- 核心思想:分而治之 + 工作窃取
- 关键组件
- 计算步骤详解(以 RecursiveTask 求和为例)
- 关键机制:工作窃取 (Work-Stealing)
- 数学模型:如何计算并行效率?
- 总结:ForkJoinPool 计算流程图解
ForkJoinPool 分治任务的计算主要依赖于 工作窃取算法 和 分治递归 两大核心机制,下面为你详细拆解其计算过程。
核心思想:分而治之 + 工作窃取
- 分治 (Divide-and-Conquer):将一个大任务递归地拆分成足够小的子任务,直到子任务可以直接求解,然后合并子任务的结果。
- 工作窃取 (Work-Stealing):如果一个线程的队列中没有任务了,它会从其他线程的队列尾部“偷”一个任务来执行,以保持所有线程的忙碌状态,实现真正的并行。
关键组件
要理解计算过程,需要先认识几个核心类:
ForkJoinPool:线程池本身,管理着一组工作线程。ForkJoinTask:提交给线程池的任务,它有两个常用子类:RecursiveAction:用于没有返回结果的任务(对一个数组进行并行排序)。RecursiveTask<V>:用于有返回结果的任务(计算1加到10000)。
ForkJoinWorkerThread:线程池中的工作线程,每个线程维护一个自己的双端队列(Deque),用于存放分配给它的子任务。
计算步骤详解(以 RecursiveTask 求和为例)
假设我们想用并行计算 1 + 2 + 3 + ... + 10000。
任务定义与拆分
你首先需要创建一个类继承 RecursiveTask<Long>,并实现 compute() 方法。
class SumTask extends RecursiveTask<Long> {
private static final int THRESHOLD = 1000; // 拆分阈值
private int start;
private int end;
public SumTask(int start, int end) {
this.start = start;
this.end = end;
}
@Override
protected Long compute() {
// 情况1:任务足够小,直接计算
if (end - start <= THRESHOLD) {
long sum = 0;
for (int i = start; i <= end; i++) sum += i;
return sum;
}
// 情况2:任务太大,进行拆分
int mid = (start + end) / 2;
SumTask leftTask = new SumTask(start, mid);
SumTask rightTask = new SumTask(mid + 1, end);
// 关键步骤:分叉 (Fork)
leftTask.fork(); // 1. 将左子任务提交到线程池,让它异步执行
Long rightResult = rightTask.compute(); // 2. 本线程自己计算右子任务
Long leftResult = leftTask.join(); // 3. 等待左子任务完成并获取结果
// 合并 (Join)
return leftResult + rightResult;
}
}
提交与执行流程
ForkJoinPool pool = new ForkJoinPool(); // 使用默认并行度 SumTask rootTask = new SumTask(1, 10000); // 创建根任务 Long result = pool.invoke(rootTask); // 提交并等待结果 System.out.println(result); // 输出: 50005000
内部具体执行过程(假设有4个线程):
- 任务提交:
pool.invoke(rootTask)将根任务(1-10000)放到线程池的提交队列中。 - 线程拾取:一个空闲线程(假设是 Thread-1)从提交队列中取出根任务,执行它的
compute()方法。 - 第一次拆分:
- Thread-1 发现任务太大 (10000 > 1000),于是将其拆分为
Left (1-5000)和Right (5001-10000)。 leftTask.fork():将Left任务放入 Thread-1 自己的 工作队列头部,这相当于说:“我稍后会处理你,或者让别人偷走”。rightTask.compute():Thread-1 立即开始计算Right任务。
- Thread-1 发现任务太大 (10000 > 1000),于是将其拆分为
- 递归拆分与并行计算:
- Thread-1 在计算
Right (5001-10000)时,发现还是太大,再次拆分:- 拆分出
Left (5001-7500)和Right (7501-10000)。
- 拆分出
leftTask.fork():将Left (5001-7500)放入 Thread-1 的队列头部。rightTask.compute():Thread-1 开始计算Right (7501-10000),发现依然太大,继续拆分... 直到任务变得足够小。
- Thread-1 在计算
- 工作窃取 (关键):
- 假设:Thread-2 一直空闲,它的队列是空的。
- 偷取:Thread-2 检查其他线程的队列,它发现 Thread-1 的队列中有很多未完成的任务(如
Left (1-5000),Left (5001-7500)等)。 - 窃取:Thread-2 从 Thread-1 的队列尾部偷走了
Left (5001-7500)这个任务。(注意:从尾部偷是为了减少与队列头部本线程任务的竞争)。 - 执行:Thread-2 开始独立执行偷来的
Left (5001-7500),它也会递归地拆分、fork、compute,将自己拆出的子任务放入自己的队列。
- 结果合并(Join):
- 当 Thread-1 计算完一个叶子任务(7501-8500)后,开始层层回溯。
- 当它需要合并时,会调用
leftTask.join()。 leftTask恰好被 Thread-2 偷走了,Thread-1 的join()会阻塞等待,直到 Thread-2 完成计算并返回结果。- 当所有子任务都完成时,最外层的
join()返回最终结果50005000。
关键机制:工作窃取 (Work-Stealing)
这是 ForkJoinPool 最核心的设计亮点,它解决了传统线程池的一个痛点。
- 传统线程池:所有任务放入一个公共队列,线程从头部取任务,如果某个线程的任务非常大(比如计算斐波那契数列的第50项),它就会一直占据线程,而其他线程可能没事干。
- ForkJoinPool 的解法:
- 每个线程有自己的双端队列 (Deque),本线程放入自己的任务(
fork)是放到队头,执行时(如果是连续fork和compute)也是从队头取任务,这保证了 LIFO(后进先出),即最近放入的任务最先被处理,这种局部性有利于利用 CPU 缓存。 - 空闲线程会扫描其他线程的队列,从队尾偷取任务,这保证了空闲线程能找到活干,并且由于是从尾部偷(通常是较大的、更早放入的任务),不容易与队列主人的操作(都在头部)产生竞争。
- 每个线程有自己的双端队列 (Deque),本线程放入自己的任务(
工作窃取的优势
- 负载均衡:计算量大的线程会被其他线程“帮助”,自动实现负载均衡,无需手动调整。
- 减少线程切换:因为每个线程大部分时间在处理自己队列头部的任务,而窃取发生在队列尾部,大大减少了锁的竞争。
- 提高 CPU 利用率:尽可能让所有线程都处于忙碌状态。
数学模型:如何计算并行效率?
ForkJoinPool 的计算效率可以用 Amdahl定律 (阿姆达尔定律) 来理解:
[ \text{加速比}(S) = \frac{1}{(1 - P) + \frac{P}{N}} ]
P:任务的可并行化比例(即通过fork()分配出去的部分)。N:CPU 核心数(或线程数)。
应用到这个求和场景:
- 串行部分:任务拆分、结果的合并(
join())、线程调度,这些开销不可忽略,尤其是当任务非常小时。 - 并行部分:各个子任务的计算(
compute()中的循环求和)。 - 关键点:需要找到一个合适的
THRESHOLD(拆分阈值)。- 阈值太大:并行度不足,有些线程可能空闲。
- 阈值太小:拆分和合并的开销(串行部分)会远大于计算本身,导致实际速度反而可能比单线程更慢(甚至会出现负收益)。
经验公式 / 实验结果: 通常建议阈值大小是 100 到 10000 个元素 之间,具体依赖计算复杂度,可以通过性能测试来确定最佳阈值。
ForkJoinPool 计算流程图解
用户提交任务 (ForkJoinPool.invoke)
│
▼
初始任务 (Root Task)
│
│ compute()
▼
┌───────────────────┐
│ 判断任务是否足够小? │
└─────────┬─────────┘
│
是 │ 否 (继续拆分)
◄─────┘
│
▼
直接计算 ┌─── leftTask.fork() ───→ 放入本线程队列头部
返回结果 │ │
┌─────┴──────┐ (可能被其他线程窃取)
│ │
▼ ▼
rightTask. leftTask.join()
compute() (等待结果)
│ │
└─────┬──────┘
▼
合并结果 (return)
ForkJoinPool 计算的核心在于 通过递归拆分产生大量粒度合适的子任务,并利用工作窃取机制让空闲线程“帮助”忙碌线程,从而在有限的计算资源下最大化并行吞吐量。