review 时和同事吵了一架
1 月中旬做 code review,我写了一段统计订单金额的代码:
long total = orders.stream()
.mapToLong(Order::getAmount)
.sum();
同事评论:"Stream 有性能损耗,这里调用很频繁,改回 for 循环吧。"
我说这点开销可以忽略。他说 Stream 每次都要装箱。我说我用了 mapToLong 没装箱。我俩谁也没说服谁,于是我写了个 JMH 测一下。
测试环境和方法
JMH 1.23,JDK 8u272 和 JDK 11.0.9 各跑一遍。机器是公司发的 MacBook Pro 2019,i7-9750H 6 核。
<dependency>
<groupId>org.openjdk.jmh</groupId>
<artifactId>jmh-core</artifactId>
<version>1.23</version>
<scope>test</scope>
</dependency>
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
@State(Scope.Thread)
@Fork(1)
@Warmup(iterations = 3, time = 1)
@Measurement(iterations = 5, time = 1)
public class StreamBenchmark {
private List<Order> orders;
@Setup
public void setup() {
orders = new ArrayList<>();
for (int i = 0; i < 1000; i++) {
orders.add(new Order((long) i, i * 7L));
}
}
// ...
}
基准是 1000 个元素的列表。这里必须说清楚:数据量会极大影响结论,后面会看到。
第一轮:求和,Stream vs for
@Benchmark
public long forLoop() {
long sum = 0;
for (int i = 0; i < orders.size(); i++) {
sum += orders.get(i).getAmount();
}
return sum;
}
@Benchmark
public long streamMapToLong() {
return orders.stream().mapToLong(Order::getAmount).sum();
}
@Benchmark
public long streamBoxed() {
// 先装箱成 Stream<Long> 再转回 long,模拟误用
return orders.stream().map(Order::getAmount).reduce(0L, Long::sum);
}
Benchmark (JDK 8) (JDK 11) Mode Cnt Score Error Units
forLoop 412.3 ± 6.1 ns/op
streamMapToLong 508.7 ± 9.4 ns/op
streamBoxed 3816.5 ± 42.3 ns/op
| 写法 | JDK 8 | JDK 11 | 相对 for 循环 |
|---|---|---|---|
| for 循环 | 412 ns | 398 ns | 1.0x |
| stream + mapToLong | 509 ns | 487 ns | 1.2x |
| stream + map(装箱) | 3817 ns | 3602 ns | 9.3x |
结论一:Stream 本身的开销很小,慢的是装箱。1000 个元素,mapToLong 比 for 循环多 97 ns,摊到每个元素 0.1 ns。而一旦用了 map(Order::getAmount) 变成 Stream<Long>,慢了 9 倍。
原因是 Stream<Long> 里每个元素都要 Long.valueOf() 一次,1000 个元素就是 1000 次装箱 + 后续 1000 次拆箱,还产生了 1000 个 Long 对象等着 GC。
第二轮:Optional 的代价
@Benchmark
public long optionalChain() {
return Optional.ofNullable(config)
.map(OrderConfig::getMaxAmount)
.orElse(100L);
}
@Benchmark
public long ternary() {
OrderConfig c = config;
return c == null ? 100L : c.getMaxAmount();
}
Benchmark Score Error Units
ternary 2.1 ± 0.03 ns/op
optionalChain 3.4 ± 0.07 ns/op
单次调用差 1.3 ns。这个数字的意义是:如果一个接口调用 10 次 Optional 链,总共多花 13 ns,而一次 Dubbo 调用是 100 万 ns 级别。完全可以忽略。
但这不代表 Optional 免费。它慢在别的地方——作为字段类型或集合元素:
@Benchmark
public long optionalField() {
// List<Optional<Long>> 的场景
long sum = 0;
for (Optional<Long> o : optionalList) {
sum += o.orElse(0L);
}
return sum;
}
Benchmark Score Error Units
plainLongList 1043 ± 12 ns/op (List<Long>)
optionalList 1587 ± 21 ns/op (List<Optional<Long>>)
慢 52%,而且内存占用翻倍:1000 个 Long 是 1000 个对象,1000 个 Optional<Long> 是 2000 个。这就是 Optional 的正确用法边界:做返回值可以,别做字段,别放集合里。
第三轮:逃逸分析真的起作用了吗
既然有装箱,JVM 的逃逸分析能不能把对象分配优化掉?我加参数对比了一下:
# 默认(开启逃逸分析 + 标量替换)
$ java -jar target/benchmarks.jar streamBoxed
Benchmark Score Error Units
streamBoxed 3816.5 ± 42.3 ns/op
# 关闭逃逸分析
$ java -XX:-DoEscapeAnalysis -jar target/benchmarks.jar streamBoxed
Benchmark Score Error Units
streamBoxed 5962.1 ± 88.7 ns/op
# 关闭标量替换
$ java -XX:-EliminateAllocations -jar target/benchmarks.jar streamBoxed
Benchmark Score Error Units
streamBoxed 5418.3 ± 71.2 ns/op
逃逸分析确实起作用了,把 5962 ns 优化到 3816 ns,省了 36%。但它救不了全部:即便优化后仍比 mapToLong 慢 7.5 倍。
原因是逃逸分析生效有条件:对象的作用域必须局限在当前方法内、不能被外部引用、方法不能被过度内联膨胀。Stream 的 lambda 调用链很长,很多分配点分析不出来。
顺手用 JFR 看了一把分配量:
$ java -XX:StartFlightRecording=duration=60s,filename=alloc.jfr -jar ...
$ jfr summary alloc.jfr | grep -i "long"
Event Type Count Size
jdk.ObjectAllocationOutsideTLAB 1,204,331 19.2 MB
jdk.ObjectAllocationInNewTLAB 812,004 412.8 MB
60 秒里 streamBoxed 那组分配了 412 MB。mapToLong 那组是 3.7 MB。差了 100 倍。
顺带测了 Stream 的短路操作
有个细节值得一提:anyMatch、findFirst 这类短路操作会提前终止,所以元素命中位置很关键。
@Benchmark
public boolean streamAnyMatchHitAtFirst() {
return orders.stream().anyMatch(o -> o.getAmount() > 0); // 第一条就命中
}
@Benchmark
public boolean streamAnyMatchNeverHit() {
return orders.stream().anyMatch(o -> o.getAmount() < 0); // 全部遍历
}
Benchmark Score Units
streamAnyMatchHitAtFirst 11.8 ± 0.2 ns/op
streamAnyMatchNeverHit 512.3 ± 8.1 ns/op
差 43 倍。这个对比说明一件事:短路操作的收益完全取决于命中位置。如果条件几乎总是第一个就命中,Stream 和 for 循环的差别可以忽略;如果需要遍历完整个集合,那 Stream 的每次迭代开销就会被放大 1000 倍。
热点路径上怎么选
我把结论用在了我们一个真实的场景:风控规则引擎里有段代码,每次请求要遍历 5000 个规则项做匹配,QPS 峰值 2000。
// 改之前
boolean hit = ruleItems.stream()
.map(RuleItem::getScore)
.filter(Objects::nonNull)
.anyMatch(s -> s > threshold);
// 改之后
boolean hit = false;
for (int i = 0; i < ruleItems.size(); i++) {
Integer s = ruleItems.get(i).getScore();
if (s != null && s > threshold) {
hit = true;
break;
}
}
压测对比(QPS 2000,持续 5 分钟):
| 版本 | P99 | Young GC 频率 | CPU |
|---|---|---|---|
| Stream 版 | 48 ms | 每 3.2 秒 | 71% |
| for 循环版 | 31 ms | 每 11.5 秒 | 54% |
这个场景值得改,因为它在每秒被调用 1000 万次(2000 QPS × 5000 项)。而商品详情页里那种"一次请求遍历 20 个元素"的 Stream,我没有改,可读性更重要。
parallelStream 别乱用
顺手测了并行流,结果挺打脸的:
@Benchmark
public long parallel() {
return orders.parallelStream().mapToLong(Order::getAmount).sum();
}
Benchmark Score Error Units
streamMapToLong 508.7 ± 9.4 ns/op
parallelStream 8912.4 ± 156.7 ns/op
慢了 17 倍。1000 个元素的简单求和,拆任务、合并结果的开销远大于计算本身。而且 parallelStream 用的是 ForkJoinPool.commonPool(),全 JVM 共享——我在另一篇文章里写过,一个报表任务把这个池占满,导致线上接口集体超时。
parallelStream 只在"单个元素处理很重 + 数据量大 + 池干净"时才划算,我工作四年就用过一次(批量计算 20 万条数据的对账结果)。
小结
- Stream 本身只比 for 循环慢 20%(1000 元素 509 ns vs 412 ns),慢的是装箱。记得用
mapToInt/mapToLong/mapToDouble。 Stream<Long>比LongStream慢 9 倍,60 秒压测下对象分配量差 100 倍。- 单次 Optional 链只比三元表达式慢 1.3 ns,随便用。但别把 Optional 当字段类型、别放集合里——
List<Optional<Long>>比List<Long>慢 52%,内存翻倍。 - 逃逸分析能优化掉 36% 的开销,但救不了装箱。
- 判断标准看调用次数:每秒百万次以上的热点路径用 for 循环,普通业务代码用 Stream,可读性优先。
parallelStream在我们的测试里慢 17 倍,默认别用。
那次 review 最后我改了风控那段,详情页那段没改,并把这组数据整理进了团队的代码规范。同事看完说了一句:"原来不是 Stream 慢,是我用错了。"