340. Java Stream API - 理解并行流的额外开销
------ 并行,不等于免费午餐!
🚀 并行流的潜在收益是有代价的!
使用 parallelStream() 看似能提升性能,但背后要付出不少额外开销:
- 数据必须被拆分成子任务
- 子任务由多个线程并发执行
- 所有子任务的结果需要合并
⚠️ 如果你的任务本来就很轻量,或者数据不适合拆分,那么使用并行流反而会更慢!
📦 拆分数据的开销
数据的拆分(Splitting)不是"白送的",它会消耗资源:
- ✅ 易于拆分的源 :比如
ArrayList,因为支持按索引随机访问 - ❌ 难以拆分的源 :比如
LinkedList,只能顺序遍历,拆分非常低效
🔍 举例:
java
List<Integer> arrayList = IntStream.range(0, 1_000_000)
.boxed()
.collect(Collectors.toCollection(ArrayList::new));
List<Integer> linkedList = IntStream.range(0, 1_000_000)
.boxed()
.collect(Collectors.toCollection(LinkedList::new));
long t1 = System.nanoTime();
arrayList.parallelStream().map(x -> x * 2).toList();
System.out.println("ArrayList time: " + (System.nanoTime() - t1));
long t2 = System.nanoTime();
linkedList.parallelStream().map(x -> x * 2).toList();
System.out.println("LinkedList time: " + (System.nanoTime() - t2));
📉 你会发现
LinkedList表现很差,因为拆分难!
⚙️ 合并结果的成本
并行处理完后,所有子线程都要把结果汇总回来,这也有开销:
| 合并类型 | 性能成本 | 说明 |
|---|---|---|
| ✅ 求和 | 低 | 每个线程只返回一个整数 |
| ✅ 收集到 List | 低 | 常见 Collector 支持高效合并 |
| ⚠️ 合并 HashMap | 高 | 存在键冲突时需要协调、加锁或合并逻辑 |
示例:合并 HashMap
java
Map<Integer, String> map = IntStream.range(0, 10_000)
.parallel()
.boxed()
.collect(Collectors.toMap(
i -> i % 1000, // ❗会产生重复 key
i -> "Val" + i,
(v1, v2) -> v1 + "/" + v2 // 手动合并冲突值
));
💥 如果你忘了写 merge 函数,程序会抛出
IllegalStateException!
🚧 并发副作用的地雷区
一旦你引入共享状态,并行流的所有优势都可能崩塌!
java
List<Integer> result = new ArrayList<>();
IntStream.range(0, 1000)
.parallel()
.forEach(result::add); // ❌ 并发写入非线程安全集合
☠️ 有副作用的代码不仅慢,而且错得离谱!
📏 判断是否使用并行流的 4 条黄金法则
| 🧪 法则 | 内容 |
|---|---|
| Rule #1 | 不要为了"酷"而优化。只有在确实性能不达标时才考虑并行。 |
| Rule #2 | 明智地选择数据源。避免使用不能高效拆分的结构(如 LinkedList)。 |
| Rule #3 | 不要修改外部状态,不要共享可变状态。 |
| Rule #4 | 不要猜性能!请使用基准测试(如 JMH)来实测。 |
🧪 使用 JMH 快速比较并行与串行性能
java
@Benchmark
public void serialSum() {
IntStream.range(0, 1_000_000).sum();
}
@Benchmark
public void parallelSum() {
IntStream.range(0, 1_000_000).parallel().sum();
}
📊 有些场景下,串行甚至更快!
✅ 小结语:
并行不是"开个线程就能快"。你要考虑数据结构、任务粒度、副作用、合并成本。否则,你是在用8核CPU完成一个人5秒能搞定的事,还掉进了坑里!