338. Java Stream API - 并行流的幕后英雄:Fork/Join 框架 & 工作机制详解
🎯 并行流的基本流程回顾
我们之前提到,并行流处理大致分为两步:
- ✅ 拆分数据源(Splitting the Source)
- ✅ 并行调度执行任务(Dispatching Work to Threads)
本节,我们重点讲第二步:拆完数据,怎么高效分发和执行?
🧠 幕后执行机制:Fork/Join 框架
并行流的执行,依赖 Java 的 Fork/Join 框架。
什么是 Fork/Join?
Fork(分叉) ➡️ 把大任务拆成小任务 Join(合并) ➡️ 把多个子任务结果合并
它背后的线程池叫:
Common Fork/Join Pool JVM 启动时自动创建,线程数 ≈ CPU 核心数
🛠️ 执行过程(可视化比喻)
我们以一个处理 1,000 个元素的任务为例来看执行流程:
1️⃣ 第一个线程开始干活
list.parallelStream().map(...).collect(...)
- 一个线程(假设是 T1)负责开始执行
- 它先判断:这任务大不大?
- 如果任务很大,就继续拆分!
🧠 类比:像一个员工接了 1000 件工作后,决定继续外包给两个助理。
2️⃣ 任务被拆分为子任务
MainTask(1000 items)
├── SubTaskA (0~499)
└── SubTaskB (500~999)
- T1 将两个子任务丢进自己的等待队列中
- 自己先处理其中一个,同时等待另一个结果
3️⃣ 等待任务完成、结果归并
SubTaskA → 再拆 → 小任务 → 各自执行 → 返回结果
SubTaskB → 同理
MainTask ← 收集两个结果 → 合并 → 返回最终值
- 每个子任务可以继续拆!
- 拆完的小任务若够小,就直接执行
- 每个任务拿到两个子结果后合并返回
✅ 最终得到完整结果
这样一层一层拆、一层一层合并,最终整个任务被完整处理。
🦹♂️ Work Stealing:线程池的“自救机制”
刚开始,只有 T1 线程在干活,其他线程都在“闲着”。
那其他线程怎么办?
🎯 工作窃取机制(Work Stealing)
- 每个线程有自己的任务等待队列
- 如果一个线程空闲了,它会偷偷从其他线程的队列里**“偷”任务来干**
🧠 类比:像公司里的员工,如果自己没活干了,就去同事桌上“借点任务”干,保持全员忙碌。
💻 示例代码:演示 Fork/Join 并行流运行
List<Integer> list = IntStream.range(0, 1000).boxed().toList();
int sum = list.parallelStream()
.mapToInt(i -> {
System.out.println(Thread.currentThread().getName() + " processing " + i);
return i;
})
.sum();
运行效果中会看到多个线程并发处理元素,且元素处理顺序混乱:
ForkJoinPool.commonPool-worker-3 processing 1
ForkJoinPool.commonPool-worker-1 processing 2
ForkJoinPool.commonPool-worker-5 processing 0
...
🚨 并行流的“顺序混乱”问题
由于 Work Stealing 是非确定性的,任务可能会:
- 被拆得不均
- 被任意线程捡走执行
- 导致元素处理顺序不可预测
❗这可能带来问题:
- 你在
.map()或.forEach()中写了副作用操作(比如写文件、修改共享变量) - 你依赖元素的顺序
✅ 建议:
- 如果处理任务有顺序要求,请使用
.stream()(串行流) - 或者使用
.forEachOrdered()强制顺序(但牺牲并行性能)
🎯 总结 & 重点小结
| 概念 | 说明 |
|---|---|
| Fork/Join 框架 | 并行流底层的并行计算框架 |
| Common Pool | JVM 默认创建的线程池,线程数 = CPU 核数 |
| 拆任务(Fork) | 拆成更小任务直到足够小 |
| 合任务(Join) | 每个任务等子任务完成后合并结果 |
| Work Stealing | 空闲线程自动从其他线程队列“偷任务”执行 |
| 顺序不稳定 | 并行执行顺序不可预测,适合无副作用操作 |
✅ 小测试:以下哪些操作适合并行流?
- 给每个数字乘 2 后求和 ✅
- 将每行日志写入同一个文件 ❌
- 批量处理大图像缩放 ✅
- 为每个订单生成递增编号 ❌