Java ForkJoinPool
引言:为何需要 ForkJoinPool?
在探讨 ForkJoinPool
之前,我们先思考一个问题:对于某些特定类型的计算任务,传统的 ThreadPoolExecutor
是否是最佳选择?
传统线程池的局限性
ThreadPoolExecutor
是 Java 并发包(JUC)提供的通用线程池实现,它非常适合处理大量独立的、异步的任务。其典型的模式是:生产者将任务(Runnable
或 Callable
)放入一个共享的阻塞队列中,多个工作线程作为消费者从队列中取出任务并执行。
然而,当任务之间存在依赖关系,特别是递归分解的场景时,ThreadPoolExecutor
就可能遇到麻烦。考虑一个典型的分治算法,比如计算一个大数组的和:
- 一个父任务负责计算整个数组的和。
- 如果数组太大,父任务将其分解成两个子任务,分别计算数组的两半。
- 父任务需要等待两个子任务都完成后,才能将其结果合并得到最终答案。
如果我们将这个父任务提交给 ThreadPoolExecutor
:
- 父任务开始执行,它创建了两个子任务,并将它们提交回线程池的共享队列。
- 然后,父任务需要等待子任务的结果,它可能会选择阻塞(例如,调用子任务
Future
的get()
方法)。 - 问题来了: 如果线程池的线程数量有限(比如等于 CPU 核心数),并且所有线程都恰好在执行类似这样的父任务,它们都在分解完子任务后进入阻塞等待状态。此时,队列里的子任务可能没有空闲的线程来执行,而执行父任务的线程又在等待子任务完成。这就造成了线程饥饿,甚至可能导致死锁——所有线程都在等待永远不会开始执行的任务。
这种情况下,ThreadPoolExecutor
无法有效利用 CPU 资源,并行计算的目标落空。
ForkJoinPool 的设计哲学:分治与工作窃取
正是为了解决上述问题,ForkJoinPool
(自 JDK 7 引入) 应运而生。它专为计算密集型且可递归分解(遵循分治思想)的任务而设计。其核心设计哲学包含两大支柱:
-
分治任务模型 (Fork/Join Task Model):
- 提供
ForkJoinTask
及其子类(RecursiveAction
,RecursiveTask
)作为任务的基本抽象。这些任务天然支持fork()
(分解并异步提交子任务)和join()
(等待子任务完成并获取结果)操作。这使得递归算法的并行化表达更加自然和高效。
- 提供
-
工作窃取算法 (Work-Stealing Algorithm):
-
这是
ForkJoinPool
与ThreadPoolExecutor
最本质的区别,也是其高性能的关键。它不依赖于单一的中央共享队列,而是为每个工作线程分配一个本地的双端队列 (Deque) 来存储任务。 -
工作流程:
- 当一个线程产生新的子任务时(
fork()
),它通常将子任务放入自己队列的头部。 - 当线程需要执行任务时,它优先从自己队列的头部取出任务来执行(LIFO - 后进先出)。这有利于利用 CPU 缓存,因为刚分解出的子任务通常与父任务处理的数据相关(数据局部性)。
- 当一个线程自己的队列为空时,它不会闲着,而是变成一个“小偷”,随机选择另一个忙碌线程,并尝试从那个线程队列的尾部“窃取”一个任务来执行(FIFO - 先进先出)。队列尾部的任务通常是较早被分解的、粒度可能更大的任务。
- 当一个线程产生新的子任务时(
-
优势:
- 极高的 CPU 利用率: 只要池中有任务,空闲线程就会主动寻找工作,最大限度地利用 CPU 核心。
- 动态负载均衡: 任务自动从繁忙线程流向空闲线程,无需中央调度器。
- 减少锁竞争: 大部分时间线程都在操作自己的本地队列,只有在窃取时才需要访问其他线程的队列(且有优化机制),相比所有线程竞争一个共享队列,大大减少了同步开销。
- 感知阻塞:
ForkJoinPool
对任务执行中的join()
等待有感知,并且可以通过ManagedBlocker
机制处理外部阻塞,避免整个池被少数阻塞任务拖垮。
-
可以这样理解:ThreadPoolExecutor
像是一个任务分发中心,适合处理大量独立的“工单”;而 ForkJoinPool
更像是一个自组织的协作团队,每个成员(线程)既能独立完成自己的部分(本地任务),又能主动帮助别人(窃取任务),特别擅长合力完成一个需要层层分解的大项目。
核心概念解析
ForkJoinPool:不仅仅是另一个线程池
虽然 ForkJoinPool
也是 ExecutorService
的一种实现,可以执行 Runnable
和 Callable
,但它的设计目标和内部机制使其与 ThreadPoolExecutor
有着显著不同。
- 构造方式:
new ForkJoinPool()
:创建具有默认并行度(通常是 CPU 核心数)的池。new ForkJoinPool(int parallelism)
:指定并行度(工作线程数量)。ForkJoinPool.commonPool()
:获取全局共享的公共池(后面会详细讨论)。
- 核心参数:
parallelism
:并行级别,即期望并发执行任务的工作线程数量。factory
:线程工厂,用于创建工作线程 (ForkJoinWorkerThread
)。handler
:未捕获异常处理器。asyncMode
:异步模式。true
表示外部提交的任务遵循 FIFO(队列),false
(默认)表示 LIFO(栈)。这主要影响非ForkJoinTask
的提交和invoke()
等外部入口方法的行为,内部工作窃取总是 LIFO(本地)+ FIFO(窃取)。
工作窃取(Work-Stealing):动态负载均衡的魔法
工作窃取是 ForkJoinPool
的灵魂。让我们通过一个简单的图示来理解这个过程(文本模拟):
假设有 3 个工作线程 (T1, T2, T3),每个线程都有一个双端队列 (D1, D2, D3)。
<-- T1 (忙) <-- <-- T2 (忙) <-- <-- T3 (空闲) <--
[任务A1] Head (LIFO Pop) [任务B1] Head (LIFO Pop) [] Head (空闲)
[任务A2] [任务B2]
[任务A3] [任务B3] Tail (FIFO Steal)
[任务A4] Tail (FIFO Steal)
- 本地执行 (LIFO): T1 和 T2 都在忙碌。它们执行任务时,会从各自队列的头部(Head)
pop
任务。例如,T1 执行 A1,T2 执行 B1。如果它们在执行过程中fork
出新任务(如 A1.1, B1.1),会push
到各自队列的头部。 - 窃取 (FIFO): T3 当前是空闲的,它的队列是空的。它会随机选择一个目标,比如 T1。T3 会尝试从 T1 队列的尾部(Tail)
steal
(窃取)一个任务。在这个例子中,它可能会偷走任务 A4。 - 继续执行: T3 偷到 A4 后开始执行。如果 T1 继续执行并完成了 A1, A2, A3,它的队列变空,它也可能去窃取 T2 或 T3(如果 T3 执行 A4 时又分解了任务)的任务。
为何 LIFO 本地 + FIFO 窃取?
- LIFO 本地: 刚
fork
出的子任务通常与当前任务处理的数据最相关,放在头部优先处理可以利用 CPU 缓存的局部性原理,提高单线程执行效率。 - FIFO 窃取: 队列尾部的任务通常是较早被
fork
的,可能是更大块的父任务或分解层级较高的任务。偷取这种任务,可以让“小偷”线程忙碌更长时间,减少窃取的频率和同步开销。
这种设计在效率和开销之间取得了精妙的平衡。
ForkJoinTask:任务的基本单元
ForkJoinTask<V>
是所有能在 ForkJoinPool
中执行的任务的抽象基类。它比 Runnable
或 Callable
更轻量级,并且内置了 fork()
和 join()
的语义。
- 核心方法:
fork()
: 异步安排任务执行。通常是提交给当前线程的队列头部。立即返回。join()
: 同步等待任务完成。如果任务未完成,阻塞当前线程(但该线程可能去帮助执行其他任务)。返回计算结果(如果是RecursiveTask
)。invoke()
: 同步执行任务并等待完成。阻塞调用者直到任务结束。通常用于启动顶层任务。get()
: 类似于Future.get()
,等待任务完成并获取结果,会抛出受检异常。join()
则抛出非受检异常。isDone()
: 检查任务是否已完成(正常或异常)。complete(V value)
/completeExceptionally(Throwable ex)
: 手动完成任务或使其异常完成。reinitialize()
: 重置任务状态以便重新运行(需谨慎使用)。
ForkJoinTask
有两个常用的具体子类:
RecursiveAction:无返回值的递归任务
如果你的任务只是执行某些操作(例如修改共享数据结构、打印、初始化等),而不需要返回一个计算结果给调用者,就使用 RecursiveAction
。你需要重写其 compute()
方法,该方法返回 void
。
// 伪代码示例
class MyAction extends RecursiveAction {
// ... 任务所需数据
public MyAction(/*... data ...*/) {
// ... 初始化
}
@Override
protected void compute() {
if (/* 问题足够小,可以直接处理 */) {
// ... 执行具体操作
} else {
// ... 分解成子任务
MyAction subtask1 = new MyAction(/*...*/);
MyAction subtask2 = new MyAction(/*...*/);
// 安排子任务执行 (多种方式)
// 方式一:fork/join (常用)
// subtask1.fork(); // 异步执行subtask1
// subtask2.compute(); // 同步执行subtask2 (利用当前线程)
// subtask1.join(); // 等待subtask1完成
// 方式二:invokeAll (一次性安排并等待)
invokeAll(subtask1, subtask2);
}
}
}
RecursiveTask:有返回值的递归任务
如果你的任务需要进行计算,并将计算结果返回给调用者(通常是分解它的父任务),就使用 RecursiveTask<V>
。你需要重写其 compute()
方法,该方法需要返回一个 V
类型的结果。
// 伪代码示例
class MyTask extends RecursiveTask<ResultType> {
// ... 任务所需数据
public MyTask(/*... data ...*/) {
// ... 初始化
}
@Override
protected ResultType compute() {
if (/* 问题足够小,可以直接计算 */) {
// ... 计算并返回结果
return directCompute();
} else {
// ... 分解成子任务
MyTask subtask1 = new MyTask(/*...*/);
MyTask subtask2 = new MyTask(/*...*/);
// 安排子任务执行并合并结果
subtask1.fork(); // 异步执行subtask1
ResultType result2 = subtask2.compute(); // 同步计算subtask2
ResultType result1 = subtask1.join(); // 等待subtask1完成并获取结果
// 合并结果
return combineResults(result1, result2);
}
}
}
选择 RecursiveAction
还是 RecursiveTask
?
- 子任务执行后是否需要向父任务提供一个独立的值用于后续计算或合并?
- 是:使用
RecursiveTask<V>
。 - 否:使用
RecursiveAction
。
- 是:使用
核心方法辨析:fork()
, join()
, invoke()
这三个方法是驱动 ForkJoinTask
执行的核心:
-
fork()
≈ “安排下去” (异步提交):- 行为: 将任务推入工作队列(通常是当前线程的队列头)。
- 阻塞性: 非阻塞,立即返回。
- 用途: 用于分解任务,将子任务提交给池进行调度。
-
join()
≈ “结果拿来” (同步等待与获取结果):- 行为: 检查任务是否已完成。如果完成,立即返回结果(
RecursiveTask
)或null
(RecursiveAction
)。如果未完成,阻塞当前线程,但该线程在等待期间可能会执行其他任务(工作窃取或帮助完成正在等待的任务)。 - 阻塞性: 可能阻塞。
- 用途: 用于获取已
fork
出去的子任务的结果,以进行合并。
- 行为: 检查任务是否已完成。如果完成,立即返回结果(
-
invoke()
≈ “现在就做完,我等着” (同步执行与等待):- 行为: 立即开始执行当前任务(如果当前线程是
ForkJoinWorkerThread
且任务未被偷走,可能直接在当前线程执行)。阻塞调用者,直到任务完全执行完毕(包括其内部可能fork
和join
的所有子任务)。返回最终结果。 - 阻塞性: 阻塞。
- 用途: 通常用于从外部提交顶层任务给
ForkJoinPool
并获取最终结果。例如pool.invoke(rootTask)
。
- 行为: 立即开始执行当前任务(如果当前线程是
理解 join()
的“智能”阻塞:
ForkJoinPool
中的 join()
阻塞与普通的 Thread.sleep()
或 Object.wait()
不同。当一个工作线程调用 task.join()
发现 task
尚未完成时,它并不会完全停止工作。它会:
- 尝试查看自己队列中是否有其他任务,如果有,则执行它们。
- 如果自己队列为空,尝试去窃取其他线程的任务来执行。
- 甚至可能尝试帮助完成它正在等待的那个
task
(如果这个task
正在等待更深层次的子任务)。
这种机制称为**“帮助者”模式 (Helper Pattern)**,它极大地提高了线程利用率,缓解了因任务依赖等待而造成的性能损失。这是 ForkJoinPool
相比 ThreadPoolExecutor
在处理递归依赖任务时更高效的关键原因之一。
深入内部机制
现在我们更深入地了解 ForkJoinPool
的内部构造。
WorkQueue:为工作窃取量身定做的双端队列
ForkJoinPool
中的每个 ForkJoinWorkerThread
都拥有一个 WorkQueue
实例。WorkQueue
是一个高度优化的、专门为工作窃取设计的双端队列(实现了 Deque 接口,但并非通用 Deque)。
- 数据结构: 通常基于数组实现,以获得更好的缓存局部性。
- 核心字段 (简化概念):
array
: 存储ForkJoinTask
的数组。top
: 指向队列头部(LIFO 端)的下一个可用槽位/任务索引。本地push
和pop
在这里操作。base
: 指向队列尾部(FIFO 端)的下一个任务索引。窃取者 (steal
) 在这里操作。scanState
: (JDK 8+) 用于协调扫描和窃取的状态,减少锁竞争。
- 线程安全与优化:
- 本地操作 (push/pop): 主要由队列的所有者线程执行,通常使用CAS (Compare-And-Swap) 操作来更新
top
索引,避免了重量级锁,非常快。 - 窃取操作 (steal): 由其他线程执行,需要访问
base
和array
中的元素。这里也大量使用了 CAS 和内存屏障 (volatile
读写base
等) 来保证可见性和原子性,尽量减少锁的使用。steal
操作比本地操作要复杂和慢一些,但相比全局锁依然高效很多。 - 避免伪共享 (False Sharing):
WorkQueue
的设计(如字段布局、填充)考虑了 CPU 缓存行,以减少不同线程访问看似无关但位于同一缓存行的字段时产生的性能问题。
- 本地操作 (push/pop): 主要由队列的所有者线程执行,通常使用CAS (Compare-And-Swap) 操作来更新
WorkQueue
的设计是 ForkJoinPool
高性能的关键基石,它通过精巧的无锁/低锁并发控制,实现了高效的本地任务处理和跨线程任务窃取。
任务状态(status
):洞察任务生命周期
ForkJoinTask
内部维护一个 volatile int status
字段,用于表示任务的当前状态。这个字段通过位运算存储了多种信息,主要包括:
- 完成状态 (Completion Status):
NORMAL
(0): 正常完成。CANCELLED
(-1): 被取消。EXCEPTIONAL
(-2): 异常完成。SIGNAL
(-3): 表示有线程正在等待该任务完成 (join)。
- 运行状态: 任务是否正在运行。
- 标记位: 是否是根任务等。
这些状态值通过 CAS 操作进行更新,确保原子性和可见性。join()
, isDone()
, getException()
等方法都会读取和判断 status
字段。例如,join()
会检查 status
是否为负数(表示已完成或正在等待),如果不是,则尝试将其 CAS 设置为 SIGNAL
并进入等待/帮助逻辑。
关键源码浅析
注意: 以下源码分析基于 OpenJDK 的某个版本,可能与你使用的具体 JDK 版本略有差异,但核心思想一致。注释为中文,旨在帮助理解。
fork()
的背后:任务入队
// ForkJoinTask.java
public final ForkJoinTask<V> fork() {
Thread t;
// 检查当前线程是否是 ForkJoinWorkerThread
if ((t = Thread.currentThread()) instanceof ForkJoinWorkerThread)
// 如果是,直接调用该工作线程的 workQueue 的 push 方法将任务入队
((ForkJoinWorkerThread)t).workQueue.push(this);
else
// 如果当前线程不是 ForkJoinWorkerThread(例如,从外部提交任务)
// 则使用 commonPool(如果存在)或默认行为将任务放入外部提交队列
ForkJoinPool.common.externalPush(this);
return this; // fork() 立即返回任务本身
}
// ForkJoinPool.java (WorkQueue inner class)
final void push(ForkJoinTask<?> task) {
ForkJoinTask<?>[] a; // 任务数组
ForkJoinPool p; // 所属的 ForkJoinPool
int b = base, s = top, n; // b: 队尾索引, s: 队头索引, n: 数组长度
// CAS 操作前先获取数组引用,防止扩容导致的问题
if ((a = array) != null) { // ignore if queue removed
int m = a.length - 1; // 数组索引掩码 (用于环形数组计算)
// 将任务放入队头位置 (环形数组)
U.putOrderedObject(a, ((m & s) << ASHIFT) + ABASE, task); // 使用 Unsafe.putOrderedObject 保证可见性
// CAS 更新队头索引 top (s -> s + 1)
// 这是本地操作,期望只有一个线程执行,通常能成功
U.putOrderedInt(this, QTOP, s + 1); // 保证 s + 1 的写入最终可见
// 如果队列增长超过了 base (意味着队列非空或即将非空)
if ((s - b) <= 1) {
// 队列可能之前是空的,或者只有一个任务(刚刚被偷走)
// 需要唤醒可能在等待任务的线程
if ((p = pool) != null)
// signalWork() 可能会唤醒一个等待的线程,或者创建一个新线程(如果需要)
p.signalWork(p.workQueues, this);
}
// 如果队列中任务数量超过了一个阈值(可能是为了触发窃取或检查)
else if (s == b + m) // grow if array is full
// 队列满了,尝试扩容
growArray();
}
}
理解帮助: fork()
非常轻量。如果是工作线程调用,它只是尝试将任务快速放入自己的队列头(通过 CAS)。如果是外部线程,则放入一个特殊的外部队列。关键在于它不等待任务执行。push
方法展示了无锁队列操作的核心:CAS 更新索引,putOrdered
保证数据写入的可见性,并在特定条件下(如队列变空或变满)触发池的进一步动作(唤醒、扩容)。
join()
的智慧:等待与“帮助”
// ForkJoinTask.java
public final V join() {
int s;
// doJoin() 返回任务状态,如果状态 <= NORMAL (即完成或取消/异常)
if ((s = doJoin() & DONE_MASK) != NORMAL)
// 如果不是正常完成,则报告异常或取消
reportException(s);
// 正常完成,返回结果 (getRawResult 是获取结果的方法,对于 Action 返回 null)
return getRawResult();
}
private int doJoin() {
int s; Thread t; ForkJoinWorkerThread wt; ForkJoinPool.WorkQueue w;
// 检查任务状态,如果已经完成 (status < 0),直接返回状态
return (s = status) < 0 ? s :
// 如果当前线程是 ForkJoinWorkerThread
((t = Thread.currentThread()) instanceof ForkJoinWorkerThread) ?
// 调用工作线程的 pool 的 awaitJoin 方法处理等待逻辑
(wt = (ForkJoinWorkerThread)t).pool.awaitJoin(w = wt.workQueue, this, 0L) :
// 如果当前线程不是 ForkJoinWorkerThread (外部线程),调用 externalAwaitDone
externalAwaitDone();
}
// ForkJoinPool.java
final int awaitJoin(WorkQueue w, ForkJoinTask<?> task, long deadline) {
int s = 0; // 任务状态
if (task != null && w != null) { // 任务和工作队列都有效
ForkJoinTask<?> prevJoin = w.currentJoin; // 获取当前线程正在 join 的任务
U.putOrderedObject(w, QCURRENTJOIN, task); // 设置当前线程正在 join 的任务为 task (原子性更新)
// 循环检查任务状态,直到任务完成
while ((s = task.status) >= 0) { // status < 0 表示任务已完成
// 尝试帮助执行任务 (可能是帮助自己等待的任务,也可能是偷别的任务)
if (w.tryHelpStealer(task)) { // 如果成功帮助或窃取并执行了一个任务
// 如果帮助的就是当前要 join 的任务,检查其是否完成
if ((s = task.status) < 0) // 帮助后任务完成了
break; // 退出循环
// 帮助了别的任务,继续循环等待
}
// 如果没能帮助或窃取到任务,或者池正在终止
else if (w.base == w.top || scan(w)) { // 检查自己队列是否空,或者扫描一圈也没偷到
// 尝试让当前线程休眠等待 (如果超过了 deadline)
if (tryAwaitSignal(task, deadline)) // 如果等待超时或被唤醒
break; // 退出循环
}
}
U.putOrderedObject(w, QCURRENTJOIN, prevJoin); // 恢复之前 join 的任务
}
return s; // 返回最终的任务状态
}
理解帮助: join()
的核心逻辑在 awaitJoin
(对于工作线程) 中。它并不只是傻等。线程会记录下自己正在等待的任务 (currentJoin
),然后进入一个循环:
- 检查状态: 任务完成了吗 (
status < 0
)?完成了就退出。 - 尝试帮助 (
tryHelpStealer
): 这是关键!线程会尝试执行自己队列中的任务,或者去窃取其他队列的任务来执行。如果它碰巧执行了自己正在等待的任务(或其子任务),那么等待就可能提前结束。 - 尝试等待 (
tryAwaitSignal
): 如果实在找不到活干(队列空,也偷不到),线程才会考虑进入短暂的休眠等待状态,等待被其他线程(完成任务后)唤醒。
join()
的这种“等待时干活”的机制是 ForkJoinPool
高效的关键。
externalPush()
与 poll()
/steal()
:队列操作
externalPush()
: 用于外部线程(非ForkJoinWorkerThread
)向池提交任务。它会将任务放入一个共享的提交队列 (submission
队列) 中,并确保有工作线程被唤醒来处理这些提交。poll()
: 由工作线程调用,尝试从自己队列的头部(LIFO)取出一个任务。使用 CAS。steal()
: 由工作线程调用,尝试从另一个随机线程队列的尾部(FIFO)取出一个任务。也使用 CAS,比poll
复杂,因为涉及跨线程访问。
工作窃取的实现:scan()
与 trySteal()
当一个工作线程的本地队列为空时,它会调用 scan()
方法来寻找窃取目标:
-
scan()
:- 遍历
ForkJoinPool
中的WorkQueue
数组(一个随机的起始点)。 - 检查每个
WorkQueue
是否有任务可偷 (base != top
)。 - 如果找到潜在目标,调用
trySteal()
尝试窃取。 - 如果窃取成功,
scan
返回true
,线程开始执行偷来的任务。 - 如果遍历一圈都没偷到,
scan
返回false
,线程可能进入等待状态。
- 遍历
-
trySteal()
:- 使用 CAS 尝试原子地将目标队列的
base
索引加一,并读取原base
位置的任务。 - 需要处理并发窃取、队列为空、队列正在扩容等复杂情况。
- 成功窃取则返回任务,失败则返回
null
。
- 使用 CAS 尝试原子地将目标队列的
补偿机制:tryCompensate()
当 ForkJoinPool
检测到可能需要更多线程来维持并行度时(例如,一个线程通过 ManagedBlocker
通知即将阻塞,或者检测到所有线程都在等待 join
),它可能会调用 tryCompensate()
:
- 检查状态: 判断当前活跃线程数是否低于期望的并行度。
- 创建补偿线程: 如果需要,创建一个新的工作线程(“补偿”线程)加入池中,以弥补因阻塞而损失的计算能力。
- 管理补偿: 当阻塞结束或池状态改变时,这些补偿线程可能会被终止。
这是 ForkJoinPool
处理阻塞、防止死锁和维持吞吐量的重要机制。
实战演练:大数组求和
理论讲了不少,我们来看一个经典的 ForkJoinPool
应用:并行计算一个非常大的 long
类型数组的和。
问题定义与分治思路
问题: 给定一个 long[] array
,计算其中所有元素的总和。
分治思路:
- 基本情况 (Base Case): 如果数组片段足够小(小于某个阈值 THRESHOLD),直接用单线程循环计算这个小片段的和。
- 递归分解 (Recursive Step): 如果数组片段大于阈值:
- 将其从中间分成两半(左半部分和右半部分)。
- 创建两个新的子任务,分别负责计算左半部分和右半部分的和。
- 并行执行这两个子任务。
- 等待两个子任务都完成后,将其结果相加,得到当前片段的和。
编写 RecursiveTask
我们需要创建一个 RecursiveTask<Long>
,因为计算结果是一个 long
类型的和。
import java.util.concurrent.RecursiveTask;
public class SumTask extends RecursiveTask<Long> {
// 设定一个阈值,小于这个值的数组片段将不再分解,直接计算
// 这个值的选择对性能有影响,需要根据实际情况调整
private static final int THRESHOLD = 10000; // 例如,处理 1 万个元素
private final long[] array; // 要计算的数组
private final int start; // 计算的起始索引(包含)
private final int end; // 计算的结束索引(不包含)
/**
* 构造函数
* @param array 要计算的数组
* @param start 起始索引
* @param end 结束索引
*/
public SumTask(long[] array, int start, int end) {
if (start < 0 || end > array.length || start >= end) {
throw new IllegalArgumentException("Invalid start or end index");
}
this.array = array;
this.start = start;
this.end = end;
}
/**
* ForkJoinTask 的核心方法,定义计算逻辑
* @return 计算结果(部分和或总和)
*/
@Override
protected Long compute() {
// 计算当前任务负责的数组片段长度
int length = end - start;
// === 基本情况 (Base Case) ===
if (length <= THRESHOLD) {
// 如果片段长度小于或等于阈值,直接在本线程内计算和
// System.out.printf("Thread %s computing sum for range [%d, %d)\n", Thread.currentThread().getName(), start, end);
long sum = 0;
for (int i = start; i < end; i++) {
sum += array[i];
}
return sum;
}
// === 递归分解 (Recursive Step) ===
else {
// 如果片段长度大于阈值,则进行分解
// 计算中间点索引
int mid = start + (length / 2);
// 创建左半部分的子任务
SumTask leftTask = new SumTask(array, start, mid);
// 创建右半部分的子任务
SumTask rightTask = new SumTask(array, mid, end);
// System.out.printf("Thread %s forking tasks for ranges [%d, %d) and [%d, %d)\n", Thread.currentThread().getName(), start, mid, mid, end);
// **并行执行子任务**
// 策略:异步执行左子任务,同步执行右子任务(充分利用当前线程)
// 1. 安排左子任务异步执行 (提交给 ForkJoinPool)
leftTask.fork();
// 2. 同步执行右子任务 (直接调用 compute, 利用当前线程计算)
// 这样做比两个都 fork() 然后 join() 可以减少一次 fork/join 开销
Long rightResult = rightTask.compute();
// 3. 等待左子任务完成并获取其结果
// 如果 leftTask 尚未完成,当前线程会阻塞,但可能帮助执行其他任务
Long leftResult = leftTask.join();
// 4. 合并左右子任务的结果
return leftResult + rightResult;
// 备选策略:两个都 fork
// leftTask.fork();
// rightTask.fork();
// Long leftResult = leftTask.join();
// Long rightResult = rightTask.join();
// return leftResult + rightResult;
// 备选策略:使用 invokeAll
// invokeAll(leftTask, rightTask);
// Long leftResult = leftTask.join(); // join 此时会立即返回,因为 invokeAll 已等待完成
// Long rightResult = rightTask.join();
// return leftResult + rightResult;
}
}
}
执行任务与获取结果
现在我们需要创建 ForkJoinPool
,创建顶层的 SumTask
,并使用 invoke()
方法来启动计算并获取最终结果。
import java.util.concurrent.ForkJoinPool;
import java.util.stream.LongStream;
public class ForkJoinSumCalculator {
public static void main(String[] args) {
// 准备一个大数组
int arraySize = 20_000_000; // 两千万个元素
long[] numbers = LongStream.rangeClosed(1, arraySize).toArray();
// 方法一:使用 ForkJoinPool
System.out.println("Calculating sum using ForkJoinPool...");
long startTimeForkJoin = System.currentTimeMillis();
// 创建一个 ForkJoinPool (可以使用 commonPool 或自定义)
// ForkJoinPool pool = ForkJoinPool.commonPool(); // 使用公共池
ForkJoinPool pool = new ForkJoinPool(); // 创建自定义池 (默认并行度=CPU核心数)
// System.out.println("Pool parallelism: " + pool.getParallelism());
// 创建顶层任务,负责计算整个数组
SumTask rootTask = new SumTask(numbers, 0, numbers.length);
// 提交顶层任务给 ForkJoinPool 并同步等待结果
// invoke() 会阻塞直到任务完成
long resultForkJoin = pool.invoke(rootTask);
long endTimeForkJoin = System.currentTimeMillis();
System.out.printf("ForkJoinPool Sum: %d, Time taken: %d ms\n", resultForkJoin, (endTimeForkJoin - startTimeForkJoin));
// 关闭自定义的 ForkJoinPool (如果使用的是 commonPool 则不需要关闭)
pool.shutdown();
// 方法二:使用单线程循环计算作为对比
System.out.println("\nCalculating sum using single thread loop...");
long startTimeSingle = System.currentTimeMillis();
long sumSingle = 0;
for (long number : numbers) {
sumSingle += number;
}
long endTimeSingle = System.currentTimeMillis();
System.out.printf("Single Thread Sum: %d, Time taken: %d ms\n", sumSingle, (endTimeSingle - startTimeSingle));
// 方法三:使用并行流 (底层也是 ForkJoinPool.commonPool())
System.out.println("\nCalculating sum using Parallel Stream...");
long startTimeParallelStream = System.currentTimeMillis();
long sumParallelStream = LongStream.rangeClosed(1, arraySize).parallel().sum();
long endTimeParallelStream = System.currentTimeMillis();
System.out.printf("Parallel Stream Sum: %d, Time taken: %d ms\n", sumParallelStream, (endTimeParallelStream - startTimeParallelStream));
// 验证结果
System.out.println("\nResults match: " + (resultForkJoin == sumSingle && sumSingle == sumParallelStream));
}
}
运行结果(示例,具体时间取决于机器配置):
Calculating sum using ForkJoinPool...
ForkJoinPool Sum: 200000010000000, Time taken: 18 ms
Calculating sum using single thread loop...
Single Thread Sum: 200000010000000, Time taken: 45 ms
Calculating sum using Parallel Stream...
Parallel Stream Sum: 200000010000000, Time taken: 15 ms
Results match: true
可以看到,在这个计算密集型的任务上,ForkJoinPool
和并行流都显著快于单线程循环。
阈值(Threshold)的重要性:平衡开销与并行度
SumTask
中的 THRESHOLD
常量至关重要。它决定了任务分解到什么程度才停止,转而直接计算。
-
阈值过小(例如,设置为 1):过度分解 (Over-decomposition)
- 后果: 会产生极其大量的
ForkJoinTask
对象。创建、fork
、join
这些任务对象的管理开销(内存分配、调度、同步)可能会远远超过并行计算本身节省的时间。线程会在大量微小的任务之间频繁切换和窃取,导致性能急剧下降,甚至比单线程还慢。 - 类比: 为了搬一箱苹果,雇了一百个人,每人只拿一个苹果。光是沟通协调的时间就远超搬运本身了。
- 后果: 会产生极其大量的
-
阈值过大(例如,设置为数组总长度):分解不足 (Under-decomposition)
- 后果: 任务根本不会被分解,或者只分解了很少几次。这导致产生的任务数量远少于 CPU 核心数。大部分 CPU 核心无事可做,无法发挥并行计算的优势,性能接近甚至等于单线程。
- 类比: 一箱苹果,只分给两个人搬,结果一个人搬完了在那闲着,另一个人还在吭哧吭哧搬。
-
合理的阈值:
- 目标: 使得每个“叶子节点”任务(不再分解的任务)的工作量足够大,其计算时间显著超过创建和管理它的开销;同时又足够小,能够产生足够多的任务让
ForkJoinPool
中的所有核心都能忙起来,实现良好的负载均衡。 - 如何选择?
- 经验法则: 没有绝对的公式。通常需要根据任务的计算复杂度、CPU 性能、缓存大小等因素进行经验性的估算。对于简单计算(如加法),阈值可能需要设得大一些(几千到几万);对于复杂计算,可以设得小一些。
- 基准测试 (Benchmarking): 最可靠的方法是使用性能测试工具(如 JMH - Java Microbenchmark Harness)对不同的阈值进行测试,观察性能变化曲线,找到最佳或接近最佳的值。
- 目标: 使得每个“叶子节点”任务(不再分解的任务)的工作量足够大,其计算时间显著超过创建和管理它的开销;同时又足够小,能够产生足够多的任务让
阈值是 ForkJoinPool
实践中一个关键的调优参数。 设置不当会导致性能不升反降。
何时选择 ForkJoinPool?
ForkJoinPool
是一个强大的工具,但并非万能。了解其适用场景和局限性至关重要。
适用场景的特征
判断一个问题是否适合使用 ForkJoinPool
,主要看它是否满足以下特征:
-
可分解性 (Divisible):
- 核心要求: 问题能够被递归地分解成性质相同、规模更小的子问题。这是应用“分而治之”策略的基础。
- 例子: 数组/集合处理(排序、搜索、聚合)、矩阵运算、某些图像/视频处理、文件系统遍历、编译器的某些阶段、一些复杂的科学计算或模拟。
- 反例: 任务步骤之间存在严格的线性依赖,无法并行处理(如某些迭代算法的每一步都依赖上一步的完整结果)。
-
计算密集型 (CPU-Bound):
- 核心要求: 任务的主要瓶颈在于 CPU 计算,而不是等待外部资源(如网络、磁盘 I/O、数据库)。
ForkJoinPool
旨在最大化 CPU 利用率。 - 例子: 大量数学运算、数据转换、模式匹配、加密/解密(计算部分)。
- 反例: Web 请求处理、文件读写、数据库查询、等待用户输入。
- 核心要求: 任务的主要瓶颈在于 CPU 计算,而不是等待外部资源(如网络、磁盘 I/O、数据库)。
-
子任务独立性或结果可合并 (Subtask Independence / Mergeable Results):
- 理想情况: 子任务之间没有共享可变状态的竞争,或者只读取共享数据。
- 可接受情况: 子任务会修改共享状态,但使用了高效的并发控制(如原子类、并发集合),或者合并子任务结果的操作本身不是性能瓶颈。
- 反例: 子任务需要频繁地对同一个普通对象或数据结构进行加锁修改,导致严重的锁竞争。
-
任务量足够大 (Sufficient Workload):
- 核心要求: 问题的总体计算量足够大,值得通过并行化来加速。对于非常小的任务,引入
ForkJoinPool
的开销(池初始化、任务对象创建、调度等)可能超过其带来的收益。 - 判断: 并行化是否能带来显著的性能提升(例如,数量级的提升,或者从不可接受的时间缩短到可接受的时间)。
- 核心要求: 问题的总体计算量足够大,值得通过并行化来加速。对于非常小的任务,引入
不适合的场景
- I/O 密集型任务: 如前所述,如果任务大部分时间在等待 I/O,工作线程会被阻塞。虽然
ManagedBlocker
可以缓解,但ForkJoinPool
(尤其是commonPool
,其线程数通常等于 CPU 核心数)不是为大量 I/O 阻塞设计的。这种场景下,使用一个可以配置更多线程(远超 CPU 核心数)的普通ThreadPoolExecutor
,或者专门的异步 I/O 框架(如 Netty, Vert.x, 或者CompletableFuture
结合自定义的 I/O 线程池)通常更合适。 - 任务无法有效分解: 如果问题本质上是顺序的,或者分解带来的通信/合并开销过大,强行使用
ForkJoinPool
可能效果不佳。 - 任务之间存在大量共享可变状态且同步开销高: 如果无法避免高强度的锁竞争,并行化可能得不偿失。
- 任务粒度极小且总量不大: “杀鸡焉用牛刀”。
决策流程图(文本版)
+-----------------------------------+
| 问题:是否考虑使用 ForkJoinPool? |
+-----------------------------------+
|
V
+-----------------------------------+ Yes +-----------------------------------+ Yes +-----------------------------------------+ Yes +---------------------------------+ Yes +---------------------+
| 问题能否递归分解成子问题? |---->| 任务主要是 CPU 计算密集型吗? |---->| 子任务间共享状态竞争可控/无? |---->| 总计算量足够大,值得并行吗? |---->| 推荐使用 |
| (分而治之) | | (非 IO 密集) | | (或结果合并开销小) | | (性能提升显著) | | ForkJoinPool |
+-----------------------------------+ +-----------------------------------+ +-----------------------------------------+ +---------------------------------+ +---------------------+
| No | No | No | No
V V V V
+---------------------------------------------------------------------------------------------------------------------------+
| 不适合使用 ForkJoinPool |
| 可能考虑:普通 ThreadPoolExecutor, 异步 IO 框架, 单线程, 或其他并行模型。 |
+---------------------------------------------------------------------------------------------------------------------------+
附加考虑:
- 是否已有现成的高层抽象可用(如 Parallel Streams, CompletableFuture)?优先使用它们。
- 如果涉及阻塞,是否能用 ManagedBlocker 包装?
避坑指南与性能调优
ForkJoinPool
虽然强大,但使用不当也可能导致性能问题甚至比单线程更差。
常见性能陷阱
陷阱一:任务粒度不当
- 阈值过小(太细): 调度开销 > 计算收益。表现为 CPU 利用率可能很高,但程序运行缓慢,Profiler 显示大量时间消耗在
fork()
,join()
以及相关的池管理方法上。 - 阈值过大(太粗): 任务不足,并行度低。表现为 CPU 利用率不高,部分核心空闲,性能接近单线程。
=> 解决方案: 通过基准测试调整阈值。
陷阱二:在任务中执行长时间阻塞操作(未用 ManagedBlocker
)
- 后果:
ForkJoinWorkerThread
被阻塞,无法执行任务也无法窃取。如果发生在commonPool
,会影响整个 JVM 的相关功能。如果大量线程阻塞,可能导致池“瘫痪”或死锁。 - 典型阻塞操作:
- 同步 I/O(文件读写、网络请求)
Thread.sleep()
Object.wait()
- 获取外部锁(
synchronized
块,ReentrantLock.lock()
) - 调用其他可能阻塞的第三方库方法
=> 解决方案:
* 避免阻塞: 尽量在 ForkJoinTask
中只做纯计算。将 I/O 等操作移到任务分解之前或合并之后,或者使用异步 I/O。
* 使用 ManagedBlocker
: 如果阻塞不可避免,必须使用 ForkJoinPool.managedBlock()
将其包装起来,告知池需要补偿。
* 使用自定义池: 对于可能包含阻塞操作的任务,使用独立的 ForkJoinPool
实例,避免污染 commonPool
。
陷阱三:过度的对象创建
- 后果:
compute()
方法可能会被递归调用很多次。如果在compute
内部(特别是基本情况或分解逻辑中)创建大量临时对象,会导致频繁的 GC,增加内存压力,甚至 OOM。 - 例子: 在
compute
内部创建新的集合、大型数据结构,或者不必要的包装类对象。
=> 解决方案:
* 复用对象: 尽可能复用对象,避免在循环或递归深处创建新对象。
* 优化数据结构: 使用更节省内存的数据结构。
* 传递状态而非创建: 考虑通过参数传递必要的状态,而不是每次都创建包含状态的新对象。
陷阱四:共享数据竞争
- 后果: 如果子任务需要频繁读写同一个非线程安全的共享数据结构,并且使用了重量级锁(如
synchronized
)进行同步,那么锁竞争的开销可能会抵消并行带来的好处。 - 例子: 多个子任务都向同一个
HashMap
或ArrayList
添加元素。
=> 解决方案:
* 无共享: 设计任务使得子任务处理数据的独立部分,避免共享。
* 不可变共享: 只读取共享数据,不修改。
* 并发集合: 使用 JUC 提供的线程安全集合,如 ConcurrentHashMap
, CopyOnWriteArrayList
(后者写入开销大,慎用)。
* 原子操作: 使用 AtomicInteger
, AtomicLong
, AtomicReference
等进行原子更新。
* 结果合并: 让每个子任务计算局部结果,最后在父任务中安全地合并这些局部结果。
陷阱五:滥用 commonPool
- 后果:
commonPool
是全局共享的。在上面运行设计不当的任务(如长时间阻塞、耗尽资源、产生大量垃圾)会影响 JVM 中所有依赖它的其他功能(并行流、CompletableFuture
默认执行等),导致难以追踪的问题。 - 例子: 在并行流的
map
操作中执行一个会阻塞的网络请求。
=> 解决方案:
* 隔离: 对于不可控、可能行为不端的任务,或者需要精细资源控制的关键任务,创建并使用独立的 ForkJoinPool
实例。
* 谨慎使用: 确保提交给 commonPool
的任务是纯计算密集型、行为良好且不会长时间阻塞的。
监控与诊断
当怀疑 ForkJoinPool
存在性能问题时,需要进行监控和诊断。
ForkJoinPool
自带指标
ForkJoinPool
类提供了一些方法来获取其内部状态:
getParallelism()
: 返回池的并行级别(目标线程数)。getPoolSize()
: 返回池中当前实际的线程总数(包括活跃和空闲的)。可能因补偿机制临时超过parallelism
。getActiveThreadCount()
: 返回当前正在活跃执行任务(非空闲、非阻塞等待)的线程数(估计值)。理想情况下接近parallelism
。getRunningThreadCount()
: 返回当前未被阻塞等待join
的线程数(估计值)。如果该值远小于getPoolSize
,可能表示很多线程在等待join
。getQueuedTaskCount()
: 返回等待执行的任务总数(估计值,包括所有工作队列中的)。getQueuedSubmissionCount()
: 返回外部提交队列中等待执行的任务数。getStealCount()
: 返回发生窃取的总次数。这是一个重要的健康指标。如果值很低,可能表示任务太少、任务太粗或者负载不均。如果值非常高,可能表示任务太细或者竞争激烈。hasQueuedSubmissions()
: 检查是否有外部提交的任务在排队。toString()
: 提供一个包含上述大部分指标的摘要字符串,方便快速查看。
可以通过定期打印这些指标或将其暴露给监控系统来观察池的运行状态。
JMX 监控
ForkJoinPool
实现了 ForkJoinPoolMXBean
接口,可以通过 JMX(Java Management Extensions)进行更详细的监控。使用 JConsole, VisualVM 或其他 JMX 客户端连接到运行的 JVM 进程,找到 java.util.concurrent
下的 ForkJoinPool
MBean,即可实时查看上述指标以及一些额外的统计信息。这是生产环境中监控 ForkJoinPool
的标准方式。
性能分析工具(Profilers)
如果指标显示异常(如 CPU 低、窃取少、线程阻塞多)或性能不达预期,就需要使用性能分析工具(Profiler)进行深入诊断。
- 常用工具: JProfiler, YourKit, VisualVM (自带采样 Profiler), Arthas (阿里巴巴开源的在线诊断工具)。
- 分析重点:
- CPU 热点 (CPU Hotspots): 查看哪个方法消耗了最多的 CPU 时间。是你的
compute
逻辑本身,还是fork
/join
开销,或者是 GC? - 线程状态 (Thread States): 查看
ForkJoinWorkerThread
的状态。有多少处于 RUNNABLE(正常运行)?多少处于 BLOCKED(等待锁)?多少处于 WAITING/TIMED_WAITING(等待join
或ManagedBlocker
)?是否存在大量线程长时间处于非 RUNNABLE 状态? - 锁竞争 (Lock Contention): 如果使用了显式锁或
synchronized
,分析锁竞争情况。 - 内存分配 (Memory Allocation): 查看对象的创建频率和大小,定位内存热点,分析 GC 活动。
- 方法调用图 (Call Tree / Flame Graph): 理解程序的执行流程和时间分布。
- CPU 热点 (CPU Hotspots): 查看哪个方法消耗了最多的 CPU 时间。是你的
Profiler 是定位 ForkJoinPool
相关性能瓶颈的终极武器。
调优策略
根据监控和诊断的结果,可以采取以下调优策略:
调整阈值
- 方法: 使用基准测试(如 JMH)系统性地测试不同阈值下的性能。绘制性能曲线,找到拐点或峰值。
- 考虑因素: 任务计算成本。计算越耗时,阈值可以相对设小;计算越简单,阈值需要设大。
调整并行度
- 默认值:
Runtime.getRuntime().availableProcessors()
通常是 CPU 密集型任务的良好起点。 - 何时调整?
- 如果任务包含少量可接受的、用
ManagedBlocker
包装的阻塞,可以适度增加并行度(例如cores + 1
或cores * 1.x
),需要测试验证效果。过度增加可能导致上下文切换开销增大。 - 如果运行在容器化环境(如 Docker)中,确保 JVM 能正确识别分配到的 CPU 核心数。旧版 JVM 可能需要显式配置
-XX:ActiveProcessorCount
。 - 只对自定义的
ForkJoinPool
进行调整。不要试图改变commonPool
的并行度(虽然可以通过系统属性,但不推荐)。
- 如果任务包含少量可接受的、用
拥抱 ManagedBlocker
- 原则: 任何在
ForkJoinTask
中不可避免的、可能长时间阻塞当前线程的操作,都应该包装在ManagedBlocker
中,并通过ForkJoinPool.managedBlock()
执行。 - 目标: 让
ForkJoinPool
感知到阻塞,并有机会创建补偿线程。
优化代码逻辑与数据结构
- 减少
compute
内部开销: 避免冗余计算、不必要的对象创建。 - 优化数据局部性: 考虑数据布局,使得子任务处理的数据在内存中尽可能连续,以提高缓存命中率。
- 减少同步: 优先使用无共享、不可变、并发集合、原子类等方式,避免使用重量级锁。
- 优化合并操作: 如果
join
后的结果合并操作成为瓶颈,需要优化合并算法。
隔离 commonPool
- 原则: 对于核心业务、长时间运行、行为不确定或需要资源隔离的任务,创建独立的
ForkJoinPool
实例。 - 好处: 避免对
commonPool
造成污染,便于独立监控、配置和管理生命周期。 - 责任: 必须手动管理自定义池的生命周期,用完后调用
shutdown()
并awaitTermination()
。
性能调优是一个迭代的过程:监控 -> 分析 -> 调整 -> 再监控。不要凭感觉猜测,要用数据说话。
深度话题探讨
commonPool
:便捷与风险并存
ForkJoinPool.commonPool()
是 JDK 提供的一个静态、全局共享的 ForkJoinPool
实例。
- 便捷性:
- 开箱即用,无需手动创建和管理。
- Java 8 的并行流 (
parallelStream()
) 和CompletableFuture
的异步方法 (如supplyAsync
无Executor
参数版本) 默认使用它。这极大地简化了并行和异步编程的入门。
- 初始化:
- 它是一个延迟初始化的静态实例。第一次被访问时(例如,第一次调用
commonPool()
或执行并行流)才会创建。 - 并行度通常是
Runtime.getRuntime().availableProcessors() - 1
(至少为 1)。可以通过系统属性java.util.concurrent.ForkJoinPool.common.parallelism
来覆盖,但不推荐随意修改。 - 使用默认的线程工厂和异常处理器。
- 它是一个延迟初始化的静态实例。第一次被访问时(例如,第一次调用
- 风险:
- 缺乏隔离: 整个 JVM 共享一个池。一个地方滥用(如提交阻塞任务)会影响所有使用者。
- 难以定制: 无法为其指定特定的线程名称、异常处理器等。
- 无法关闭: 不能调用
shutdown()
。其生命周期与 JVM 绑定。 - 类加载器问题: 在某些复杂的应用(如应用服务器、OSGi 环境)中,
commonPool
的静态特性和线程上下文类加载器可能引发问题。
使用建议:
- 适合场景: 快速原型开发、简单的并行计算、对性能和资源隔离要求不高的场景。
- 避免场景: 核心业务逻辑、长时间运行的任务、可能阻塞的任务、需要精细控制或监控的任务、库代码(库不应污染调用者的
commonPool
)。 - 原则: 如果不确定,或者对任务的行为和影响有疑虑,优先使用自定义的
ForkJoinPool
实例。
ManagedBlocker
:为阻塞操作正名
设计意图与机制
ManagedBlocker
接口是 ForkJoinPool
提供的一个钩子 (Hook),允许在 ForkJoinTask
内部执行外部阻塞操作时,通知 ForkJoinPool
。
-
接口方法:
boolean block() throws InterruptedException;
: 执行实际的阻塞操作。如果确实发生了阻塞,应返回true
;如果无需阻塞或阻塞操作已完成,应返回false
。可以抛出InterruptedException
。boolean isReleasable();
: 检查阻塞状态是否可以解除(例如,锁是否可用、IO 是否就绪)。此方法必须是非阻塞的,并且要足够快。
-
使用方式:
ForkJoinPool.managedBlock(new ManagedBlocker() {
@Override
public boolean isReleasable() {
// 检查阻塞条件是否解除 (非阻塞)
return /* condition */;
}
@Override
public boolean block() throws InterruptedException {
// 循环直到可以解除阻塞
while (!isReleasable()) {
// 执行实际的阻塞等待操作,例如:
// someLock.lockInterruptibly();
// condition.await();
// socket.read(); // 注意:同步 IO 本身就是阻塞的
if (Thread.interrupted()) throw new InterruptedException();
// 可能需要短暂 sleep 或 park
}
return true; // 表示阻塞已发生并解除
}
});
- 解决的问题:
当工作线程调用ForkJoinPool.managedBlock()
时:ForkJoinPool
得知该线程即将进入“可管理的阻塞”状态。- 它会检查当前活跃线程数是否低于目标并行度。
- 如果低于,它可能会创建并启动一个新的补偿线程来维持池的吞吐量。
- 然后调用
blocker.block()
执行阻塞操作。 block()
方法内部通常会循环调用isReleasable()
检查状态,并在条件满足时返回。managedBlock()
返回后,如果之前创建了补偿线程,该补偿线程可能会在稍后被终止(如果池中线程过多)。
ManagedBlocker
通过这种方式,让 ForkJoinPool
能够区分内部的 join
等待(可以通过帮助者模式缓解)和外部的阻塞(需要补偿线程),从而在任务包含不可避免的阻塞时,仍能维持并行处理能力,防止池“饿死”。
使用示例
假设我们需要在一个 ForkJoinTask
中获取一个全局锁:
import java.util.concurrent.ForkJoinPool;
import java.util.concurrent.ForkJoinPool.ManagedBlocker;
import java.util.concurrent.locks.ReentrantLock;
import java.util.concurrent.RecursiveAction;
import java.util.concurrent.TimeUnit;
public class ManagedBlockerExample {
private static final ReentrantLock sharedLock = new ReentrantLock();
static class LockingAction extends RecursiveAction {
private final int id;
LockingAction(int id) { this.id = id; }
@Override
protected void compute() {
System.out.printf("Task %d trying to acquire lock...\n", id);
try {
// 使用 ManagedBlocker 来包装锁获取操作
ForkJoinPool.managedBlock(new ManagedBlocker() {
private boolean acquired = false;
@Override
public boolean isReleasable() {
// 尝试非阻塞地获取锁
acquired = sharedLock.tryLock();
// 如果获取成功,则阻塞状态可以解除了
return acquired;
}
@Override
public boolean block() throws InterruptedException {
// 如果 tryLock 没成功,则阻塞等待获取锁
if (!acquired) {
sharedLock.lockInterruptibly(); // 阻塞等待
acquired = true; // 获取成功
}
// 返回 true 表示确实发生了(或可能发生)阻塞
return true;
}
});
// === 锁已获取 ===
try {
System.out.printf("Task %d acquired lock. Doing work...\n", id);
// 模拟持有锁并进行工作
TimeUnit.MILLISECONDS.sleep(500);
} finally {
sharedLock.unlock();
System.out.printf("Task %d released lock.\n", id);
}
} catch (InterruptedException e) {
System.out.printf("Task %d interrupted.\n", id);
Thread.currentThread().interrupt();
}
}
}
public static void main(String[] args) {
// 创建一个并行度为 2 的 ForkJoinPool
ForkJoinPool pool = new ForkJoinPool(2);
// 创建 4 个任务,它们都会竞争同一个锁
LockingAction task1 = new LockingAction(1);
LockingAction task2 = new LockingAction(2);
LockingAction task3 = new LockingAction(3);
LockingAction task4 = new LockingAction(4);
System.out.println("Submitting tasks...");
pool.invoke(task1); // 使用 invoke 来等待第一个任务完成(只是为了简单)
pool.execute(task2); // execute 是异步提交
pool.execute(task3);
pool.execute(task4);
// 给任务一些时间执行
pool.awaitQuiescence(5, TimeUnit.SECONDS); // 等待所有任务(近似)完成
System.out.println("\nPool Stats: " + pool);
pool.shutdown();
System.out.println("Pool shutdown.");
// 观察输出:即使池的并行度只有 2,但由于使用了 ManagedBlocker,
// 当某个任务阻塞在获取锁时,池可能会创建补偿线程来执行其他任务,
// 使得整体进度不会完全卡死。
// 如果去掉 ManagedBlocker,直接调用 lock(),则可能只有 2 个任务能同时进展。
}
}
JDK 内部的 ForkJoinPool 用户
ForkJoinPool
作为 Java 并发处理能力的基石之一,在 JDK 内部被广泛应用:
-
并行流 (Parallel Streams -
java.util.stream
):- 当你调用集合的
.parallelStream()
或流的.parallel()
方法时,其后续的中间操作(map
,filter
等)和终端操作(reduce
,collect
,forEach
等)默认由ForkJoinPool.commonPool()
执行。 Spliterator
接口负责将数据源分割成小块,这些小块的处理被包装成内部的ForkJoinTask
。
- 当你调用集合的
-
CompletableFuture
:CompletableFuture
的大多数异步方法(如supplyAsync(Supplier<U>)
,runAsync(Runnable)
,thenApplyAsync(Function<? super T,? extends U>)
等)在不提供自定义Executor
参数时,默认使用ForkJoinPool.commonPool()
来执行异步任务或回调。- 这使得
CompletableFuture
默认情况下非常适合执行 CPU 密集型的异步计算。
-
Arrays.parallelSort()
:- JDK 提供的并行排序实现。它使用
ForkJoinPool
(通常是commonPool
)来并发地执行排序算法的分解和合并步骤(例如,并行归并排序)。
- JDK 提供的并行排序实现。它使用
为何选择 ForkJoinPool?
- 天然契合分治: 流处理、排序等场景天然适合分治模型。
- 高效负载均衡: 工作窃取能很好地适应流处理中不同操作阶段的计算量变化。
- 低调度开销: 适合处理流操作可能产生的大量小任务。
- CPU 密集优化: 这些 API 的主要目标是加速 CPU 计算。
- 易用性:
commonPool
提供了方便的默认执行环境。
总结:ForkJoinPool 在并发框架中的位置
回顾 Java 并发框架的演进,ForkJoinPool
扮演了重要的角色:
- 早期 (JDK < 5): 基本的
Thread
,synchronized
,wait/notify
。并发编程困难且易错。 - JUC 诞生 (JDK 5): 引入
java.util.concurrent
包,提供ExecutorService
(ThreadPoolExecutor
),Lock
,BlockingQueue
,Atomic*
, 并发集合等。这是巨大的进步,提供了通用的并发工具。ThreadPoolExecutor
成为处理大多数并发任务的标准。 - ForkJoinPool (JDK 7): 认识到
ThreadPoolExecutor
在递归分治型 CPU 密集任务上的局限性,引入ForkJoinPool
作为特化的高性能解决方案。它通过工作窃取机制,专门优化了这类场景,旨在榨干多核 CPU 的性能。它补充而非取代ThreadPoolExecutor
。 - 高层抽象 (JDK 8+): 在
ForkJoinPool
等底层机制之上,提供了更易用的并行和异步 API,如并行流和CompletableFuture
。它们默认利用commonPool
,让开发者能更方便地利用多核能力,而无需直接操作ForkJoinTask
。
ForkJoinPool
的出现,标志着 Java 并发框架从提供“通用工具”向提供“针对特定并行模式优化的高性能工具”的演进。它是 Java 处理 CPU 密集型分治任务的核心引擎,并支撑了后续更高级别的并行和异步编程范式。
Happy coding!