网上教 Stream 的文章,多半上来就摆一排方法名:map、filter、collect……背完还是不会用。这篇文章换个教法:每一段 Stream,都是你已经写过一百遍的 for 循环的另一种记法。你不需要学新知识,只需要学翻译。
十二个模式全部来自一个真实项目——订单行(JtOrderLine)、物料特性(JtOrderLineMaterialCharacteristic)、特性字典(JtFeature)——类名原样保留。每一式固定三段:for 时代怎么写、Stream 怎么写、坑在哪。先看熟的,再看新的,最后记住它会在哪摔跤。
开工前:Stream 是什么(90 秒版)
把 Stream 想成一条流水线,分三段:
- 数据源:
list.stream()把集合搬上传送带; - 中间操作:
map/filter/distinct/sorted是传送带上的工位,只登记、不干活; - 终结操作:
collect/anyMatch/reduce是开工铃——铃不响,前面登记的所有工位一行代码都不会执行。
最后一条最容易踩:只写list.stream().map(...)不接终结操作,什么都不会发生。这不是 bug,是设计——工位先登记好,开工铃一响数据一次性流过,中间不用落地。
两个语法糖也先拆掉:
o -> o.getUnitPrice()读作"给我一个 o,返回它的 unitPrice"——这就是 lambda,循环体的参数化写法;JtOrderLine::getId是它的缩写,专指"调它的 getId"——方法引用,没有更多魔法。
速览:我要干 X → 用哪式
这张表当翻译词典用:手里有段 for 循环,先想清楚它在干嘛,再对表取写法。
| 我要干的事 | 模式 | 核心方法 |
|---|---|---|
| 提取 id 批量查,防 N+1 | 模式 1 | map+filter+distinct+Collectors.toList() |
| 按条件留下/丢掉元素 | 模式 2 | filter |
| 建查找表,循环里按 key 查 | 模式 3 | Collectors.toMap |
| 只回答"在不在" | 模式 4 | Collectors.toSet+contains |
| 加工完覆盖原变量 | 模式 5 | filter/map+ 重新赋值 |
| 按 key 聚合、报表归类 | 模式 6 | Collectors.groupingBy |
| 只要"是/否"结论 | 模式 7 | anyMatch/allMatch/noneMatch |
| 排序 | 模式 8 | sorted+Comparator |
| 拼逗号串 | 模式 9 | Collectors.joining |
| 嵌套结构(订单→行)拍平 | 模式 10 | flatMap |
| 合计、均值、最大最小 | 模式 11 | reduce/mapToInt+summaryStatistics |
| 选收尾写法 | 模式 12 | collect(toList())/toList()/toSet() |
模式 1:提取字段 → 批量查询(防 N+1)
for 时代这么写:查完主表要拿子表数据,逐行查库——1 次主查询 + N 行各查 1 次 = N+1 次数据库往返。100 行订单就是 101 次查库。去重还得自己拿contains防(它要从头扫到尾,又慢一层):
List<Long>orderLineIds=newArrayList<>();for(JtOrderLineline:orderLines){Longid=line.getId();if(id!=null&&!orderLineIds.contains(id)){// contains 每次从头扫orderLineIds.add(id);}}Stream 这么写:收齐 id,一次IN查询,把 1+N 次数据库往返压成 2 次。链条顺着读:对象变 id(map)→ 扔掉 null(filter)→ 去重(distinct)→ 装箱(collect):
// 第一步:从订单行列表提取所有行 idList<Long>orderLineIds=orderLines.stream().map(JtOrderLine::getId)// 对象 → id.filter(Objects::nonNull)// 剔除 null,防止 IN 列表脏数据.distinct()// 去重(基于哈希,不用从头扫).collect(Collectors.toList());// 收成 List// 第二步:一次 IN 查询拿全部关联数据List<JtOrderLineMaterialCharacteristic>characteristics=materialCharacteristicRepository.selectByCondition(Condition.builder(JtOrderLineMaterialCharacteristic.class).andWhere(Sqls.custom().andIn("orderLineId",orderLineIds)).build());坑在哪:
- id 列表可能是空的。空列表拼
IN会生成IN (),直接 SQL 报错(Oracle 是 ORA-00936)——IN 语法不为空列表负责,判空是你的事。 filter(Objects::nonNull)不是可有可无:id 字段混进 null,IN 列表就是脏的。项目代码里这行注释写的就是"防止 IN 列表脏数据"。
模式 2:条件过滤
for 时代这么写:for + if + add,三件套:
List<JtOrderLineMaterialCharacteristic>kept=newArrayList<>();for(JtOrderLineMaterialCharacteristico:characteristics){if(o.getCharacteristicValueNumber()!=null||o.getCharacteristicValueSingle()!=null||!StringUtils.isEmpty(o.getCharacteristicValueText())||multiValueIds.contains(o.getId())){kept.add(o);}}characteristics=kept;Stream 这么写:
// 保留"五种值形态任一有值"的特性行,丢弃空行characteristics=characteristics.stream().filter(o->o.getCharacteristicValueNumber()!=null||o.getCharacteristicValueSingle()!=null||!StringUtils.isEmpty(o.getCharacteristicValueText())||multiValueIds.contains(o.getId())).collect(Collectors.toList());// 就地重赋值坑在哪:
- filter 的 lambda 必须返回 boolean:true 留、false 丢,没有第三个选项。
- 产物是新列表。
characteristics = ...是重新赋值,不是原地修改——原列表对象一个元素都没少。
模式 3:建查找表(toMap)
for 时代这么写:循环里要反复按 key 查值,先把 List 转成 Map,一次遍历建好索引:
Map<Long,String>featureMap=newHashMap<>();for(JtFeaturef:featureList){featureMap.put(f.getId(),f.getFeatureCode());// 注意 put 的脾气,下面说}Stream 这么写:
// 特性 id → 特性编码Map<Long,String>featureMap=featureList.stream().collect(Collectors.toMap(JtFeature::getId,// key:特性idJtFeature::getFeatureCode// value:特性编码));// 后续循环里直接查,不用再遍历Stringcode=featureMap.get(characteristicDTO.getRelateFeature());坑在哪:for 版和 Stream 版的脾气不一样,这是最容易栽的地方:
- for +
put是老好人:key 重复就静默覆盖(你根本不知道数据少了);value 是 null 也照收。不报错,但坑是安静的。 - toMap 是较真的人:key 重复直接抛
IllegalStateException;value 为 null 直接 NPE(空指针异常——对 null 调方法,Java 最常见的报错)。机制在底层:toMap 用Map.merge装值,merge 的规矩就是拒收 null value。 - 所以大项目规范要求必须写第三个参数,把"重复了怎么办"说在明面上:
Collectors.toMap(X::getId,X::getName,(first,second)->first)// 重复时保留第一个- value 可能空的字段要进 Map?老老实实换回 for 循环填。
模式 4:判存在(toSet)
for 时代这么写:只需要回答"这个 id 在不在名单里",很多人顺手收个 List,然后每次contains从头扫到尾——List 的 contains 是 O(n),n 个元素查 n 次,就是双重循环。Set 的 contains 是 O(1):HashSet 按内容直接算出该放哪个抽屉,一步到位,像查字典直接翻到那一页,而不是从第一页翻到最后一页:
Set<Long>multiValueIds=newHashSet<>();for(CharacteristicDTOdto:characteristicMulRows){multiValueIds.add(dto.getId());// add 顺手去重}Stream 这么写:
// 有字典行的特性行 id = 多选特性行Set<Long>multiValueIds=characteristicMulRows.stream().map(CharacteristicDTO::getId).collect(Collectors.toSet());// 后续 filter 里逐行判断,Set 查找快.filter(o->...||multiValueIds.contains(o.getId()))坑在哪:HashSet 不保序。需要"去重 + 保序"时用LinkedHashSet:collect(Collectors.toCollection(LinkedHashSet::new))。
模式 5:加工完覆盖原变量
for 时代这么写:其实就是模式 2 的收尾动作——建个新列表装过滤结果,再用它换掉旧变量,省一个新变量名:
List<CharacteristicDTO>kept=newArrayList<>();for(CharacteristicDTOdto:characteristicDTOList){if(matchedFeatureIds.contains(dto.getRelateFeature())){kept.add(dto);}}characteristicDTOList=kept;// 新列表换旧引用Stream 这么写:
// 原变量被过滤后的版本替换characteristicDTOList=characteristicDTOList.stream().filter(dto->matchedFeatureIds.contains(dto.getRelateFeature())).collect(Collectors.toList());坑在哪:本质是"新列表覆盖旧引用",只在方法内部的局部变量上成立——给方法入参重新赋值,调用方手里的引用纹丝不动。方法里换的是你手里那份,别人手里那份还是旧的。
模式 6:分组(groupingBy)
for 时代这么写:按 key 把列表拆成若干组,是报表、归类、按父单聚合子行的常客。手写版是著名的六行模板:
Map<Long,List<JtOrderLine>>linesByOrderId=newHashMap<>();for(JtOrderLineline:allLines){LongorderId=line.getOrderId();List<JtOrderLine>bucket=linesByOrderId.get(orderId);if(bucket==null){// 这组还没有桶bucket=newArrayList<>();linesByOrderId.put(orderId,bucket);// 给它发一个}bucket.add(line);}Stream 这么写:六行模板换一行:
// 按订单 id 把所有订单行分组Map<Long,List<JtOrderLine>>linesByOrderId=allLines.stream().collect(Collectors.groupingBy(JtOrderLine::getOrderId));// 结果:{ 1001=[行1,行2], 1002=[行3] }进阶:分组后再对组内做转换,用groupingBy+mapping——组里只留行号,不要整个对象:
// 按订单分组,组内只要行号不要整个对象Map<Long,List<String>>lineNosByOrder=allLines.stream().collect(Collectors.groupingBy(JtOrderLine::getOrderId,Collectors.mapping(JtOrderLine::getLineNumber,Collectors.toList())));坑在哪:分组函数返回 null 会直接 NPE——JDK 在这里做了显式的非空校验,报错原文就是 “element cannot be mapped to a null key”。分组键可能为 null 的字段,先 filter 掉或先兜默认值。
模式 7:存在性判断(anyMatch / allMatch / noneMatch)
for 时代这么写:只要一个"是/否"结论,for + boolean 标志 + break 三件套:
booleanhasEmptyPrice=false;for(JtOrderLineline:orderLines){if(line.getUnitPrice()==null){hasEmptyPrice=true;break;// 找到一个就够了}}Stream 这么写:
// 旧写法:for 循环 + boolean 标志 + break// 新写法:一行booleanhasEmptyPrice=orderLines.stream().anyMatch(line->line.getUnitPrice()==null);// 任一行没价格 → true// 全部满足用 allMatch;全不满足用 noneMatchbooleanallPushed=orders.stream().allMatch(o->o.getSalesOrderNumber()!=null);坑在哪:
- anyMatch 找到第一个满足的元素就短路返回,不遍历全量——for 版的 break 被写进了它的定义里,这是保证,不是巧合。反过来,判存在别写成
filter(...).count() > 0,那是全量遍历。 - 空列表上 allMatch 返回 true(数学上的"空真")。列表为空时"全部满足"成立,业务上未必成立——空单据被判成"全部已推送",是事故不是特性。
模式 8:排序(sorted)
for 时代这么写:Java 7 时代的排序,匿名内部类写比较器,五行起步:
Collections.sort(orders,newComparator<JtOrder>(){@Overridepublicintcompare(JtOrdera,JtOrderb){returnb.getCreationDate().compareTo(a.getCreationDate());// 倒序}});Stream 这么写:
// 按创建时间倒序List<JtOrder>recent=orders.stream().sorted(Comparator.comparing(JtOrder::getCreationDate).reversed()).collect(Collectors.toList());// 多字段排序:先按状态,再按日期Comparator.comparing(JtOrder::getStatus).thenComparing(JtOrder::getCreationDate)坑在哪:comparing的 key 为 null 会 NPE——排序器拿 null 跟别的值比,比较本身就不成立。可空字段包一层:Comparator.comparing(X::getDate, Comparator.nullsLast(Comparator.naturalOrder()))(null 排最后)。另外,不开流的简单排序直接list.sort(...)更直接,不必为了流而流。
模式 9:拼逗号串(joining)
for 时代这么写:把 id 列表拼成逗号串(日志、SQL、外部接口参数),手写循环 + 判断"最后一项不加逗号":
StringBuildersb=newStringBuilder("[");for(inti=0;i<orderLineIds.size();i++){sb.append(orderLineIds.get(i));if(i<orderLineIds.size()-1){// 最后一项不加逗号sb.append(",");}}sb.append("]");StringidStr=sb.toString();Stream 这么写:分隔符、前缀、后缀都交给 joining:
StringidStr=orderLineIds.stream().map(String::valueOf)// Long → String.collect(Collectors.joining(",","[","]"));// 分隔符, 前缀, 后缀// 结果:"[101,102,103]"坑在哪:
- joining 只吃字符串流。Long 列表要先
map(String::valueOf),别的类型同理。 - null 元素不报错——它会被拼成四个字母的 “null” 混进结果串。安静,但脏。
模式 10:扁平化(flatMap)
for 时代这么写:一层套一层的结构(订单→行)拍平成一层,双层循环:
List<ItemDTO>allItems=newArrayList<>();for(JtOrderorder:orders){allItems.addAll(order.getItems());// items 为 null 直接 NPE——for 版一样炸}Stream 这么写:
// 每个订单有多行,把所有订单的行拍平成一个大列表List<ItemDTO>allItems=orders.stream().flatMap(order->order.getItems().stream())// 每个订单贡献一个流.collect(Collectors.toList());记忆点:map 是 1 进 1 出(对象 → id),flatMap 是 1 进 N 出(订单 → 它的所有行)。
坑在哪:flatMap 的函数必须返回一个流——每个元素贡献一个流,流与流再接起来;返回空流等于这个元素不产出任何东西。order.getItems().stream()遇到 items 为 null 会直接 NPE——看上面的 for 版,这个坑 for 时代就有,不是 Stream 引入的。嵌套集合的 null 先判再进流。
模式 11:求和与统计
for 时代这么写:BigDecimal 金额合计,循环外初始化 ZERO,循环体里累加:
BigDecimaltotalAmount=BigDecimal.ZERO;for(Xline:lines){totalAmount=totalAmount.add(line.getAmount());// amount 为 null 照样炸}Stream 这么写:reduce(BigDecimal.ZERO, BigDecimal::add)就是这段循环的声明式写法——初值 ZERO 对应循环外的初始化,add对应循环体:
// BigDecimal 金额合计(金额必须用 reduce,不能用 mapToInt)BigDecimaltotalAmount=lines.stream().map(X::getAmount).reduce(BigDecimal.ZERO,BigDecimal::add);// int 求和 / 统计inttotalQty=lines.stream().mapToInt(X::getQty).sum();IntSummaryStatisticsstat=lines.stream().mapToInt(X::getQty).summaryStatistics();// stat.getMax() / getMin() / getAverage() / getCount()坑在哪:
- 数值流家族(
mapToInt/mapToLong/mapToDouble)只收 int / long / double。BigDecimal 必须走reduce + BigDecimal::add——mapToInt的参数类型就对不上,编译期就拦下。 - reduce 初值给
BigDecimal.ZERO:空列表时合计就是 0。不给初值,拿到的是 Optional——空列表对应 empty,直接取值会炸。
模式 12:收尾写法选择(toList / toSet / collect)
for 时代这么写:收尾就是你 new 集合 + for 里 add——自己 new 的天然可变,从来没为这个操过心:
List<CharacteristicDTO>result=newArrayList<>();for(CharacteristicDTOdto:characteristicDTOList){if(!idWithMulti.contains(dto.getId())){result.add(dto);}}toAddcharacteristicDTOList.addAll(result);// 能跑,因为 result 可变Stream 这么写:收尾交给收集器,但谁帮你 new 的集合,脾气听谁的:
// 不直接toList而是Collectors.toList(),防止集合不可变导致add失败toAddcharacteristicDTOList.addAll(characteristicDTOList.stream().filter(o->!idWithMulti.contains(o.getId())).collect(Collectors.toList()));// 收完还要 add,必须可变三种写法的差别:
| 写法 | 产物 | 可变性 | 版本 |
|---|---|---|---|
.collect(Collectors.toList()) | List | 可改 | Java 8+ |
.toList() | List | 不可变 | Java 16+ |
.collect(Collectors.toSet()) | Set | 可改 | Java 8+ |
坑在哪:.toList()(Java 16+)返回不可变 List——收完还要 add / addAll,就是UnsupportedOperationException。上面那条注释是项目getSapDTO里的血泪注释,就是事故现场:代码能编译、能跑过测试,直到某个 add 在生产上炸。规则三句话:收完还要改 →collect(Collectors.toList());收完只读 →.toList();要判重/查存在 →toSet()。
收尾:坑就三类,翻译就一套
把十二式的坑摊开,不是十几个知识点,只有三类:
| 族 | 触发条件 | 违约形态 | 出自 |
|---|---|---|---|
| 数据脏(null) | 元素或字段为 null | toMap value NPE、comparing key NPE、groupingBy 分组键 NPE、IN 混 null、joining 拼出 “null”、flatMap 撞 null 集合 | 模式 1、3、6、8、9、10 |
| 输入空 | 列表为空 | IN ()SQL 报错、reduce 拿到空 Optional、allMatch 空流返回 true | 模式 1、7、11 |
| 产物反直觉 | 收完之后想当然地用 | toList() 不可变、HashSet 不保序、重赋值不出方法、toMap key 冲突即抛 | 模式 2、3、4、5、12 |
第一,别背 API,做翻译。学 Stream 最快的路不是背方法名,是拿着你写熟的 for 循环对照速查表找意图、抄写法。十二次之后,手感自然有——毕竟每个工位你都用 for 手工搭过。
第二,报错先对号三类。null、空、收出来的不对——Stream 的报错再花哨,几乎都落在这三格里。for 时代你手写的判 null、判size() == 0、自己 new 容器自己管脾气,就是这三类的老版本;Stream 把它们搬进了 API 的条款里,防御没有消失,只是换了存放位置——不读条款的人,防御不会自动生效。
第三,同事留下的注释就是免疫。getSapDTO里那行"不直接 toList 而是 Collectors.toList(),防止集合不可变导致 add 失败"——一行字,把一次真实故障变成了后来所有人的免疫。坑踩了不写下来,就会有人再踩一遍。