Administrator
发布于 2021-01-21 / 2168 阅读
56

Optional 与 Stream 的性能代价实测

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 8JDK 11相对 for 循环
for 循环412 ns398 ns1.0x
stream + mapToLong509 ns487 ns1.2x
stream + map(装箱)3817 ns3602 ns9.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 的短路操作

有个细节值得一提:anyMatchfindFirst 这类短路操作会提前终止,所以元素命中位置很关键。

@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 分钟):

版本P99Young 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 慢,是我用错了。"

参考