一个"报表聚合"从 stream() 改成 parallelStream(),在 8 核机器上从 320ms 变成 210ms,团队觉得"赚了"。后来同一个方法在"4 核容器"里跑出 480ms——比串行还慢。而更糟的一次:并行版里用了 forEach 直接写 ArrayList,结果"少了几条数据".
并行流不是"免费的加速开关",它有三个前提:任务够重、切分够均匀、且不共享可变状态。三条里缺一条,就会"更慢"或"更错".
1. 并行流的三个前提
| 前提 | 不满足的后果 |
|---|---|
| 单元素耗时够大 | 切分/合并开销 > 收益 |
| 数据源"易切分" | 切分不均,尾部拖尾 |
| 无共享可变状态 | 数据丢失/错乱 |
这个"吃掉"的量级值得记住:"切分 + fork + join"每批大约"几微秒到几十微秒"——于是"每元素 0.1ms、每批 1000 个"时,"批的收益"约 100ms、"开销"约 30μs,看起来还行;而"每元素 0.01ms"时,"一批只值 10ms",开销占比立刻上升。
第一项最容易被忽略:"每个元素只算 0.1ms"时,并行框架的"任务切分与结果合并"(几十微秒级)会"吃掉全部收益".
2. "更慢"的三种机制
| 机制 | 说明 |
|---|---|
| 切分开销 | Spliterator 拆分 + 结果合并 |
| 线程池争用 | 共用 ForkJoinPool.commonPool |
| 数据源不可切分 | LinkedList/Iterator 只能"顺序切" |
第三项是"最隐蔽"的:LinkedList 的 spliterator "很难均分"第三项的"具体代价"是"先遍历一遍求中位":LinkedList.spliterator() 要"走 n/2 步"才能"切成两半"——**于是"切分本身"就是 O(n),而"并行"本该省的正是 O(n)。
——**于是"并行"退化成"两个线程各干一半,还得先遍历一遍".
"丢数据"这件事可以"用一个十行例子"复现,而它比"读文档"快:
// 反例:parallelStream + forEach 写 ArrayList
var out = new ArrayList<String>();
IntStream.range(0, 10_000).parallel().forEach(i -> out.add("v"+i));
System.out.println(out.size()); // 常见 9xxx —— 丢了几十条
// 正解:collect(每个线程各自累积,最后合并)
var ok = IntStream.range(0, 10_000).parallel().mapToObj(i -> "v"+i).collect(Collectors.toList());
// 判据:ok.size() == 10000 永远成立
3. "共享可变状态"的三种写法(都错)
| 写法 | 后果 |
|---|---|
forEach 里 add 到 ArrayList |
丢数据/数组越界 |
forEach 里 count++ |
计数偏小 |
| 用"非线程安全"的集合做"收集容器" | 结构损坏 |
这个"为并行设计"的含义很具体:collect 走"每个线程各自累积一个容器,最后合并"——**于是"没有共享写",也就不需要"锁"。
正确做法是"用 collect 而不是 forEach":Collectors.toList()/toConcurrentMap() 是"为并行设计"的——而"并行流 + forEach"几乎是"错"的代名词.
"自定义池"的写法只有两行,而它"解决的是'与别人抢'":
var pool = new ForkJoinPool(4);
var sum = pool.submit(() -> list.parallelStream().mapToLong(Item::cost).sum()).get();
// 判据:这个任务的并行度 = 4,不受 commonPool 影响
4. "线程池"的三种选择
| 选择 | 说明 |
|---|---|
默认 commonPool |
方便(但"和别的并行流抢") |
自定义 ForkJoinPool |
隔离(要"手动 submit") |
不用并行流,用"显式线程池 + CompletableFuture" |
最可控 |
第二项的"坑"是"语法不直观":"父线程里 parallelStream()"不会用你自定义的池第二项的"正确姿势"是"把整个流操作当作一个任务提交给自定义池":pool.submit(() -> list.parallelStream()...)——**而"在父线程里调"用的还是 commonPool。
——要"在池里提交整个任务".
"实测对比"要"固定输入 + 丢弃预热",否则"结论会反向":
// 三步"对比测"
1 同一份数据、同一台机器(或"线上最小规格")
2 前 3 轮丢弃(JIT/类加载),取"后 5 轮中位数"
3 指标看"耗时"与"CPU 利用率"(并行常"耗时降、CPU 升")
// 判据:串行 320ms、并行 210ms、CPU 从 30%→90% —— 这算"赚"
5. "该不该并行"的三条判据
| 判据 | 结论 |
|---|---|
| 单元素耗时 > 1ms | 值得考虑 |
| 数据量 > 1 万 | 值得考虑 |
| 纯计算(无 IO、无锁) | 值得考虑 |
这个"冷热两组"的要求,是因为并行流的"首次运行"包含"类加载 + JIT 编译 + 池初始化"——于是"第一次测出来慢 40%",而"稳态"其实快 20%。
三条都满足才"值得试"——而"试"要看"冷热两组数据",不能只看一次.
"数据源是否易切分"可以"一条语句"判断:
System.out.println(list.spliterator().characteristics() & Spliterator.SIZED);
// SIZED 存在 + 随机访问(ArrayList/数组)→ 切分便宜
// LinkedList/Iterator → 切分要"边走边数",便宜不了
// 判据:不确定就用 ArrayList
6. 排错:五种"并行流问题"
| 现象 | 成因 | 处理方向 |
|---|---|---|
| 并行更慢 | 元素太轻 | 改回串行 |
| 数据变少 | forEach 写集合 |
改 collect |
| 偶发越界 | 非线程安全容器 | 换并发容器 |
| 单位置变慢 | 与其它并行流抢池 | 自定义池 |
| 4 核更慢、8 核更快 | 核数依赖 | 按"最小核数"评估 |
"有 IO 就别并行"这条,理由不是"慢",而是"会把池占满":
// 三种"IO 场景"的正确做法
1 HTTP 调用:CompletableFuture + 显式池(池大小按"下游容量")
2 DB 查询:批量查,而不是"每个元素一次查"
3 文件:AsynchronousFileChannel 或"生产者-消费者队列"
// 判据:并行流只用于"纯 CPU"
7. 边界:什么情况"永远别开并行"
| 场景 | 为什么 | 做法 |
|---|---|---|
| 有 IO(HTTP/DB/文件) | 阻塞线程,池被打满 | 用"异步 + 显式池" |
| 有锁/同步块 | 争用放大 | 串行 |
| 数据量小(< 1000) | 开销占比高 | 串行 |
| 需要"严格顺序" | 并行不保证 | 串行或"最后排序" |
判断口径一句话:"这段计算"是不是"纯 CPU 且每元素够重""。是,可以试并行并"实测对比";不是(有 IO、有锁、元素太轻),"开并行"只是"把简单问题复杂化"——**而"8 核机器上更快"从来不是"并行有效"的证据,因为线上是"4 核容器".
8. 自测
- 并行流的三个前提是什么?为什么"元素太轻"会让它更慢?
- 为什么
LinkedList的"不可切分"会让并行退化成"更慢"? - "并行流 +
forEach写ArrayList"为什么会丢数据?正确写法是什么? - 自定义
ForkJoinPool为什么"不能靠'在父线程里调parallelStream'"? - "8 核更快、4 核更慢"说明了什么?评估该以什么为基准?
- 判断"能否并行"的那个问题是什么?
补一句"决策"建议:"并行流"应当'按方法'决定,而不是'按项目'决定——同一个项目里,"聚合统计"可能值得并行,"单个订单校验"绝不值得;而"全局开并行"这种"风格化决定",通常以"某处更慢"收场。
而"按方法决定"的判据,也正是"三条前提"本身:够重、能切、无共享写;三条都在,才叫"值得"。
而"三条都在"这件事,最好"在评审时被问一次",而不是"在线上被量出来"。
而"评审时问一次",成本只有一分钟。
而"一分钟"能省掉的,是一次"线上更慢"的排查。
9. 一句话总结
并行流是"为'纯 CPU、重元素、易切分'的批量计算准备的工具",而不是"性能开关"。它的三个前提缺一个,就会"更慢"或"更错";而"最稳的用法"是:用 collect 而不是 forEach、按"线上最小核数"评估、以及"先实测再决定"——因为并行流的收益,从来取决于"具体这段计算",而不取决于"机器有几核".