写Stream时踩过的坑:中间操作不会真正执行,直到你调用这一个方法

5 阅读8分钟

「Java 进阶之路」系列 Day38

写在前面

上一篇讲完Lambda表达式的底层原理,这篇顺着往下走——Lambda最常见的应用场景就是Stream API。很多人写Stream写得挺熟练,filter().map().collect()一路链下去很顺手,但被问到"这条链什么时候真正开始计算"、"为什么Stream不能被复用第二次"这类问题时就说不清楚了。这篇结合一个真实的订单统计场景,把Stream的常见用法和背后的坑一次讲透。


一、是什么:Stream 是一条描述"如何计算"的流水线,不是数据容器

Stream不是集合,它本身不存储任何数据,只是对数据源(集合、数组等)的一层封装,描述了一套要对这些数据做的操作。一条Stream的处理链路由三部分组成:数据源、若干个中间操作(intermediate operation,比如filtermapsorted)、一个终止操作(terminal operation,比如collectforEachreduce)。

关键点在于:中间操作是惰性的(lazy),调用filtermap这些方法时,不会立刻遍历数据去执行,只是把这个操作记录下来、返回一个新的Stream描述对象,串成一条待执行的流水线;只有当终止操作被调用时,整条链才会被真正触发,从数据源开始,对每个元素依次执行链上的所有操作。

flowchart LR
    A[数据源 List] --> B[filter 记录规则<br/>map 记录规则<br/>sorted 记录规则]
    B --> E[collect 终止操作<br/>触发真正执行]
    E --> F[遍历元素<br/>依次执行整条链]
    F --> G[得到结果]

也正因为中间操作只是"记录规则"而不是"立刻执行",如果一条Stream链只写了filtermap,没有任何终止操作收尾,这条链其实什么都不会发生——这也是新手最容易踩的第一个坑,后面会详细展开。


二、为什么:从命令式循环到声明式流水线,解决的是什么问题

在Stream出现之前,对集合做"筛选+转换+统计"这类组合操作,只能写成层层嵌套的for循环,判断逻辑和处理逻辑混在一起,读代码的人得在脑子里手动模拟循环过程才能看懂意图。举个真实场景:统计一批订单里,已支付订单按商品类别分组后的总金额。

传统写法:

Map<String, BigDecimal> result = new HashMap<>();
for (Order order : orders) {
    if (order.getStatus() == OrderStatus.PAID) {
        String category = order.getCategory();
        BigDecimal amount = result.getOrDefault(category, BigDecimal.ZERO);
        result.put(category, amount.add(order.getAmount()));
    }
}

这段代码要表达的意图其实很简单——"筛选已支付订单,按类别分组求和",但真正读起来还要先在脑子里过一遍if判断、Map初始化、累加逻辑,命令式写法把"做什么"和"怎么做"揉在了一起。

Stream把这两件事拆开了:链上每一步只声明"做什么"(过滤掉未支付的、按类别分组、对金额求和),具体"怎么遍历、怎么累加"交给Stream内部实现,代码读起来就是需求本身:

Map<String, BigDecimal> result = orders.stream()
        .filter(order -> order.getStatus() == OrderStatus.PAID)
        .collect(Collectors.groupingBy(
                Order::getCategory,
                Collectors.reducing(BigDecimal.ZERO, Order::getAmount, BigDecimal::add)));

除了可读性,惰性求值还带来一个性能上的好处:中间操作会做短路优化。比如链上同时有filterlimit(10),Stream不会先对全部元素做完filter再截取前10个,而是每处理一个元素就立刻走完它在链上的全部操作,凑够10个满足条件的结果就提前终止,不需要遍历完整个数据源。


三、怎么用:真实场景代码 + 常见坑

场景:统计已支付订单中,金额最高的前3个类别

class Order {
    private String category;
    private BigDecimal amount;
    private OrderStatus status;
    // getter省略
}

List<String> top3Categories = orders.stream()
        .filter(order -> order.getStatus() == OrderStatus.PAID)
        .collect(Collectors.groupingBy(
                Order::getCategory,
                Collectors.reducing(BigDecimal.ZERO, Order::getAmount, BigDecimal::add)))
        .entrySet().stream()
        .sorted(Map.Entry.<String, BigDecimal>comparingByValue().reversed())
        .limit(3)
        .map(Map.Entry::getKey)
        .collect(Collectors.toList());

这段代码分两段Stream:第一段按类别分组求和得到Map<String, BigDecimal>,第二段对这个Map的entrySet再开一条Stream,按金额倒序排序后取前3个类别名。两段之间用的是同一套filter/map/sorted/collect词汇,这正是Stream的优势——不管数据源是订单列表还是Map的entrySet,处理套路是统一的。

中间操作和终止操作的常用方法:

类型方法作用
中间操作filter / map / sorted / distinct / limit只记录规则,返回新Stream,不触发执行
终止操作collect / forEach / reduce / count / anyMatch触发整条链真正执行,产出结果或副作用

坑一:Stream只能被消费一次

Stream<Order> stream = orders.stream().filter(o -> o.getAmount().compareTo(BigDecimal.ZERO) > 0);
long count = stream.count();
long sum = stream.count();   // 抛出IllegalStateException:stream has already been operated upon or closed

Stream描述的是一次性的计算过程,一旦执行过一次终止操作,这条流水线就关闭了,不能重新触发。如果同一份数据要做两次不同的统计,要么保留原始集合重新.stream(),要么用Supplier<Stream<T>>每次生成新的Stream。

坑二:Collectors.toMap 遇到重复key会直接抛异常

// 如果orders里有两个订单的category相同,直接抛IllegalStateException: Duplicate key
Map<String, Order> byCategory = orders.stream()
        .collect(Collectors.toMap(Order::getCategory, order -> order));

toMap默认不允许key冲突,真实业务数据很难保证分组字段唯一,必须显式传入合并函数处理冲突,比如Collectors.toMap(Order::getCategory, o -> o, (o1, o2) -> o1)保留先出现的那个,或者干脆改用groupingBy把同key的元素收集成List。

坑三:parallelStream 不是无脑加速器

// 数据量不大时,并行带来的线程切换、任务拆分开销可能比串行处理本身还大
long total = orders.parallelStream()
        .filter(order -> order.getStatus() == OrderStatus.PAID)
        .count();

parallelStream底层依赖ForkJoinPool的公共线程池,把数据拆成多份并行处理,只有在数据量足够大、且每个元素的处理逻辑足够耗时(能覆盖拆分和合并的开销)时才划算。小数据量场景、或者链上的lambda操作了非线程安全的共享状态(比如在forEach里往一个普通ArrayList塞数据),并行反而会更慢,甚至出现并发问题。


四、面试追问

Q1:Stream和集合有什么区别?

集合是数据结构,负责存储和管理数据;Stream不存储数据,它是对数据源的一层计算描述,串联一系列操作、描述"要对这些数据做什么处理",最终通过终止操作触发计算并产出结果。同一个集合可以反复调用stream()生成新的Stream,但每个Stream实例只能被消费一次。

Q2:中间操作和终止操作的区别是什么?惰性求值体现在哪?

中间操作(filtermapsorted等)调用后只是把操作规则记录下来、返回一个新的Stream描述对象,不会立刻遍历数据;终止操作(collectforEachreduce等)调用时才会触发整条链真正执行,从数据源开始逐个元素走完链上所有中间操作。这意味着一条只有中间操作、没有终止操作的Stream链实际上什么都不会执行,这就是惰性求值。

Q3:为什么Stream不能被重复使用?

Stream描述的是一次性的计算过程,终止操作执行完之后这条流水线就被标记为关闭状态,内部实现上不允许对同一个Stream实例二次触发计算,再次调用任何操作方法都会抛出IllegalStateException。如果同一份数据要做多次不同统计,需要基于原始数据源重新创建Stream。

Q4:parallelStream一定比stream快吗?

不一定。parallelStream底层用ForkJoinPool把数据拆分成多份并行处理,只有在数据量足够大、单个元素处理耗时也足够长时,并行带来的收益才能覆盖任务拆分、线程调度、结果合并的额外开销;数据量小或者处理逻辑很轻量时,并行反而可能比串行更慢。另外如果链上的操作访问了非线程安全的共享状态,并行还会引入并发安全问题,不能因为叫"parallel"就默认更快更安全。

Q5:Collectors.groupingBy的实现原理大致是什么?

groupingBy内部本质上是一个Collector,处理时先构造一个HashMap作为容器,然后遍历Stream中的每个元素,用传入的分类函数算出这个元素的key,如果Map里还没有这个key就先放入一个空的下游容器(默认是ArrayList),再把当前元素塞进对应key的容器里;如果传入了下游收集器(比如本文用到的Collectors.reducing),则每个元素会先经过这个下游收集器的累加逻辑处理,而不是简单地塞进List。最终结果就是一个"key分组、value是该组内元素按下游收集器处理后的结果"的Map。


下一篇预告

Day39 讲方法引用的四种形式——静态方法引用、实例方法引用、特定对象的实例方法引用、构造方法引用,把上一篇提到的String::compareToArrayList::new这类写法背后的规则彻底讲清楚。