Skip to content

ForkJoinPool 原理与工作窃取

提出问题

线程池(ThreadPoolExecutor)是 Java 并发编程的基石,但它在处理**分治(Divide-and-Conquer)**类型任务时力不从心。假设你需要对大数组求和、对百万级数据进行排序、或者递归地计算斐波那契数列——这些任务天然适合"先拆成小任务并行处理,再合并结果",但用普通线程池实现时,子任务之间的依赖关系(父任务需要等待子任务结果)管理起来非常麻烦,要么用 Future.get() 导致线程阻塞,要么用 CompletableFuture 编排复杂的依赖链。

JDK 7 引入的 ForkJoinPool 就是为这个场景量身定做的。它引入了一个独特的设计——工作窃取(Work-Stealing)算法——让线程池内的线程在完成自己的任务后,主动"偷"其他线程队列里的任务来执行,而不是闲着。理解 ForkJoinPool 的底层原理,不仅能写出更高效的分治程序,还能更深入理解 CompletableFuture 和 Java 8 的 parallelStream() 背后的执行引擎。

分析问题

ForkJoinPool 的核心架构

ForkJoinPool 的架构与 ThreadPoolExecutor 有本质区别:

ThreadPoolExecutor:一个全局的阻塞队列,所有线程共享,竞争同一个队列上的锁。

ForkJoinPool:每个工作线程(ForkJoinWorkerThread)持有一个双端队列(Deque),线程从自己队列的头部取任务执行;同时有一个全局的 submission queue 用于外部提交的任务。

关键数据结构:

  • WorkQueue:每个工作线程关联一个 WorkQueue,内部用 ForkJoinTask<?>[] 数组实现双端队列,通过 topbase 两个指针控制队列两端。topbase 都是 volatile 变量,保证窃取者和被窃取者看到的是同一份内存视图
  • ForkJoinTask:任务基类,两个常用子类——RecursiveTask<V>(有返回值)和 RecursiveAction(无返回值)
  • ForkJoinWorkerThread:包装了平台线程 + WorkQueue 的线程对象

ForkJoinPool 完整生命周期流程

                    外部提交 (submit/execute)


                    ┌───────────────┐
                    │ submission    │
                    │ queue (全局)  │
                    └───────┬───────┘
                            │ 空闲线程窃取
        ┌───────────────────┼───────────────────┐
        ▼                   ▼                   ▼
┌──────────────┐   ┌──────────────┐   ┌──────────────┐
│ WorkQueue[0] │   │ WorkQueue[1] │   │ WorkQueue[2] │
│ Thread T1    │   │ Thread T2    │   │ Thread T3    │
│              │   │              │   │              │
│ top → [A]    │   │ top → [D]    │   │ top → [ ]    │
│       [B]    │   │       [E]    │   │              │
│ base → [C]   │   │ base → [F]   │   │              │
└──────┬───────┘   └──────┬───────┘   └──────┬───────┘
       │                  │                  │
       │ 从头部取任务     │ 从头部取任务     │ 空闲,从 T2 尾部窃取
       ▼                  ▼                  ▼
   执行任务            执行任务            窃取 F 执行

工作窃取(Work-Stealing)算法

工作窃取的核心思想可以用一句话概括:线程优先从自己的队列头部取任务;自己的队列空了,就随机从其他线程的队列尾部"偷"一个任务来执行

为什么是窃取尾部而不是头部?

因为工作线程从自己队列的头部取任务(FIFO),窃取线程从尾部取任务(LIFO)。这样设计的好处是:

  1. 减少竞争:生产者和消费者(本线程是生产者,窃取者是消费者)操作的是队列的两端,冲突概率极低
  2. 局部性更好:后入队的任务往往是更细粒度的子任务(由当前正在执行的任务 fork 出来的),窃取较大的任务有助于负载均衡

完整的工作窃取执行时序

假设 3 个线程(T1, T2, T3),初始队列:
  T1: [A(fork: B1, B2)]
  T2: []
  T3: []

Step 1: T1 从队列头部取出 A 执行
  A 内部执行 fork() → 产生 B1, B2 两个子任务,push 到 T1 队列尾部
  T1: [B1, B2] (base=0, top=2)

Step 2: T1 继续执行 A 的下半段(compute() 右半部分)
  T2 空闲,扫描其他线程队列 → 发现 T1 队列有任务
  T2 从 T1 队列尾部窃取 B2
  T1: [B1]  ← base=0, top=1
  T2: [B2]  ← base=0, top=1

Step 3: T1 调用了 join() 等待子任务结果
  T1 不阻塞,而是从自己队列头部取 B1 执行
  T2 从自己队列头部取 B2 执行
  T1: []  ← B1 完成
  T2: []  ← B2 完成

Step 4: T1 合并 B1 和 B2 的结果,A 完成

这个时序的关键点:join() 不会阻塞线程。T1 在等待 B1 结果时,不是调用了 park() 挂起,而是从队列里取其他任务执行。这就是 ForkJoinPool 区别于 ThreadPoolExecutor 最核心的地方——"干活不等闲"

窃取竞争的 CAS 细节

当 T2 尝试从 T1 的队列尾部窃取任务时,需要做以下操作:

  1. varHandle.getAcquire() 读取 T1 队列的 basetop——这两个字段都是 volatile 的,保证 T2 看到的是 T1 线程最新写入的值
  2. 如果 base < top,说明队列非空,用 CAS 尝试修改 base(窃取者操作尾部,对应 base 指针的前移)
  3. 如果 CAS 失败(T1 也在消费头部),自旋重试

关键源码片段(JDK 17 ForkJoinPool.java):

java
// 窃取逻辑简化版
final WorkQueue<?>[] ws;  // 所有工作队列
int b = q.base;           // 读目标队列的 base(volatile 读)
int t = q.top;            // 读目标队列的 top(volatile 读)
if (b - t < 0) {          // 队列非空
    // CAS 尝试窃取:修改 base 指针
    if (Q_BASE.compareAndSet(q, b, b + 1)) {
        // 窃取成功,取出任务
        ForkJoinTask<?> task = q.array[Q_BASE.getOpaque(q) - 1];
        // 执行任务
    }
}

为什么 CAS 在这里够用:窃取者和被窃取者操作的是队列的两端,CAS 冲突概率极低。但如果队列中只剩一个元素了,窃取者和本线程同时取这个元素,CAS 会保证只有一个成功。这就是工作窃取在高并发下仍然高效的原因——两端的 CAS 几乎不冲突

top 和 base 的 volatile 语义

  • top:被本线程(生产者)通过 fork() 写入,CAS 增加。本线程的 fork() 调用完成后,top 的 volatile 写保证其他线程的 scan() 方法能读到新值
  • base:被窃取者通过 CAS 修改,本线程通过 pop() 从头部取任务时读 base,也做 volatile 读
  • 这种设计保证了窃取者 vs 本线程的可见性,不需要额外的锁或 fence

扫描策略:随机而不是轮询

ForkJoinPool 的窃取线程在扫描其他队列时,使用的是随机偏移量而不是顺序轮询。源码中通过 ThreadLocalRandom 生成一个随机种子,然后用它来索引 WorkQueue[] 数组:

java
// 简化的扫描逻辑
int r = ThreadLocalRandom.current().nextInt();
int mask = ws.length - 1;  // 数组长度必须是 2 的幂
int idx = r & mask;        // 随机起始索引

为什么用随机而不是轮询? 因为如果所有空闲线程都从同一个起始位置开始扫描,它们会同时涌向同一个队列,导致该队列的 CAS 竞争加剧。随机化让空闲线程的扫描起始点分散,减少了多个窃取者碰撞同一队列的概率。

窃取失败的退避机制

当窃取 CAS 失败(另一个线程也在偷同一个队列,或者队列被本线程消费了),ForkJoinPool 会执行渐进式退避

  1. 第一次失败:立即重试,不等待(预期是瞬时冲突)
  2. 连续失败:执行 Thread.yield() 让出 CPU,给其他线程机会
  3. 多次失败:进入 park() 休眠,等待被 signalWork() 唤醒
java
// 简化的退避逻辑
if (failed++) {
    if (failed <= 1) continue;            // 第一次失败,立即重试
    else if (failed <= 10) Thread.yield(); // 连续失败,让出 CPU
    else LockSupport.parkNanos(1L << failed); // 指数退避休眠
}

这个设计很关键:高并发场景下,线程不会一直自旋空转消耗 CPU,而是逐步降级让步。如果所有空闲线程都在死循环自旋 CAS,CPU 会被打满,反而影响正常工作的线程。

窃取完整时序:一个 4 线程池的极端场景

假设 4 个线程,所有任务都集中在 T1 队列,T2/T3/T4 空闲:

T1: [A, B, C, D, E, F, G, H]  ← 8 个任务
T2: []
T3: []
T4: []

Step 1: T1 从头部取 A 执行
Step 2: T2 随机扫描 → 发现 T1 队列,从尾部窃取 H
Step 3: T3 随机扫描 → 发现 T1 队列,从尾部窃取 G
Step 4: T4 随机扫描 → 发现 T1 队列,从尾部窃取 F
Step 5: T1 完成 A,从头部取 B
          T2 完成 H,从尾部窃取 E
          T3 完成 G,从尾部窃取 D
          T4 完成 F,从尾部窃取 C

最终:T1 执行 A,B;T2 执行 H,E;T3 执行 G,D;T4 执行 F,C
负载均衡自动完成,没有全局调度器参与

注意:T2/T3/T4 的窃取顺序是随机的,但整体趋势是最忙的线程的队列会被快速偷空,因为空闲线程越多,从该队列尾部偷走的任务越多。这是工作窃取算法天然的自平衡特性。

唤醒机制:signalWork() 的完整流程

当有新的任务提交到 ForkJoinPool 时(无论是 submit() 还是 execute()),signalWork() 方法会被调用,其核心逻辑是判断是否需要创建或唤醒工作线程:

java
// ForkJoinPool.signalWork() 简化逻辑
final boolean signalWork(WorkQueue[] ws, WorkQueue q) {
    long c = ctl;  // pool 控制变量(64 位位段)
    // 检查是否有空闲线程
    // ctl 的低 16 位编码了空闲线程数
    if ((int)(c >> AC_SHIFT) + parallelism > 0) {
        // 有空闲线程,从空闲链表中取出一个唤醒
        // 通过 CAS 修改 ctl 来取出一个空闲线程
        // 修改后,空闲线程数减 1,空闲线程栈指针更新
    } else if (totalWorkers < maxPoolSize) {
        // 没有空闲线程且未达上限,创建新线程
        tryAddWorker();
    }
    // 如果既没有空闲线程也不能创建新线程,入队等待
}

ctl 这个控制变量很巧妙:它是一个 64 位的 long,通过位段编码了以下信息:

  • 低 16 位:空闲线程数(ACTC 字段)
  • 中间位:空闲线程栈的栈顶指针(指向空闲线程的 WorkQueue)
  • 高 16 位:总目标线程数

ForkJoinPool 用 CAS 一次性修改 ctl,完成"取出一个空闲线程"的原子操作,不需要锁。这个设计跟 ThreadPoolExecutorctl 有异曲同工之妙。

分治:Fork 和 Join

ForkJoinPool 的典型用法是递归拆分:

java
public class SumTask extends RecursiveTask<Long> {
    private static final int THRESHOLD = 1000;
    private final long[] array;
    private final int start, end;
    
    public SumTask(long[] array, int start, int end) {
        this.array = array;
        this.start = start;
        this.end = end;
    }
    
    @Override
    protected Long compute() {
        int length = end - start;
        if (length <= THRESHOLD) {
            // 足够小,直接计算
            long sum = 0;
            for (int i = start; i < end; i++) {
                sum += array[i];
            }
            return sum;
        }
        // 拆分成两个子任务
        int mid = start + length / 2;
        SumTask left = new SumTask(array, start, mid);
        SumTask right = new SumTask(array, mid, end);
        left.fork();  // 异步执行子任务
        long rightResult = right.compute();  // 当前线程执行右半部分
        long leftResult = left.join();       // 等待左半部分结果
        return leftResult + rightResult;
    }
}

// 使用
ForkJoinPool pool = ForkJoinPool.commonPool();
long result = pool.invoke(new SumTask(array, 0, array.length));

fork() 做了什么?将当前任务 push 到当前工作线程的 Deque 尾部(通过 CAS 修改 top 指针,O(1) 操作,无阻塞)。join() 做了什么?检查任务是否完成,如果自己的任务还没执行完,从自己的 Deque 头部取其他任务执行(帮助其他任务更快完成),而不是阻塞等待。这就是 ForkJoinPool 的**"干活不等闲"**哲学。

join() 的三种状态流转

ForkJoinTask.join() 内部调用了 doJoin(),其核心逻辑是根据任务状态做不同处理:

java
// ForkJoinTask.doJoin() 简化逻辑
private int doJoin() {
    int s;  // 任务状态
    Thread t = Thread.currentThread();
    WorkQueue w = (t instanceof ForkJoinWorkerThread) ?
        ((ForkJoinWorkerThread)t).workQueue : null;
    
    // 检查任务状态,不阻塞,而是尝试窃取/执行
    return (s = status) < 0 ? s :  // 已完成,直接返回
        (w != null) ? w.execExternalTask(this) :  // 在池中,走工作窃取
        externalAwaitDone();  // 不在池中,阻塞等待
}

任务状态有四种:

  • NORMAL(负数,如 -1):已完成,直接返回结果
  • SIGNAL(1):有其他线程在等待这个任务完成,需要通知
  • CANCELLED(负数):已取消,抛 CancellationException
  • EXCEPTIONAL(负数):执行中抛了异常,需要包装并重新抛出

关键就在这里:join()ForkJoinPool 池中调用时,不会 park() 阻塞,而是从队列中取其他任务执行。只有当前线程不在池中(比如在主线程调用 pool.invoke()),才会走 externalAwaitDone()FutureTask.awaitDone() 阻塞等待。

异常传播:ForkJoinTask 的 completeExceptionally 机制

ForkJoinPool 处理子任务异常时有一个特殊的传播机制。当 fork() 的子任务抛出异常时,异常不会直接抛出,而是被封装在 ForkJoinTask 中,等待 join() 调用时统一抛出:

java
ForkJoinTask<Long> task = new SumTask(array, 0, 1000);
pool.execute(task);
try {
    Long result = task.join();  // 异常在这里抛出,类型为 RuntimeException
    // 实际内部是 CompletionException,包装了原始异常
} catch (CompletionException e) {
    // 原始异常通过 e.getCause() 获取
    Throwable original = e.getCause();
}

内部实现路径

  1. 当子任务的 compute() 抛出异常时,ForkJoinTask.recordExceptionalCompletion() 被调用:

    java
    // ForkJoinTask.recordExceptionalCompletion() 简化
    final int recordExceptionalCompletion(Throwable ex) {
        int s;
        // 1. 创建异常对象,存入 ForkJoinTask 的 exception 字段
        setException(ex);  // 将异常 存入 throwable 字段
        // 2. 通过 CAS 将 status 从 0 或 SIGNAL 修改为 EXCEPTIONAL
        do {
            s = status;
            if (s < 0) return s;  // 已经有其他线程完成了状态变更
        } while (!STATUS.compareAndSet(this, s, EXCEPTIONAL));
        // 3. 唤醒所有等待这个任务的线程
        signalWaiters();
        return EXCEPTIONAL;
    }
  2. join() 检测到 status < 0 且为 EXCEPTIONAL 状态时,调用 reportException() 抛出 CompletionException

    java
    // ForkJoinTask.reportException() 简化逻辑
    private void reportException(int s) {
        if (s == CANCELLED)
            throw new CancellationException();
        if (s == EXCEPTIONAL)
            rethrow(getThrowableException());  // 包装成 CompletionException 抛出
    }
  3. getThrowableException() 会在已缓存的异常上包装一层,确保 getCause() 链完整:

    java
    // 如果异常已经被设置了,直接返回;否则创建新的 CompletionException
    private Throwable getThrowableException() {
        Throwable ex = throwable;  // volatile 读
        if (ex == null) {
            ex = new CompletionException("ForkJoinTask completed exceptionally");
            // 这里不设置原始异常,因为原始异常可能已经被吞了
        }
        return ex;
    }

异常传播的完整链路

子任务 compute() 抛出 NPE
  → ForkJoinTask.recordExceptionalCompletion(NPE)
    → CAS 设置 status = EXCEPTIONAL
    → signalWaiters() 唤醒等待线程
  → 父任务 join() 检测到 status = EXCEPTIONAL
    → reportException(EXCEPTIONAL)
      → getThrowableException() 包装成 CompletionException
      → 抛出 CompletionException,其 getCause() = NPE

踩坑:异常被吞没

如果你用 fork() 但不调用 join(),异常永远不会被抛出。比如:

java
// 错误:fork 了但不 join,异常被吞
MyTask task = new MyTask(data);
task.fork();
// 没有调用 task.join(),异常不会抛出

fork() 只负责排队,不检查异常。异常对象一直挂在 ForkJoinTaskexception 字段上,如果你不 join(),它永远不会被 reportException() 抛出。这就是为什么 invokeAll() 比手动 fork() 更安全——它内部会处理异常传播。

fork() 和 invoke() 的区别

  • fork():只把任务 push 到当前线程的 WorkQueue(通过 CAS 修改 top 指针,O(1) 操作),不保证立即执行。调用后返回,继续执行后续代码。
  • invoke(task):在当前线程同步执行 compute(),如果当前线程在 ForkJoinPool 中,会走工作窃取流程;如果不在,则创建一个临时 ForkJoinWorkerThread 执行。invoke() 内部会处理异常传播,不需要手动 catch。
  • submit(task):把任务提交到 submission queue,由池中空闲线程窃取执行。返回 ForkJoinTask(也实现了 Future 接口)。
  • invokeAll(t1, t2):同时 fork 两个子任务,然后执行当前线程的那一半——这是效率最高的方式,因为减少了至少一次 fork 调用。invokeAll() 还负责异常传播:如果任意子任务抛出异常,所有子任务都会被取消。

更复杂的场景:并行归并排序

java
public class MergeSortTask extends RecursiveAction {
    private static final int THRESHOLD = 10_000;
    private final int[] arr;
    private final int left, right;
    
    public MergeSortTask(int[] arr, int left, int right) {
        this.arr = arr;
        this.left = left;
        this.right = right;
    }
    
    @Override
    protected void compute() {
        if (right - left <= THRESHOLD) {
            Arrays.sort(arr, left, right + 1);
            return;
        }
        int mid = left + (right - left) / 2;
        MergeSortTask leftTask = new MergeSortTask(arr, left, mid);
        MergeSortTask rightTask = new MergeSortTask(arr, mid + 1, right);
        invokeAll(leftTask, rightTask);  // 同时 fork 两个子任务
        merge(arr, left, mid, right);
    }
    
    private void merge(int[] arr, int left, int mid, int right) {
        int[] temp = new int[right - left + 1];
        int i = left, j = mid + 1, k = 0;
        while (i <= mid && j <= right) {
            temp[k++] = (arr[i] <= arr[j]) ? arr[i++] : arr[j++];
        }
        while (i <= mid) temp[k++] = arr[i++];
        while (j <= right) temp[k++] = arr[j++];
        System.arraycopy(temp, 0, arr, left, temp.length);
    }
}

// 1 亿个随机数排序,8 核机器实测
// 单线程 Arrays.sort():约 18 秒
// ForkJoinPool 并行归并:约 3.2 秒
// 加速比:5.6x(接近理论极限 7x,因为 merge 阶段是串行的)

commonPool() 与自定义线程池

java
// 全局共享的 ForkJoinPool
ForkJoinPool commonPool = ForkJoinPool.commonPool();
// 默认大小 = Runtime.getRuntime().availableProcessors() - 1
System.out.println(commonPool.getParallelism());

ForkJoinPool.commonPool() 是 JVM 全局共享的 ForkJoinPool 实例,parallelStream()CompletableFuture 默认使用它。它的并行度是 availableProcessors - 1(减去主线程)。

踩坑:parallelStream 的 commonPool 饥饿问题

真实案例:线上一个服务用 parallelStream() 处理数据,同时多个请求并发进来,每个请求的流里都做了数据库查询。结果出现:

  • commonPool 只有 7 个线程(8 核 CPU)
  • 7 个请求来了,每个请求占一个线程,都在等数据库(IO 阻塞)
  • 第 8 个请求进来,发现没有空闲线程,commonPool 不会像 ThreadPoolExecutor 一样创建新线程
  • 第 8 个请求的流永远不会执行,整个请求超时

原因commonPool 的线程数是固定的,它是为 CPU 密集型任务设计的,不会因为 IO 阻塞而扩容。而 ThreadPoolExecutor 在核心线程被占满后,会创建新线程直到 maxPoolSize。

修复方案:IO 密集型任务必须指定自定义线程池:

java
// 自定义线程池,不要用 commonPool
ForkJoinPool ioPool = new ForkJoinPool(
    Runtime.getRuntime().availableProcessors() * 2
);
try {
    ioPool.submit(() -> 
        data.parallelStream().forEach(item -> {
            // IO 操作:数据库查询、RPC 调用
            userService.queryById(item.getId());
        })
    ).get();
} finally {
    ioPool.shutdown();
}

踩坑:CompletableFuture 默认也是 commonPool

CompletableFuture.supplyAsync(() -> ...) 默认走 ForkJoinPool.commonPool()。如果你的异步任务里有 IO 操作,同样会饥饿。很多团队把 CompletableFuture 当异步编排工具用,但不知道它默认的线程池只有 CPU核数 - 1 个线程:

java
// 错误写法:IO 任务占满 commonPool
CompletableFuture.supplyAsync(() -> userService.queryById(id));

// 正确写法:指定自定义线程池
ExecutorService executor = new ThreadPoolExecutor(
    20, 50, 60L, TimeUnit.SECONDS, new LinkedBlockingQueue<>(1000)
);
CompletableFuture.supplyAsync(() -> userService.queryById(id), executor);

踩坑:parallelStream 的线程复用问题

parallelStream() 使用的 commonPool 线程是daemon 线程,如果主线程退出,这些线程会被 JVM 直接杀死,不会等待任务完成:

java
// 问题:主线程退出后,parallelStream 的线程被杀死
new Thread(() -> {
    IntStream.range(0, 100).parallel().forEach(i -> {
        // 如果主线程很快退出,这里可能没执行完就被杀了
        heavyComputation(i);
    });
}).start();
Thread.sleep(100);  // 主线程等待一小会就退出
// JVM 退出时,daemon 线程被强行终止

修复方案:确保主线程等待 parallelStream 完成,或者使用非 daemon 线程池:

java
ForkJoinPool customPool = new ForkJoinPool(4);
try {
    customPool.submit(() -> 
        IntStream.range(0, 100).parallel().forEach(i -> {
            heavyComputation(i);
        })
    ).get();  // 阻塞等待完成
} finally {
    customPool.shutdown();
}

ThreadPoolExecutor vs ForkJoinPool 对比表

维度ThreadPoolExecutorForkJoinPool
队列结构全局共享一个 BlockingQueue每个线程一个双端队列 + 全局 submission queue
取任务规则所有线程竞争同一个队列先取自己队列头部,空了再窃取别人尾部
任务依赖用 Future.get() 阻塞等待join() 不阻塞,取其他任务执行(工作窃取)
适用场景IO 密集型、短任务、独立任务CPU 密集型、分治任务、依赖链
线程数配置按业务调 corePoolSize / maxPoolSize默认 parallelism = CPU 核数 - 1
拒绝策略饱和策略(Abort/CallerRuns/Discard)没有拒绝策略,通过 ManagedBlocker 弹性扩缩
空闲线程keepAliveTime 后回收持续扫描窃取,不会空闲
任务拆分不原生支持,需手动编排原生 fork/join 分治模式
扩容机制队列满后创建新线程直到 maxPoolSize固定 parallelism,不主动扩容;ManagedBlocker 例外
阻塞处理线程阻塞后其他线程继续从队列取任务线程阻塞后其他线程窃取任务;全局阻塞时 ManagedBlocker 创建补偿线程
线程类型非 daemon(默认)工作线程是 daemon 线程
异常处理execute() 提交后异常被吞,submit() 通过 Future.get() 获取fork() 后必须 join() 才能获取异常,否则异常被吞

任务拆分粒度:too fine 是陷阱

性能上最容易被忽视的问题就是拆分粒度。如果拆得太细,窃取/合并的开销会超过计算本身:

100 万个元素的任务:
  拆成 2 个 50 万 → 只拆一层,两个线程并行,性能好
  拆成 100 万个单元素任务 → 每个任务都要 fork/join,栈帧开销爆炸

实际踩坑案例:某个业务团队用 ForkJoinPool 处理 500 万条用户数据,写了一个 THRESHOLD = 10 的拆分,结果比单线程还慢 3 倍。排查发现:

  • 每个任务 fork/join 要执行 CAS 操作修改 WorkQueue 的 top 指针
  • ForkJoinTask 内部有 ForkJoinPool 引用,每次 fork 都要做 volatile 读
  • 拆分 50 万次 CAS 操作,CPU 时间全花在同步上了

修复方案:把 THRESHOLD 改成 5000,耗时从 12 秒降到 0.8 秒。

实践中推荐拆到 1000-10000 个元素的粒度,或者用 ForkJoinTask.getSurplusQueuedTaskCount() 让框架自适应调整:

java
@Override
protected Long compute() {
    // 自适应:如果队列积压太多,说明其他线程空闲,别再拆了
    if (getSurplusQueuedTaskCount() > 3) {
        // 直接执行,不做分治
        return computeDirectly();
    }
    // 正常分治
    ...
}

getSurplusQueuedTaskCount() 返回当前线程队列中"多余"的任务数量。如果这个值大于 3,说明其他线程已经空闲了,再 fork 子任务只会被窃取,不如直接执行。这个自适应策略在任务大小不均匀的场景下特别有用。

生产调优参数

bash
# 设置 commonPool 并行度
-Djava.util.concurrent.ForkJoinPool.common.parallelism=8

# 设置 commonPool 最大线程数(备用线程,用于 ManagedBlocker)
-Djava.util.concurrent.ForkJoinPool.common.maximumSpares=256

ManagedBlocker 的完整用法

对于 ForkJoinPool 中执行可能阻塞的操作(如读写文件、等待锁),可以使用 ManagedBlocker 接口。当 ForkJoinPool 检测到所有工作线程都在阻塞时,它会临时创建额外的线程,避免死锁:

java
ForkJoinPool.managedBlock(new ForkJoinPool.ManagedBlocker() {
    @Override
    public boolean block() throws InterruptedException {
        // 执行阻塞操作
        return false;
    }
    @Override
    public boolean isReleasable() {
        return false;
    }
});

实际案例:一个使用 ForkJoinPool 做并行数据处理的系统,任务里需要写入本地文件。写入时 FileOutputStream.write() 是阻塞的,当所有线程都在写文件时,commonPool 认为所有线程都"忙",即使它们实际上在 IO 阻塞。这时如果有新任务提交,ForkJoinPool 不会自动创建新线程(因为 commonPool 不扩容),导致死锁。修复方案:不要在 ForkJoinPool 任务里做阻塞 IO,或者把阻塞操作封装成 ManagedBlocker

更优方案:IO 密集型任务用 ThreadPoolExecutor 而不是 ForkJoinPool

java
// 不要这样做——在 ForkJoinPool 里做 IO
ForkJoinPool ioPool = new ForkJoinPool(16);  // 问题:线程数固定,不扩容
ioPool.submit(() -> {
    data.parallelStream().forEach(item -> {
        // IO 阻塞 → 占着线程不做计算
        String result = httpClient.get(item.getUrl());  
        process(result);
    });
});

// 应该这样做——IO 密集型用 ThreadPoolExecutor
ThreadPoolExecutor ioPool = new ThreadPoolExecutor(
    16, 80, 60L, TimeUnit.SECONDS, new LinkedBlockingQueue<>(1000)
);
data.forEach(item -> {
    ioPool.submit(() -> {
        String result = httpClient.get(item.getUrl());
        process(result);
    });
});

ForkJoinPool 是为CPU 密集型分治计算设计的。IO 密集型任务用 ThreadPoolExecutor + 稍大的线程池更合适——线程被 IO 阻塞后,其他线程可以继续从队列取任务执行,不会被占着茅坑不拉屎。

Work-Stealing 与 Gang Scheduling 的区别

面试中常见的一个对比问题。Gang Scheduling(协作式调度)是另一种并行调度策略,与 Work-Stealing 有本质不同:

维度Work-StealingGang Scheduling
调度单位任务(Task)线程组(Thread Gang)
负载均衡被动:空闲线程主动窃取主动:调度器在运行前分配
阻塞行为线程只阻塞自己的任务,其他线程继续所有线程必须同时运行或同时阻塞
典型场景ForkJoinPool、Cilk、Go 调度器MPI 并行计算、GPU Kernel
适用架构共享内存多核分布式内存集群

Work-Stealing 更适合共享内存的多核场景(Java 的 ForkJoinPool 就是典型),而 Gang Scheduling 更适合分布式内存的高性能计算场景。

ForkJoinPool 四种构造方式对比(面试高频)

java
// 1. commonPool(全局共享,JVM 生命周期内唯一)
ForkJoinPool common = ForkJoinPool.commonPool();

// 2. 默认构造(parallelism = CPU 核数)
ForkJoinPool pool1 = new ForkJoinPool();

// 3. 指定并行度
ForkJoinPool pool2 = new ForkJoinPool(4);

// 4. 完整参数构造(JDK 9+)
ForkJoinPool pool3 = new ForkJoinPool(
    4,                    // parallelism
    ForkJoinPool.defaultForkJoinWorkerThreadFactory,  // 线程工厂
    null,                 // 异常处理器(UncaughtExceptionHandler)
    false,                // 是否 asyncMode(true 为 FIFO 模式)
    0,                    // 核心线程数(0 表示不限制核心线程)
    256,                  // 最大线程数
    1,                    // 最小 RNG 数(随机扫描种子)
    null,                 // 线程池名称前缀(JDK 17+)
    null,                 // 自定义线程池属性
    true                  // 注册为并行线程
);

asyncMode 参数:设为 true 时,工作线程从队列头部取任务(FIFO 模式),而不是默认的 LIFO 模式。这个参数在 ForkJoinPool 用于事件驱动任务时有用——比如 CompletableFuture 的异步回调,确保先提交的回调先执行,避免回调堆积导致优先级反转。但大部分场景保持默认(false)即可。

CompletableFuture 与 ForkJoinPool 的关系

CompletableFuture 的内部实现大量依赖 ForkJoinPool 的工作窃取机制。supplyAsync()runAsync() 无参重载就是提交到 ForkJoinPool.commonPool()thenApply()thenCompose() 等编排方法,在任务完成时通过 postComplete() 方法触发后续任务的执行,这个过程也走的是工作窃取的扫描路径。

关键源码路径(JDK 17 CompletableFuture.java):

java
// 任务完成时,触发后续依赖的任务
final void postComplete() {
    ForkJoinPool pool = ForkJoinPool.commonPool();
    // 遍历依赖链,提交到 commonPool 执行
    for (CompletionNode node = this.stack; ; ) {
        // 调用 tryFire() 实际上是 ForkJoinPool.execute()
        // 走 commonPool 的工作窃取调度
    }
}

理解了这个关系,就知道为什么 CompletableFuture 编排大量 IO 任务时必须指定自定义线程池——不然 commonPool 的少量线程会被 IO 阻塞,导致所有 thenApply 回调都排不上队。

面试高频追问:ForkJoinPool 的 ctl 控制变量如何工作

ctl 是一个 64 位 long,被位段分为 4 个 16 位字段:

高 16 位  │  16 位  │  16 位  │  低 16 位
  AC      │   TC    │  SS     │   ID
  • AC(Active Count):活跃线程数的补码。AC + parallelism 表示当前活跃线程数。如果 AC + parallelism < 0,说明有空闲线程(因为活跃线程数小于总并行度)
  • TC(Total Count):总线程数的补码。TC + parallelism 表示当前总线程数
  • SS(Stack Slot):空闲线程栈的版本号和栈指针。空闲线程挂起时,把自己入栈;被唤醒时,从栈顶取出
  • ID:空闲线程链表中下一个空闲线程的 WorkQueue 索引

为什么用位段而不是多个字段? 因为 ctl 需要被一次 CAS 原子更新。如果拆成多个字段,取空闲线程时需要先读 ctl,再读空闲线程列表,然后 CAS 修改空闲线程列表——这中间不是原子的,可能出现竞态。ForkJoinPool 的设计把所有需要原子操作的状态信息编码到 64 位里,一个 CAS 搞定。

java
// ctl 的位操作(ForkJoinPool 源码)
// 取空闲线程:从 ctl 中解析出栈顶 WorkQueue 的索引
int stackTop = (int)(c >>> SS_SEQ) & SMASK;
// 更新空闲线程数:CAS 修改 ctl 高位
long nc = (c - RC_UNIT) & ~RSVD;  // 空闲线程数减 1
U.compareAndSetLong(this, CTL, c, nc);

面试高频追问:ForkJoinPool 的 workQueue 数组为什么是 2 的幂

因为窃取时的随机索引运算 r & (ws.length - 1) 等价于 r % ws.length,但位运算比取模快 10 倍以上。这是 JDK 源码中常见的优化手法——HashMap 的桶数组也是 2 的幂,原因一样。

生产环境监控

线上排查 ForkJoinPool 问题时,用以下工具看线程状态:

bash
# 查看 commonPool 活跃线程数(通过 JMX)
jcmd <pid> VM.command_line | grep ForkJoinPool

# 通过 jstack 看线程名
# ForkJoinPool 的线程名格式:ForkJoinPool.commonPool-worker-1
jstack <pid> | grep -c "ForkJoinPool"

# 程序内获取 commonPool 指标
ForkJoinPool commonPool = ForkJoinPool.commonPool();
System.out.println("活跃线程数: " + commonPool.getActiveThreadCount());
System.out.println("并行度: " + commonPool.getParallelism());
System.out.println("池大小: " + commonPool.getPoolSize());
System.out.println("窃取总数: " + commonPool.getStealCount());
System.out.println("队列提交数: " + commonPool.getQueuedSubmissionCount());

线上排查案例:某次线上服务 CPU 占用 100%,但 QPS 下降。jstack 发现所有 ForkJoinPool.commonPool-worker 线程都在 park() 状态,但 commonPool.getActiveThreadCount() 显示 0。排查发现是业务代码里 parallelStream() 的 lambda 里有 synchronized 块,导致所有线程都在等待锁,commonPool 认为线程都"空闲"(因为 park() 了),实际上是被锁阻塞了。修复方案:把 synchronized 换成 ReentrantLock,或者把锁加到 ForkJoinTask 的计算范围之外。

总结

关键点清单

  • ForkJoinPool 是分治并行场景的专用线程池,核心是工作窃取算法
  • 每个线程有自己的双端队列,从头部取任务,从尾部窃取任务,减少竞争
  • fork() 将子任务入队,join() 在等待结果时执行其他任务(不阻塞空转)
  • join() 的四种状态流转:NORMAL、SIGNAL、CANCELLED、EXCEPTIONAL
  • 异常通过 completeExceptionally 机制传播,fork() 后不 join() 会吞异常
  • 任务拆分粒度是关键:推荐 1000-10000 个元素为一个子任务,过大或过小都影响性能
  • commonPool() 全局共享,并行度 = CPU核数 - 1IO 密集型请用自定义线程池,否则会饥饿
  • CompletableFuture 默认也走 commonPool,IO 场景必须指定自定义线程池
  • commonPool 线程是 daemon 线程,主线程退出后会被 JVM 杀死
  • ManagedBlocker 解决阻塞操作导致的线程池死锁问题
  • 与 ThreadPoolExecutor 不是替代关系,而是不同场景的互补
  • 工作窃取的 CAS 操作在队列两端几乎不冲突,是高性能的关键
  • 窃取扫描使用随机偏移量而不是轮询,避免多个空闲线程碰撞同一队列
  • 窃取失败使用渐进式退避(立即重试 → yield → park),避免空转耗 CPU
  • ctl 64 位控制变量通过位段编码了空闲线程数和栈指针,CAS 一次性修改
  • topbase 的 volatile 语义保证窃取者看到最新写入
  • 四种构造方式:commonPool、默认构造、指定并行度、完整参数构造(asyncMode 控制 FIFO/LIFO)
  • workQueue 数组长度是 2 的幂,用 r & (len-1) 代替取模运算
  • 线上监控:getStealCount() 看窃取效率、getActiveThreadCount() 看活跃线程、jstack 看线程名

面试话术示例

"ForkJoinPool 的核心设计是工作窃取——每个线程一个双端队列,从自己队头取任务,偷别人队尾的任务。这种设计在高并发下自旋开销低,因为窃取者和被窃取者操作的是队列两端,topbase 的 volatile 保证了可见性。join() 的等待不是阻塞而是干活——它会从队列里取其他任务执行,直到被依赖的任务完成,这背后是 doJoin() 的四种状态流转。parallelStream() 默认用 commonPool,如果流里有 IO 操作一定要指定自己的线程池,否则所有线程被 IO 阻塞时整个池子就废了——我线上遇到过,7 个请求进来占满 7 个线程都在等数据库,第 8 个请求直接超时,排查半天才发现是 commonPool 不会扩容。拆分的粒度也很关键,我见过 500 万条数据拆成 10 条一份,结果 50 万次 fork/join 的 CAS 操作把 CPU 打满了,改成 5000 一份后耗时从 12 秒降到 0.8 秒。还有 CompletableFuture 默认也用 commonPool,很多人不知道,做异步编排时 IO 操作一多就莫名其妙卡住。异常传播也要注意——fork() 了不 join() 异常会被吞,invokeAll() 比手动 fork() 更安全。另外 commonPool 的线程是 daemon 线程,主线程退出后任务会被强行终止,得用 get() 等完成。面试官问深了可以从 ctl 的位段编码、signalWork() 的唤醒机制、WorkQueue 的 top/base 指针 volatile 语义这几个源码层面展开。再往下挖,还可以问 workQueue 数组为什么是 2 的幂、asyncMode 在什么场景下用、getStealCount() 怎么监控工作窃取效率——这些都能答上。"

参考:ForkJoinPool 源码 java.util.concurrent.ForkJoinPoolForkJoinTask 源码、WorkQueue 实现细节、ForkJoinPool.commonPool() 源码、ManagedBlocker 接口文档、CompletableFuture.postComplete() 源码

手撕 → 框架 → 生产化,一步步把 AI Agent 工程化搞透。