Java 8 Stream 实战:分组、聚合与常见坑 Java 8 Stream 实战:分组、聚合与常见坑一堆订单要按用户分组、算每组总额、再筛出金额过万的用户——用传统的 for 循环加 HashMap 能写,但代码又长又容易错。Java 8 的 Stream 加Collectors能把这类「分组 聚合」压到几行。可惜网上教程大多停在collect(toList()),真正高频的分组聚合反而讲得少,还藏着几个一踩一个准的坑。这篇专攻这块。先准备一个真实点的数据后面全程用这批订单举例:recordOrder(Stringuser,Stringcategory,intamount){}ListOrderordersList.of(newOrder(张三,数码,3000),newOrder(张三,图书,200),newOrder(李四,数码,8000),newOrder(李四,数码,5000),newOrder(王五,图书,150));groupingBy:一行搞定分组最基础的需求:按用户把订单分组。传统写法要先new HashMap、判断 key 存不存在、computeIfAbsent……Stream 一行:importstaticjava.util.stream.Collectors.*;MapString,ListOrderbyUserorders.stream().collect(groupingBy(Order::user));// {张三[数码3000, 图书200], 李四[数码8000, 数码5000], 王五[图书150]}groupingBy(Order::user)的意思是:以user为 key,把 user 相同的元素收进一个 List。默认 value 就是List,这是最常用的形态。分组的同时做聚合:第二个参数真实需求很少只是「分个组」,更多是「分组后算点什么」——每个用户消费总额、订单数、平均值。groupingBy的第二个参数(下游收集器)专门干这个。// 每个用户的消费总额MapString,IntegertotalByUserorders.stream().collect(groupingBy(Order::user,summingInt(Order::amount)));// {张三3200, 李四13000, 王五150}// 每个用户的订单数MapString,LongcountByUserorders.stream().collect(groupingBy(Order::user,counting()));// {张三2, 李四2, 王五1}// 每个用户的平均消费MapString,DoubleavgByUserorders.stream().collect(groupingBy(Order::user,averagingInt(Order::amount)));// {张三1600.0, 李四6500.0, 王五150.0}summingInt、counting、averagingInt就是下游收集器。记住这个结构:groupingBy(按谁分, 每组做什么),分组聚合的九成需求都是它。多级分组:分组里再分组需求升级:先按用户分,每个用户内部再按品类分,统计各品类花了多少。groupingBy的下游收集器可以再放一个groupingBy,自然形成嵌套 Map:MapString,MapString,IntegerbyUserThenCategoryorders.stream().collect(groupingBy(Order::user,groupingBy(Order::category,summingInt(Order::amount))));// {张三{数码3000, 图书200}, 李四{数码13000}, 王五{图书150}}结构读起来就是「先按 user 分,每组内再按 category 分,最后对金额求和」。层级想加几层加几层,但超过两层可读性就差了,到那份上建议换成专门的 DTO。分组后再筛选:别在错误的地方 filter一个高频需求:找出总消费过万的用户。新手常犯的错是先filter再分组——但 filter 作用在单条订单上,而「总额过万」是分组后才有的概念,顺序根本不对。正确做法是先分组聚合,再用 Java 8 的filtering或对结果 Map 过滤:// 正确:先分组算总额,再筛结果MapString,IntegerrichUsersorders.stream().collect(groupingBy(Order::user,summingInt(Order::amount))).entrySet().stream().filter(e-e.getValue()10000)// 对「每组总额」筛选.collect(toMap(Map.Entry::getKey,Map.Entry::getValue));// {李四13000}关键点:聚合值(总额)是分组之后才存在的,所以筛选必须发生在分组之后。想在组内筛选原始元素(比如「只统计每个用户的数码订单」),才用groupingBy(..., filtering(cond, downstream))——filtering和filter名字像,作用位置完全不同,别混。三个一踩一个准的坑坑 1:groupingBy 的 key 为 null 直接 NPE如果分组字段可能是null,groupingBy会抛NullPointerException——它内部拿 key 去 HashMap 时不接受 null 逻辑(JDK 实现限制)。数据不干净时先兜底:MapString,ListOrdersafeorders.stream().collect(groupingBy(o-o.category()null?未分类:o.category()));坑 2:toMap 遇到重复 key 直接抛异常用toMap时如果两条数据算出同一个 key,默认行为是抛IllegalStateException: Duplicate key,而不是覆盖:// 危险:user 有重复就炸orders.stream().collect(toMap(Order::user,Order::amount));// 正确:第三个参数指定冲突时怎么合并(这里相加)MapString,Integermergedorders.stream().collect(toMap(Order::user,Order::amount,Integer::sum));// {张三3200, 李四13000, 王五150}只要用toMap且 key 不保证唯一,永远带上第三个合并函数,否则线上数据一重复就异常。坑 3:Stream 用完就废,不能重复消费Stream 是一次性的,collect或forEach之后再用同一个 Stream 会抛IllegalStateException: stream has already been operated upon or closed:StreamOrdersorders.stream();s.count();// 第一次终端操作,OKs.collect(toList());// 报错:stream 已被操作过要复用逻辑,复用的是数据源(orders),每次重新.stream(),而不是把 Stream 对象存起来反复用。小结分组聚合的黄金结构:groupingBy(按谁分, 每组做什么),下游收集器用summingInt/counting/averagingInt。多级分组:下游再套一个groupingBy,嵌套 Map;超过两层就换 DTO。分组后筛选:聚合值分组后才存在,filter 要放在分组之后;组内筛原始元素才用filtering。三个必记坑:分组 key 为 null 会 NPE(先兜底)、toMap重复 key 会抛异常(带合并函数)、Stream 不可重复消费(重新.stream())。一句话记忆:Stream 让「分组算账」变简单,但 null、重复 key、二次消费这三根刺,不避开迟早扎到线上。