一行 parallelStream,把整个服务的 CPU 打满了
一行 parallelStream,把整个服务的 CPU 打满了
上周一个同事改了行代码,把普通的 stream() 换成了 parallelStream(),说是要"利用多核加速"。本地测确实快了,发到线上后不久,一个毫不相关的接口开始超时。查下去发现,罪魁祸首就是这一行。
parallelStream 看着像免费的性能午餐,实际上坑不少。这篇说清楚它为什么会出事,以及什么情况下才该用它。
它为什么会拖垮别的服务
关键认知:parallelStream 默认用的是全局共享的线程池。
list.parallelStream()
.map(this::heavyCall)
.collect(Collectors.toList());
这段代码底层用的是 ForkJoinPool.commonPool()——一个整个 JVM 共享 的线程池,默认大小是 CPU 核数 - 1。
问题来了:
- 它会和你的其他并行流抢同一个池。项目里任何一处
parallelStream,以及并行的CompletableFuture(不传自定义 executor 时),全都挤在这一个公共池里。一处慢任务,处处跟着遭殃。 - CPU 被打满。8 核机器上公共池会开满工作线程,如果你的 map 里有密集计算,几个并行流就能把 CPU 吃到 100%,把留给其他请求的算力全抢走了。
那次线上超时的真相就是:这个新上的 parallelStream 把公共池和 CPU 占满,另一个本该秒回的接口因为抢不到线程而超时。它们看似无关,实则共用同一个池。
更隐蔽的坑:它不适合 I/O 密集
很多人以为"并行=快",于是对着一个"循环里调 RPC / 查数据库"的列表上 parallelStream。这恰恰是最糟的用法:
- 公共池的线程数按 CPU 核数设计(核数 - 1),本来就只有几个线程;
- 每个线程都在等 I/O(网络、DB),干等不产生算力,任务吞吐被这几个线程死死限制住;
- 你想并行发起 100 个下游调用,实际同时在跑的只有
核数-1个,加速微乎其微,还占满了公共池。
parallelStream 适合 CPU 密集、数据量大、无共享可变状态的场景(比如对上万元素做纯计算)。I/O 密集场景要的是"大量线程各自阻塞等待",那是虚拟线程或专用线程池的活,不是并行流。
还有这些容易被忽略的点
顺序不再保证。 并行流打乱了处理顺序,依赖顺序的 reduce、累加、分页逻辑可能得到不同结果。需要顺序请用 stream()。
不是线程安全的操作会出错。
// ❌ 危险:多个线程并发写同一个 ArrayList
List<String> result = new ArrayList<>();
list.parallelStream().forEach(result::add); // 数据竞争,可能丢元素或抛异常
// ✅ 用 collect 让框架自己处理并发归并
List<String> result = list.parallelStream()
.map(String::toUpperCase)
.collect(Collectors.toList());
调试地狱。 栈帧被拆散到多个 ForkJoin 线程,异常堆栈难以复现,本地单步调试几乎失效。
如果确实要用,怎么隔离风险
不要用公共池,给它一个专属的 ForkJoinPool:
ForkJoinPool customPool = new ForkJoinPool(4); // 自己控制并行度
try {
result = customPool.submit(
() -> list.parallelStream()
.map(this::compute)
.collect(Collectors.toList())
).get();
} finally {
customPool.shutdown();
}
这样至少不会和其他业务的公共池互相干扰,并行度也可控。
我的建议
- 默认别用 parallelStream。先用普通
stream(),只有当 profiling 证明某段 CPU 密集计算确实是瓶颈,再考虑并行。 - I/O 密集的并发,交给专用线程池或虚拟线程,不是并行流。
- 非用不可时,用自定义
ForkJoinPool把公共池隔离出去。 - 警惕任何依赖顺序、或往共享集合里写的并行流。
一行"看起来更先进"的代码,前提是理解它到底动了谁的奶酪。这次事故里没人会怀疑那个超时的接口——它什么都没改,只是和一个贪婪的并行流共用了同一个线程池而已。
- 点赞
- 收藏
- 关注作者
评论(0)