Fork/Join 框架与并行流:CPU 密集型的“分身术”

欢迎来打------兵器时代------Java 并发编程的重武器库!

如果说之前的 ThreadPoolExecutor 是传统的"包工头"(接任务 -> 分给工人 -> 等结果),那么 Fork/Join 就是拥有**"分身术"的超级包工头;**时代在进步,科技在进步,**在进步!

  • 传统线程池痛点:面对海量数据(处理 10 亿行日志、计算超大矩阵),任务划分不均,会出现"忙的忙死,闲的闲死"的不公平现象(负载严重不均衡),并且大任务阻塞线程,小任务频繁创建销毁线程,效率低到冰点。
  • Fork/Join 核心思想分而治法 (Divide and Conquer) + 工作窃取 (Work Stealing)
    • 分治:大任务拆成小任务,小任务再拆,直到小到可以瞬间完成。
    • 窃取:谁干完了,就去偷别人的活干,绝不让 CPU 闲置。

第一部分:核心原理------"分身术"与"互偷机制"

1. 分治法 (Divide and Conquer) ------ "无限拆分"

像西游记中鸡嗛米,狗餂面,灯焰燎断锁梃(CPU 密集型任务)如果是咱们应该如何最快完成

  • 传统做法:叫 10 个人,每人分一堆,弄完汇报。如果某人分到的那堆特别大,他就成了瓶颈,其他人早下班了,他还在加班。
  • Fork/Join 做法
    1. Fork (分):包工头拿到任务,发现太大,直接劈成两半。
    2. 自己留一半,把另一半扔给小弟 A。
    3. 小弟 A 发现还是太大,再劈成两半,留一半,扔给小弟 B...
    4. 递归拆分,直到任务小到"一口能吞下"(阈值 Threshold),比如"吃 1000 粒米"。
    5. 执行:大家迅速吃完这 1000 粒。哈哈 一吃不成胖子,除非一直吃!
    6. Join (合):小弟 B 把结果给 A,A 合并后给包工头,包工头汇总最终结果。
2. 工作窃取算法 (Work Stealing) ------ "拒绝摸鱼"

还得是fork/join,这老板们最近的手段都是从这学的吧,哈哈

这是 Fork/Join 的灵魂!🌟

  • 数据结构 :每个线程(牛马)都有一个双端队列 (Deque)
    • 自己干活 :从队列尾部 (LIFO) 拿任务(特性,利用缓存局部性)。
    • 偷别人活 :当自己的队列空了,随机找另一个忙碌的线程,从它的队列头部 (FIFO) 偷一个任务来干。
  • 为什么这样设计?
    • 减少竞争:自己拿尾,别人偷头,两头操作互不干扰,几乎不需要锁(CAS 即可)。
    • 负载均衡:没人会闲着。只要还有任务没做完,空闲线程就会不断去"偷",直到所有任务清零。
3. 核心类族
  • ForkJoinPool:特殊的线程池,默认大小 = CPU 核心数。内部维护着 Work Stealing 队列。
  • ForkJoinTask :任务的抽象基类(轻量级,比普通 Thread 轻得多,可以创建几万个)。
    • RecursiveAction无返回值的分治任务(如:排序、打印)。
    • RecursiveTask<V>有返回值的分治任务(如:求和、查找最大值)。

第二部分:手写实战------用 Fork/Join 计算数组和

我们要计算一个超大数组(1 亿个整数)的和。

复制代码
import java.util.concurrent.RecursiveTask;
import java.util.concurrent.ForkJoinPool;

// 1. 定义任务:继承 RecursiveTask<Long> (因为有返回值)
class SumTask extends RecursiveTask<Long> {
    private final int[] array;
    private final int start;
    private final int end;
    // 阈值:任务小于这个值就直接算,不再拆分 
    // 调优关键,经验1000------10000,具体看业务
    private static final int THRESHOLD = 10_000; 

    public SumTask(int[] array, int start, int end) {
        this.array = array;
        this.start = start;
        this.end = end;
    }

    @Override
    protected Long compute() {
        // 2. 判断是否足够小
        if (end - start <= THRESHOLD) {
            // 【Base Case】直接计算
            long sum = 0;
            for (int i = start; i < end; i++) {
                sum += array[i];
            }
            return sum;
        } else {
            // 3. 【Recursive Case】拆分任务 (Fork)
            int mid = (start + end) / 2;
            
            SumTask leftTask = new SumTask(array, start, mid);
            SumTask rightTask = new SumTask(array, mid, end);
            
            // 启动子任务 (异步执行,放入队列,立即返回)
            //不是立刻执行,任务压入当前线程deque,通知池中空闲线程来偷
            leftTask.fork(); 
            // 当前线程直接执行右任务,强制同步执行:像调用普通方法 当前栈帧里递归执行右任务的逻辑
            //🌟compute为了复用当前线程,避免多余队列操作
            
            long rightResult = rightTask.compute(); 
            //🌟等待左任务完成并获取结果 (Join),如果左任务没完,当前线程可以去偷别点任务干直到左完成
            long leftResult = leftTask.join(); //一边等待、一边偷活干 😂
            
            return leftResult + rightResult;
        }
    }
}

public class ForkJoinDemo {
    public static void main(String[] args) {
        int[] array = new int[100_000_000];
        // 填充数据...
        for(int i=0; i<array.length; i++) array[i] = 1;

        // 4. 创建 ForkJoinPool (默认并行度 = CPU 核数)
        ForkJoinPool pool = new ForkJoinPool();
        
        long startTime = System.currentTimeMillis();
        
        // 提交任务,
        Long result = pool.invoke(new SumTask(array, 0, array.length));
        
        long endTime = System.currentTimeMillis();
        
        System.out.println("结果: " + result);
        System.out.println("耗时: " + (endTime - startTime) + " ms");
        
        pool.shutdown();
    }
}
task.join()

join与之前的认识的不太一样:伪装了,下面咱们就解释一下:

  • 当 Thread-A 调用 leftTask.join() 时,它发现 Left-Task 还没做完 (status != DONE)。如果是普通线程,Thread-A 就 park() (休眠) 了,CPU 时间片浪费

但在 ForkJoinPool 中:

  1. 检查队列 :Thread-A 首先看自己的 deque

    • 如果有其他任务?👉 拿出来执行! (执行完再回来检查 Left-Task 好了没)。
    • 如果没有任务?👉 进入"偷窃模式"
  2. 随机偷窃 (Stealing)

    • Thread-A 随机挑选一个受害者线程(比如 Thread-C)。
    • 尝试从 Thread-C 的 deque 头部 偷一个任务。
    • 如果偷到了:很棒!Thread-A 立即执行这个偷来的任务。执行完后,回来检查 Left-Task 好了没?没好?继续偷!
    • 如果没偷到(别人队列也空):换个受害者继续偷。
  3. 循环直到完成

    • Thread-A 就像一个不知疲倦的搬运工 。它在等待 Left-Task 完成的这段时间里,绝不让 CPU 闲置
    • 它不断地:检查 Left-Task 状态 -> 没好? -> 偷个任务干 -> 检查 Left-Task 状态 ...
    • 直到 Left-Task 的状态变为 DONE(可能是 Thread-B 算完了,也可能是 Thread-A 自己偷到了 Left-Task 并把它算完了)。
  4. 最终返回

    • 一旦 Left-Task 完成,join() 返回结果,Thread-A 汇总数据,任务结束。

**为什么这很重要?**如果没有这个机制:

  • Thread-A 在等 Left-Task。
  • Thread-B 在跑 Left-Task。
  • Thread-C 没事干(它的队列空了)。
  • 结果:Thread-C 傻等着,CPU 核心闲置。

有了 join() 窃取机制:

  • Thread-A 在等 Left-Task 时,发现没事干,转头去帮 Thread-D 干活了。
  • 结果 :所有 CPU 核心始终满载,直到最后一个任务完成。这就是 Work Stealing 的精髓:等待即工作

第三部分:Java 8 并行流 (Parallel Stream) ------ 封装的艺术

平时写的 stream().parallel(),咱们看源码会发现底层其实就是 Fork/Join Pool。Java 8 把复杂的拆分逻辑封装起来了!

1. 原理揭秘

当你调用 list.parallelStream() 时:

  1. JDK 会使用 ForkJoinPool.commonPool() (公共池)。

  2. 它会根据数据源类型(ArrayList, Array, HashSet 等)自动选择Spliterator (可分割迭代器)。

  3. Spliterator 负责将数据源递归拆分成多个小块。

  4. 每个小块被包装成 ForkJoinTask,投入池中执行。

  5. 最后结果自动合并。

    import java.util.Arrays;
    import java.util.List;
    import java.util.stream.Collectors;

    public class ParallelStreamDemo {
    public static void main(String[] args) {
    List<Integer> numbers = Arrays.asList(1, 2, 3, ..., 1000000);

    复制代码
         // 不推荐:串行流:单线程处理
         long start1 = System.currentTimeMillis();
         long sum1 = numbers.stream()
                 .mapToLong(Integer::longValue)
                 .sum();
         System.out.println("Serial: " + (System.currentTimeMillis() - start1));
    
         // 🌟 并行流:底层 Fork/Join,自动分治
         long start2 = System.currentTimeMillis();
         long sum2 = numbers.parallelStream() // 关键在这里
                 .mapToLong(Integer::longValue)
                 .sum();
         System.out.println("Parallel: " + (System.currentTimeMillis() - start2));
         
         // ⚡ 自定义并行度 (不使用公共池)
         // 有时候公共池被其他任务占用了,我们可以用自己的池
         ForkJoinPool customPool = new ForkJoinPool(16); // 指定 16 个线程
         long sum3 = customPool.submit(() -> 
             numbers.parallelStream().mapToLong(Integer::longValue).sum()
         ).join();
     }

    }

2. 并行流的陷阱 (重要) ⚠️

工具有了,怎么用如何用很重要,自己把自己玩晕的比比皆是

  1. 线程污染

    • parallelStream() 默认使用 ForkJoinPool.commonPool()
    • 这是一个全局共享 的池。如果在某个任务里做了一个阻塞操作(如 IO、sleep、等待网络),会占用这个公共池的一个线程。如上可以自己创建
    • 后果:整个 JVM 中所有其他使用并行流的地方都会变慢,因为线程被阻塞任务占光了!
    • 解决 :涉及 IO 或阻塞操作,绝对不要 用并行流,或者使用自定义的 ForkJoinPool
  2. 顺序敏感性

    • 如果操作依赖于顺序(如 forEach 打印顺序),并行流无法保证顺序。
    • 如果需要顺序,请用 forEachOrdered (但这会牺牲性能) 或改用串行流。
  3. 状态共享

    • 并行流中的操作必须是无状态的。
    • ❌ 错误:list.parallelStream().forEach(n -> sharedList.add(n)); (线程不安全,需要外部同步,导致性能倒退)。
    • ✅ 正确:使用归约操作 (reduce, collect),它们内部处理了线程安全。
  4. 数据源拆分成本

    • ArrayList、数组:拆分极快 ( O(1)O(1) ),适合并行。
    • LinkedListHashSet、IO Stream:拆分很慢甚至无法高效拆分,并行流可能比串行还慢!
  5. reduce:简单数据的汇总

    • 适用于:求和、求最大值、拼接字符串等,结果是单个值。
    • int sum = list.parallelStream() .filter(n -> n > 2) .reduce(0, (a, b) -> a + b);初始值0,累加器a+b :线程 A 算 [1, 2] -> 和为 0。线程 B 算 [3, 4] -> 和为 7。线程 C 算 [5] -> 和为 5,JDK 自动调用累加逻辑,把 0 + 7 + 5 = 12
  6. collect:复杂结果的收集

    • 这个比较常用了,要把结果收集到 List, Set, Map 或者自定义容器中,需要三个函数(或者使用 Collector 工具类)。
    • List<Integer> result = list.parallelStream() .filter(n -> n > 2) .collect(Collectors.toList());
    • Accumulator 阶段,线程 A 操作 List-A,线程 B 操作 List-B。它们互不干扰,完全不需要锁 !只有在最后的 Combiner 阶段,才需要合并。如果有 N 个线程,只需要合并 N-1 次
    • forEach 是所有线程往同一个 全局 List 里 add。每次 add 都要加锁(或者用 CAS),竞争极其激烈,性能极差

第四部分:深度对比与选型指南

特性 传统线程池 (ThreadPoolExecutor) Fork/Join Pool 并行流 (Parallel Stream)
核心模型 生产者 - 消费者 分治 + 工作窃取 分治 + 工作窃取 (封装版)
任务粒度 粗粒度 (一个大任务一个线程) 细粒度 (递归拆分到微小任务) 自动拆分
负载均衡 差 (依赖初始分配) 极佳 (动态窃取) 极佳
适用场景 IO 密集型 (Web 请求、DB 查询)、异步解耦 CPU 密集型 (数学计算、排序、大数据处理) CPU 密集型 集合处理
阻塞容忍度 高 (线程多,阻塞几个没关系) 低 (线程数=CPU 核数,阻塞会导致整体停滞) 低 (同左)
代码复杂度 中 (需手动管理任务队列) 高 (需手写 RecursiveTask) 极低 (一行代码)
问题来了,选哪个
  1. 任务是 IO 密集型吗? (查库、调接口、读写文件等操作)

    • →→ 传统线程池 (ThreadPoolExecutor)。
      • 理由:需要更多线程来掩盖 IO 等待时间。Fork/Join 线程太少,会卡死。
    • (纯计算) →→ 继续问。
  2. 任务是处理集合/数组数据吗?

    • →→ 并行流 (parallelStream)。
      • 理由:代码最简洁,JDK 优化最好。
      • 注意:确保没有阻塞操作,数据源支持高效拆分。
    • (复杂递归逻辑、非集合数据、需要精细控制拆分阈值) →→ 手写 Fork/Join
      • 理由 :你需要自定义 RecursiveTask 的拆分逻辑。
  3. 需要自定义并行度吗?

    • 如果是 CPU 密集型但逻辑复杂,且不想污染公共池 →→ 新建 ForkJoinPool 并提交任务。

总结:何时使用这把"倚天剑"?

  • 场景:项目有 10GB 的日志要分析,要计算圆周率后 100 万位,或者进行图像渲染、加密解密。
  • 特征
    1. CPU 跑满 :任务主要是计算,不怎么睡觉(IO)。
    2. 可拆分:大任务能切成独立的小任务,互不干扰。
    3. 可合并:小任务的结果能轻松拼成大结果。
相关推荐
茶本无香1 小时前
【无标题】Kafka 系列博文(一):从零认识 Kafka,到底解决了什么问题?
java·分布式·kafka
惊讶的猫1 小时前
SpringMVC介绍
java·springmvc·springboot
ErizJ1 小时前
面试 | gin gorm go-zero
面试·golang·gin·gorm·gozero
郝学胜-神的一滴1 小时前
循环队列深度剖析:从算法原理到C++实现全解析
开发语言·数据结构·c++·算法·leetcode
JWASX1 小时前
【RocketMQ 生产者和消费者】- 事务消息的使用
java·rocketmq·java-rocketmq
Via_Neo1 小时前
接雨水问题 + 输入优化
java·开发语言·算法
所谓伊人,在水一方3331 小时前
【Python数据可视化精通】第9讲 | 实时数据流可视化
开发语言·python·信息可视化·数据分析·pandas
xufengzhu1 小时前
多层Module依赖项目Maven编译错误的解决方案
java·maven
吃鱼不吐刺.1 小时前
阻塞队列。
java·开发语言