「Java 进阶之路」系列 Day38
写在前面
上一篇讲完Lambda表达式的底层原理,这篇顺着往下走------Lambda最常见的应用场景就是Stream API。很多人写Stream写得挺熟练,filter().map().collect()一路链下去很顺手,但被问到"这条链什么时候真正开始计算"、"为什么Stream不能被复用第二次"这类问题时就说不清楚了。这篇结合一个真实的订单统计场景,把Stream的常见用法和背后的坑一次讲透。
一、是什么:Stream 是一条描述"如何计算"的流水线,不是数据容器
Stream不是集合,它本身不存储任何数据,只是对数据源(集合、数组等)的一层封装,描述了一套要对这些数据做的操作。一条Stream的处理链路由三部分组成:数据源 、若干个中间操作 (intermediate operation,比如filter、map、sorted)、一个终止操作 (terminal operation,比如collect、forEach、reduce)。
关键点在于:中间操作是惰性的(lazy) ,调用filter、map这些方法时,不会立刻遍历数据去执行,只是把这个操作记录下来、返回一个新的Stream描述对象,串成一条待执行的流水线;只有当终止操作被调用时,整条链才会被真正触发,从数据源开始,对每个元素依次执行链上的所有操作。
也正因为中间操作只是"记录规则"而不是"立刻执行",如果一条Stream链只写了filter、map,没有任何终止操作收尾,这条链其实什么都不会发生------这也是新手最容易踩的第一个坑,后面会详细展开。
二、为什么:从命令式循环到声明式流水线,解决的是什么问题
在Stream出现之前,对集合做"筛选+转换+统计"这类组合操作,只能写成层层嵌套的for循环,判断逻辑和处理逻辑混在一起,读代码的人得在脑子里手动模拟循环过程才能看懂意图。举个真实场景:统计一批订单里,已支付订单按商品类别分组后的总金额。
传统写法:
java
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内部实现,代码读起来就是需求本身:
java
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)));
除了可读性,惰性求值还带来一个性能上的好处:中间操作会做短路优化 。比如链上同时有filter和limit(10),Stream不会先对全部元素做完filter再截取前10个,而是每处理一个元素就立刻走完它在链上的全部操作,凑够10个满足条件的结果就提前终止,不需要遍历完整个数据源。
三、怎么用:真实场景代码 + 常见坑
场景:统计已支付订单中,金额最高的前3个类别
java
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只能被消费一次
java
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会直接抛异常
java
// 如果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 不是无脑加速器
java
// 数据量不大时,并行带来的线程切换、任务拆分开销可能比串行处理本身还大
long total = orders.parallelStream()
.filter(order -> order.getStatus() == OrderStatus.PAID)
.count();
parallelStream底层依赖ForkJoinPool的公共线程池,把数据拆成多份并行处理,只有在数据量足够大、且每个元素的处理逻辑足够耗时(能覆盖拆分和合并的开销)时才划算。小数据量场景、或者链上的lambda操作了非线程安全的共享状态(比如在forEach里往一个普通ArrayList塞数据),并行反而会更慢,甚至出现并发问题。
四、面试追问
Q1:Stream和集合有什么区别?
集合是数据结构,负责存储和管理数据;Stream不存储数据,它是对数据源的一层计算描述,串联一系列操作、描述"要对这些数据做什么处理",最终通过终止操作触发计算并产出结果。同一个集合可以反复调用stream()生成新的Stream,但每个Stream实例只能被消费一次。
Q2:中间操作和终止操作的区别是什么?惰性求值体现在哪?
中间操作(filter、map、sorted等)调用后只是把操作规则记录下来、返回一个新的Stream描述对象,不会立刻遍历数据;终止操作(collect、forEach、reduce等)调用时才会触发整条链真正执行,从数据源开始逐个元素走完链上所有中间操作。这意味着一条只有中间操作、没有终止操作的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::compareTo、ArrayList::new这类写法背后的规则彻底讲清楚。