吃透 ForkJoinPool:工作窃取底层原理,一次性讲透

前言

前段时间业务有个性能优化需求:需对2000 万条物料数据 做整体排序。起初采用单线程实现,执行效率极低,排查后发现CPU 多核利用率严重偏低

这时我开始思考:如何才能真正压榨多核 CPU 的并行算力?

深入研究后,我选用 ForkJoinPool 结合归并排序 来落地优化。归并排序核心思想是:将大数组不断拆分为两个等长子数组,对子数组各自排序后,再合并成完整有序数组。

由于天然适合递归拆分、分而治之的特性,和 ForkJoin 框架完美适配。


一、实现原理

Fork/Join 是一款面向并行计算的框架,专为支撑分治任务模型而设计。在该框架中,Fork 对应分治模型里的任务拆分 ,Join 则负责结果汇总

其核心设计思想为:将一个体量庞大的复杂任务,拆解为若干轻量化子任务并并行执行,待所有子任务运算完成后,再统一整合输出最终结果。

该框架适配各类可通过分治策略处理的计算密集型场景,典型应用包括大规模数组排序、图形渲染、复杂算法运算等。


二,🌰🌰举个例子

1、🌰RecursiveAction:排序

用于递归执行但不需要返回结果的任务。

1.1、代码例子

java 复制代码
package com.nl.forkjoin;

import java.util.Arrays;
import java.util.Random;
import java.util.concurrent.ForkJoinPool;
import java.util.concurrent.RecursiveAction;

public class ForkJoinDemo {

    public static void main(String[] args) {
        // 创建数组
        int size = 10000000;
        int[] array = new Random().ints(size, 0, 100000000).toArray();

        // 普通方式
        int[] arr1 = Arrays.copyOf(array, size);
        long start1 = System.currentTimeMillis();
        Arrays.sort(arr1);
        System.out.println("普通排序: " + (System.currentTimeMillis() - start1) + "ms");

        // ForkJoin方式
        int[] arr2 = Arrays.copyOf(array, size);
        ForkJoinPool pool = new ForkJoinPool();
        long start2 = System.currentTimeMillis();
        pool.invoke(new SortTask(arr2, 0, arr2.length));
        System.out.println("ForkJoin: " + (System.currentTimeMillis() - start2) + "ms");

        pool.shutdown();
    }

    /**
     * ForkJoinPool排序任务
     */
    static class SortTask extends RecursiveAction {
        private int[] arr;
        private int l, r;

        SortTask(int[] arr, int l, int r) {
            this.arr = arr;
            this.l = l;
            this.r = r;
        }

        /**
         * 排序
         */
        @Override
        protected void compute() {
            // 必须保留终止条件,任务过小不拆分,阈值会影响计算消耗时间
            // 避免没有设置阈值导致无限递归拆分,最终栈溢出或资源耗尽
            if (r - l <= 10000) {
                Arrays.sort(arr, l, r);
                return;
            }
            int m = (l + r) / 2;
            invokeAll(new SortTask(arr, l, m), new SortTask(arr, m, r));
        }
    }
}

⚠️注意

✔️必须保留终止阈值条件,避免没有设置阈值导致无限递归拆分,最终栈溢出或资源耗尽

✔️阈值会影响计算消耗时间,如:r - l <=10000

1.2、输出结果

makefile 复制代码
普通排序: 663ms
ForkJoin: 33ms

⚠️注意

✔️阈值会影响计算消耗时间

2、🌰RecursiveTask:计算

用于递归执行需要返回结果的任务。

2.1、执行

java 复制代码
public class LongSumMain {
    public static void main(String[] args) throws Exception {
        //准备数组
        int[] array = Utils.buildRandomIntArray(100000000);

        LongSumDemo longSumDemo = new LongSumDemo(array, 0, array.length);
        // 构建ForkJoinPool
        ForkJoinPool forkJoinPool = new ForkJoinPool();
        //ForkJoin计算数组总和
        ForkJoinTask<Long> result = forkJoinPool.submit(longSumDemo);
        System.out.println("forkJoinPool sum=" + result.get());
        forkJoinPool.shutdown();
    }
}

2.2、计算任务

ini 复制代码
package com.nl.forkjoin

import java.util.concurrent.RecursiveTask;

public class LongSumDemo extends RecursiveTask<Long> {
    static final int THRESHOLD = 1000;
    int[] arr;
    int lo, hi;

    LongSumDemo(int[] arr, int lo, int hi) {
        this.arr = arr;
        this.lo = lo;
        this.hi = hi;
    }

    /**
     * 执行拆分任务,拆分的任务都存在:ForkJoinTask<?>[] array
     * 比如:left.fork(),会创建一个ForkJoinTask对象,并添加到ForkJoinTask[] array中
     * @return
     */
    @Override
    protected Long compute() {
        // 任务执行逻辑,如果任务拆分到小于等于阀值(THRESHOLD),则开始求和
        if (hi - lo <= THRESHOLD) {
            long sum = 0;
            for (int i = lo; i < hi; i++) {
                sum += arr[i];
            }
            return sum;
        }
        int mid = (lo + hi) / 2;
        LongSumDemo left = new LongSumDemo(arr, lo, mid);
        LongSumDemo right = new LongSumDemo(arr, mid, hi);
        //  invokeAll(left, right);
        //  等价于
        //  left.fork();、right.fork();
        invokeAll(left, right);
        return left.join() + right.join();
    }
}

⚠️注意

✔️汇总输出结果,也需要设置阈值是为了平衡并行开销和计算效率:hi - lo <=THRESHOLD

2.2、输出

ini 复制代码
forkJoinPool sum=5000239626626864

三、源码分析

1、构造函数

scss 复制代码
    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);
    }
  • int parallelism :指定并行级别(parallelism level),ForkJoinPool 会根据该值确定工作线程的数量。若未显式设置,将默认使用 Runtime.getRuntime().availableProcessors() 获取 CPU 核心数作为并行级别。
  • ForkJoinWorkerThreadFactory factory :用于创建 ForkJoinPool 工作线程的工厂类。注意必须实现 ForkJoinWorkerThreadFactory 接口,而非普通的 ThreadFactory。若未指定,将使用框架默认的 DefaultForkJoinWorkerThreadFactory 创建线程。
  • UncaughtExceptionHandler handler:设置未捕获异常处理器,用于处理任务执行过程中抛出的未捕获异常。
  • boolean asyncMode :配置内部任务队列的工作模式。当值为 true 时采用 先进先出(FIFO)队列;为 false 时采用后进先出(LIFO) 队列。

⚠️注意

✔️Runtime.getRuntime().availableProcessors() : 获取 CPU 逻辑核心数(如 8 核、16 线程)

✔️Math.min(MAX_CAP, ...) : 限制最大值不超过 MAX_CA

⚠️为什么要这样写?

✔️防止 CPU 核心数过多时创建太多线程,导致资源浪费

2、 fork()和 join()

ForkJoinTask 的核心为 fork() 与 join() 两大方法,二者承担核心的任务调度协作职责,分别用于异步提交子任务阻塞获取任务结果

2.1、fork ():提交拆分任务

fork() 方法用于将子任务提交至线程池执行。若当前线程为 ForkJoinWorkerThread 工作线程,任务会优先加入当前线程的私有任务队列;否则,统一提交至 Common 公共线程池队列。

⚠️注意

✔️left.fork(); right.fork(); 效果等价于 invokeAll(left, right)。

✔️Common 公共线程池: 内部 workQueues 数组里的一类特殊 WorkQueue所有者线程owner=null队列

2.2、join():获取任务执行结果

join() 方法用于等待并获取任务最终执行结果,调用该方法会阻塞当前线程,直至目标任务执行完毕,再返回计算结果。

3、execute、invoke和submit:提交执行

ForkJoinPool 中最常用的任务提交方式,核心区别就是是否阻塞主线程谁来负责任务拆分

方法名 谁负责第一层拆分 主线程是否阻塞 返回值 核心特点 适用场景
invoke() 主线程 是,阻塞等待完成 直接返回任务结果 同步执行,任务完成才继续执行后续代码 需要立即获取计算结果(大数据排序、聚合计算)
execute() 线程池核心线程 否,非阻塞 无返回值(void) 纯异步执行,不关注结果、不处理异常 后台无返回值任务(日志写入、文件清理、异步通知)
submit() 线程池核心线程 否,非阻塞 返回 Future 异步执行,可主动获取结果、处理异常、取消任务 需要异步获取结果,且支持灵活控制任务

⚠️主线程调用invoke

✔️主线程调用 task.fork():任务被放入共享队列workQueues(owner == null)

✔️主线程调用 task.compute():主线程直接在当前栈帧里递归执行。

✔️主线程调用 task.join():如果子任务还没做完,主线程甚至会尝试去偷别人的任务来干。

✔️无论使用 invoke 还是 submit 方式提交任务,只要是由 ForkJoinPool 外部线程(例如主线程、Tomcat 业务线程等)发起提交,该任务绑定的 WorkQueue 其 owner 属性始终为 null

4、工作窃取

工作窃取机制允许空闲线程从繁忙线程的双端队列中抢夺任务执行

  • 默认规则下,工作线程优先从自身双端队列(WorkQueue)的头部获取任务
  • 当本地队列无任务可处理 时,便会从其他繁忙线程的队列(WorkQueue)尾部窃取任务

该设计能够有效降低多线程之间的任务竞争,充分利用线程资源,大幅提升整体并行执行效率。

⚠️注意

✔️ForkJoinPool 与 ThreadPoolExecutor 的核心差异,在于ForkJoinPool 引入了工作窃取机制,这也是 Fork/Join 高性能的关键设计。

4.1、WorkQueue

WorkQueue 是 ForkJoinPool 内部的双端队列 ,用来存放工作线程的专属任务。每个工作线程都维护独立的本地 WorkQueue,优先执行自身队列任务;当本地任务处理完毕,便会从其他繁忙线程的 WorkQueue 中窃取任务。

php 复制代码
    static final class WorkQueue {
        ......
        volatile int base;         // index of next slot for poll
        int top;                   // index of next slot for push
        ForkJoinTask<?>[] array;   // the elements (initially unallocated)
        ......
}

⚠️注意

✔️线程owner=自己 fork 任务 → top 指针 +1
✔️线程owner=自己 取任务 → top 指针 -1

✔️其他线程 窃取任务 → base 指针 +1

4.2、总结

步骤 动作 关键指针 / 字段 形象比喻
提交 外部线程扔任务 workQueues 数组 快递员把包裹放进公共柜
拆分 大任务变小任务 top 指针 +1 把大石头敲成小石子,堆在托盘顶
窃取 闲线程偷忙线程的活 base 指针 +1 隔壁工人从你托盘底抽走一块大石头
合并 小结果汇成大结果 join() 把碎纸片拼成完整的画

⚠️ 补充说明

✔️流程:提交→拆分→ 窃取→ 合并

✔️top 指针:所有者线程入队 / 出队的一端,fork() 提交任务时 top 指针上移,任务下沉到队列底部。

✔️base 指针:窃取线程取任务的一端,窃取时 base 指针上移,从队列底部取任务,避免和所有者竞争。


四,面试题

1、当工作线程自身的任务队列变为空时,是否就会闲置?

不闲置

ForkJoinPool 具备任务窃取(Work-Stealing) 机制:一旦工作线程空闲,它就可以主动 "窃取" 其他工作线程队列中尚未执行的任务。通过这种机制,所有工作线程都能尽可能保持忙碌,大幅提升线程利用率与整体并行效率。

2、递归任务是否可以不设置阈值?

不可以

  • 面对递归深度较高的任务场景,使用 Fork/Join 框架容易出现任务调度过载、内存占用过高等问题,极易触发 StackOverflowError 栈溢出错误。
  • 递归层级过深时,会批量生成大量子任务,大量子任务分散调度至不同线程执行。频繁的线程创建、销毁与任务调度会产生高额开销,持续挤占系统资源,最终造成整体性能下降

设置阈值代码:

bash 复制代码
            if (r - l <= 10000) {
                Arrays.sort(arr, l, r);
                return;
            }

如果是r - l <=100时,就会导致错误:

php 复制代码
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
	at java.util.Arrays.copyOf(Arrays.java:3284)
	at com.forkjoin.recursiveaction.ForkJoinDemo.main(ForkJoinDemo.java:16)

因此,在使用 Fork/Join 框架处理递归任务时,需结合实际场景合理评估递归深度任务粒度,以此规避任务调度过载、内存消耗过高及栈溢出等潜在问题。


五、总结

ForkJoinPool 工作窃取机制核心:让空闲线程偷取繁忙线程任务,最大化利用CPU。其workQueues含私有队列和公共队列,工作线程从自身队列头部取任务,空闲时从其他队列尾部窃取,fork改top指针、窃取改base指针,join等待返回结果。

相关推荐
longxibo2 小时前
【Flowable 7.2 源码深度解析与实战】
java·后端·流程图
雨辰AI2 小时前
从 MySQL 迁移至人大金仓 V9 完整改造指南|分页 / 函数 / 语法兼容全部解决
java·开发语言·数据库·后端·mysql·政务
杨运交2 小时前
[007][租户模块]基于 TransmittableThreadLocal 与 TaskDecorator 的租户上下文传递设计
后端
huzhongqiang2 小时前
Python全站链接爬取工具优化:支持过滤和断点续爬
后端·爬虫
神奇小汤圆2 小时前
SpringBoot 4 最被低估的新特性:Spring Data AOT
后端
杨运交2 小时前
[004][缓存模块]Caffeine缓存自定义:构建灵活的Spring Boot缓存管理器
后端
刀法如飞2 小时前
一款开箱即用的Flask 3.0 MVC工程脚手架,面向AI开发
后端·python·flask
神奇小汤圆3 小时前
美团Java一面:布隆过滤器有什么缺点?
后端