2053 字
约 6 分钟
16
S04-A26 · `parallelStream`:为什么"开了更慢",而且不总是更快

一个"报表聚合"从 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. 自测

  1. 并行流的三个前提是什么?为什么"元素太轻"会让它更慢?
  2. 为什么 LinkedList 的"不可切分"会让并行退化成"更慢"?
  3. "并行流 + forEach 写 ArrayList"为什么会丢数据?正确写法是什么?
  4. 自定义 ForkJoinPool 为什么"不能靠'在父线程里调 parallelStream'"?
  5. "8 核更快、4 核更慢"说明了什么?评估该以什么为基准?
  6. 判断"能否并行"的那个问题是什么?

补一句"决策"建议:"并行流"应当'按方法'决定,而不是'按项目'决定——同一个项目里,"聚合统计"可能值得并行,"单个订单校验"绝不值得;而"全局开并行"这种"风格化决定",通常以"某处更慢"收场。

而"按方法决定"的判据,也正是"三条前提"本身:够重、能切、无共享写;三条都在,才叫"值得"。

而"三条都在"这件事,最好"在评审时被问一次",而不是"在线上被量出来"。

而"评审时问一次",成本只有一分钟。

而"一分钟"能省掉的,是一次"线上更慢"的排查。

9. 一句话总结

并行流是"为'纯 CPU、重元素、易切分'的批量计算准备的工具",而不是"性能开关"。它的三个前提缺一个,就会"更慢"或"更错";而"最稳的用法"是:用 collect 而不是 forEach、按"线上最小核数"评估、以及"先实测再决定"——因为并行流的收益,从来取决于"具体这段计算",而不取决于"机器有几核".

S04-A26 · `parallelStream`:为什么"开了更慢",而且不总是更快
https://www.clxhxhhr.top/posts/6712/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。