欢迎来打------兵器时代------Java 并发编程的重武器库!
如果说之前的 ThreadPoolExecutor 是传统的"包工头"(接任务 -> 分给工人 -> 等结果),那么 Fork/Join 就是拥有**"分身术"的超级包工头;**时代在进步,科技在进步,**在进步!
- 传统线程池痛点:面对海量数据(处理 10 亿行日志、计算超大矩阵),任务划分不均,会出现"忙的忙死,闲的闲死"的不公平现象(负载严重不均衡),并且大任务阻塞线程,小任务频繁创建销毁线程,效率低到冰点。
- Fork/Join 核心思想 :分而治法 (Divide and Conquer) + 工作窃取 (Work Stealing) 。
- 分治:大任务拆成小任务,小任务再拆,直到小到可以瞬间完成。
- 窃取:谁干完了,就去偷别人的活干,绝不让 CPU 闲置。
第一部分:核心原理------"分身术"与"互偷机制"
1. 分治法 (Divide and Conquer) ------ "无限拆分"
像西游记中鸡嗛米,狗餂面,灯焰燎断锁梃(CPU 密集型任务)如果是咱们应该如何最快完成
- 传统做法:叫 10 个人,每人分一堆,弄完汇报。如果某人分到的那堆特别大,他就成了瓶颈,其他人早下班了,他还在加班。
- Fork/Join 做法 :
- Fork (分):包工头拿到任务,发现太大,直接劈成两半。
- 自己留一半,把另一半扔给小弟 A。
- 小弟 A 发现还是太大,再劈成两半,留一半,扔给小弟 B...
- 递归拆分,直到任务小到"一口能吞下"(阈值 Threshold),比如"吃 1000 粒米"。
- 执行:大家迅速吃完这 1000 粒。哈哈 一吃不成胖子,除非一直吃!
- 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 中:
-
检查队列 :Thread-A 首先看自己的 deque。
- 如果有其他任务?👉 拿出来执行! (执行完再回来检查 Left-Task 好了没)。
- 如果没有任务?👉 进入"偷窃模式"。
-
随机偷窃 (Stealing):
- Thread-A 随机挑选一个受害者线程(比如 Thread-C)。
- 尝试从 Thread-C 的 deque 头部 偷一个任务。
- 如果偷到了:很棒!Thread-A 立即执行这个偷来的任务。执行完后,回来检查 Left-Task 好了没?没好?继续偷!
- 如果没偷到(别人队列也空):换个受害者继续偷。
-
循环直到完成:
- Thread-A 就像一个不知疲倦的搬运工 。它在等待 Left-Task 完成的这段时间里,绝不让 CPU 闲置。
- 它不断地:
检查 Left-Task 状态->没好?->偷个任务干->检查 Left-Task 状态... - 直到 Left-Task 的状态变为
DONE(可能是 Thread-B 算完了,也可能是 Thread-A 自己偷到了 Left-Task 并把它算完了)。
-
最终返回:
- 一旦 Left-Task 完成,
join()返回结果,Thread-A 汇总数据,任务结束。
- 一旦 Left-Task 完成,
**为什么这很重要?**如果没有这个机制:
- 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() 时:
-
JDK 会使用
ForkJoinPool.commonPool()(公共池)。 -
它会根据数据源类型(ArrayList, Array, HashSet 等)自动选择Spliterator (可分割迭代器)。
-
Spliterator 负责将数据源递归拆分成多个小块。
-
每个小块被包装成
ForkJoinTask,投入池中执行。 -
最后结果自动合并。
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. 并行流的陷阱 (重要) ⚠️
工具有了,怎么用如何用很重要,自己把自己玩晕的比比皆是
-
线程污染:
parallelStream()默认使用ForkJoinPool.commonPool()。- 这是一个全局共享 的池。如果在某个任务里做了一个阻塞操作(如 IO、sleep、等待网络),会占用这个公共池的一个线程。如上可以自己创建
- 后果:整个 JVM 中所有其他使用并行流的地方都会变慢,因为线程被阻塞任务占光了!
- 解决 :涉及 IO 或阻塞操作,绝对不要 用并行流,或者使用自定义的
ForkJoinPool。
-
顺序敏感性:
- 如果操作依赖于顺序(如
forEach打印顺序),并行流无法保证顺序。 - 如果需要顺序,请用
forEachOrdered(但这会牺牲性能) 或改用串行流。
- 如果操作依赖于顺序(如
-
状态共享:
- 并行流中的操作必须是无状态的。
- ❌ 错误:
list.parallelStream().forEach(n -> sharedList.add(n));(线程不安全,需要外部同步,导致性能倒退)。 - ✅ 正确:使用归约操作 (
reduce,collect),它们内部处理了线程安全。
-
数据源拆分成本:
ArrayList、数组:拆分极快 ( O(1)O(1) ),适合并行。LinkedList、HashSet、IO Stream:拆分很慢甚至无法高效拆分,并行流可能比串行还慢!
-
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
-
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),竞争极其激烈,性能极差
- 这个比较常用了,要把结果收集到 List, Set, Map 或者自定义容器中,需要三个函数(或者使用
第四部分:深度对比与选型指南
| 特性 | 传统线程池 (ThreadPoolExecutor) |
Fork/Join Pool | 并行流 (Parallel Stream) |
|---|---|---|---|
| 核心模型 | 生产者 - 消费者 | 分治 + 工作窃取 | 分治 + 工作窃取 (封装版) |
| 任务粒度 | 粗粒度 (一个大任务一个线程) | 细粒度 (递归拆分到微小任务) | 自动拆分 |
| 负载均衡 | 差 (依赖初始分配) | 极佳 (动态窃取) | 极佳 |
| 适用场景 | IO 密集型 (Web 请求、DB 查询)、异步解耦 | CPU 密集型 (数学计算、排序、大数据处理) | CPU 密集型 集合处理 |
| 阻塞容忍度 | 高 (线程多,阻塞几个没关系) | 低 (线程数=CPU 核数,阻塞会导致整体停滞) | 低 (同左) |
| 代码复杂度 | 中 (需手动管理任务队列) | 高 (需手写 RecursiveTask) | 极低 (一行代码) |
问题来了,选哪个
-
任务是 IO 密集型吗? (查库、调接口、读写文件等操作)
- 是 →→ 传统线程池 (
ThreadPoolExecutor)。- 理由:需要更多线程来掩盖 IO 等待时间。Fork/Join 线程太少,会卡死。
- 否 (纯计算) →→ 继续问。
- 是 →→ 传统线程池 (
-
任务是处理集合/数组数据吗?
- 是 →→ 并行流 (
parallelStream)。- 理由:代码最简洁,JDK 优化最好。
- 注意:确保没有阻塞操作,数据源支持高效拆分。
- 否 (复杂递归逻辑、非集合数据、需要精细控制拆分阈值) →→ 手写 Fork/Join 。
- 理由 :你需要自定义
RecursiveTask的拆分逻辑。
- 理由 :你需要自定义
- 是 →→ 并行流 (
-
需要自定义并行度吗?
- 如果是 CPU 密集型但逻辑复杂,且不想污染公共池 →→ 新建
ForkJoinPool并提交任务。
- 如果是 CPU 密集型但逻辑复杂,且不想污染公共池 →→ 新建
总结:何时使用这把"倚天剑"?
- 场景:项目有 10GB 的日志要分析,要计算圆周率后 100 万位,或者进行图像渲染、加密解密。
- 特征 :
- CPU 跑满 :任务主要是计算,不怎么睡觉(IO)。
- 可拆分:大任务能切成独立的小任务,互不干扰。
- 可合并:小任务的结果能轻松拼成大结果。