
网上教 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 批量查防 N1模式 1mapfilterdistinctCollectors.toList()按条件留下/丢掉元素模式 2filter建查找表循环里按 key 查模式 3Collectors.toMap只回答在不在模式 4Collectors.toSetcontains加工完覆盖原变量模式 5filter/map 重新赋值按 key 聚合、报表归类模式 6Collectors.groupingBy只要是/否结论模式 7anyMatch/allMatch/noneMatch排序模式 8sortedComparator拼逗号串模式 9Collectors.joining嵌套结构订单→行拍平模式 10flatMap合计、均值、最大最小模式 11reduce/mapToIntsummaryStatistics选收尾写法模式 12collect(toList())/toList()/toSet()模式 1提取字段 → 批量查询防 N1for 时代这么写查完主表要拿子表数据逐行查库——1 次主查询 N 行各查 1 次 N1 次数据库往返。100 行订单就是 101 次查库。去重还得自己拿contains防它要从头扫到尾又慢一层ListLongorderLineIdsnewArrayList();for(JtOrderLineline:orderLines){Longidline.getId();if(id!null!orderLineIds.contains(id)){// contains 每次从头扫orderLineIds.add(id);}}Stream 这么写收齐 id一次IN查询把 1N 次数据库往返压成 2 次。链条顺着读对象变 idmap→ 扔掉 nullfilter→ 去重distinct→ 装箱collect// 第一步从订单行列表提取所有行 idListLongorderLineIdsorderLines.stream().map(JtOrderLine::getId)// 对象 → id.filter(Objects::nonNull)// 剔除 null防止 IN 列表脏数据.distinct()// 去重基于哈希不用从头扫.collect(Collectors.toList());// 收成 List// 第二步一次 IN 查询拿全部关联数据ListJtOrderLineMaterialCharacteristiccharacteristicsmaterialCharacteristicRepository.selectByCondition(Condition.builder(JtOrderLineMaterialCharacteristic.class).andWhere(Sqls.custom().andIn(orderLineId,orderLineIds)).build());坑在哪id 列表可能是空的。空列表拼IN会生成IN ()直接 SQL 报错Oracle 是 ORA-00936——IN 语法不为空列表负责判空是你的事。filter(Objects::nonNull)不是可有可无id 字段混进 nullIN 列表就是脏的。项目代码里这行注释写的就是防止 IN 列表脏数据。模式 2条件过滤for 时代这么写for if add三件套ListJtOrderLineMaterialCharacteristickeptnewArrayList();for(JtOrderLineMaterialCharacteristico:characteristics){if(o.getCharacteristicValueNumber()!null||o.getCharacteristicValueSingle()!null||!StringUtils.isEmpty(o.getCharacteristicValueText())||multiValueIds.contains(o.getId())){kept.add(o);}}characteristicskept;Stream 这么写// 保留五种值形态任一有值的特性行丢弃空行characteristicscharacteristics.stream().filter(o-o.getCharacteristicValueNumber()!null||o.getCharacteristicValueSingle()!null||!StringUtils.isEmpty(o.getCharacteristicValueText())||multiValueIds.contains(o.getId())).collect(Collectors.toList());// 就地重赋值坑在哪filter 的 lambda 必须返回 booleantrue 留、false 丢没有第三个选项。产物是新列表。characteristics ...是重新赋值不是原地修改——原列表对象一个元素都没少。模式 3建查找表toMapfor 时代这么写循环里要反复按 key 查值先把 List 转成 Map一次遍历建好索引MapLong,StringfeatureMapnewHashMap();for(JtFeaturef:featureList){featureMap.put(f.getId(),f.getFeatureCode());// 注意 put 的脾气下面说}Stream 这么写// 特性 id → 特性编码MapLong,StringfeatureMapfeatureList.stream().collect(Collectors.toMap(JtFeature::getId,// key特性idJtFeature::getFeatureCode// value特性编码));// 后续循环里直接查不用再遍历StringcodefeatureMap.get(characteristicDTO.getRelateFeature());坑在哪for 版和 Stream 版的脾气不一样这是最容易栽的地方for put是老好人key 重复就静默覆盖你根本不知道数据少了value 是 null 也照收。不报错但坑是安静的。toMap 是较真的人key 重复直接抛IllegalStateExceptionvalue 为 null 直接 NPE空指针异常——对 null 调方法Java 最常见的报错。机制在底层toMap 用Map.merge装值merge 的规矩就是拒收 null value。所以大项目规范要求必须写第三个参数把重复了怎么办说在明面上Collectors.toMap(X::getId,X::getName,(first,second)-first)// 重复时保留第一个value 可能空的字段要进 Map老老实实换回 for 循环填。模式 4判存在toSetfor 时代这么写只需要回答这个 id 在不在名单里很多人顺手收个 List然后每次contains从头扫到尾——List 的 contains 是 O(n)n 个元素查 n 次就是双重循环。Set 的 contains 是 O(1)HashSet 按内容直接算出该放哪个抽屉一步到位像查字典直接翻到那一页而不是从第一页翻到最后一页SetLongmultiValueIdsnewHashSet();for(CharacteristicDTOdto:characteristicMulRows){multiValueIds.add(dto.getId());// add 顺手去重}Stream 这么写// 有字典行的特性行 id 多选特性行SetLongmultiValueIdscharacteristicMulRows.stream().map(CharacteristicDTO::getId).collect(Collectors.toSet());// 后续 filter 里逐行判断Set 查找快.filter(o-...||multiValueIds.contains(o.getId()))坑在哪HashSet 不保序。需要去重 保序时用LinkedHashSetcollect(Collectors.toCollection(LinkedHashSet::new))。模式 5加工完覆盖原变量for 时代这么写其实就是模式 2 的收尾动作——建个新列表装过滤结果再用它换掉旧变量省一个新变量名ListCharacteristicDTOkeptnewArrayList();for(CharacteristicDTOdto:characteristicDTOList){if(matchedFeatureIds.contains(dto.getRelateFeature())){kept.add(dto);}}characteristicDTOListkept;// 新列表换旧引用Stream 这么写// 原变量被过滤后的版本替换characteristicDTOListcharacteristicDTOList.stream().filter(dto-matchedFeatureIds.contains(dto.getRelateFeature())).collect(Collectors.toList());坑在哪本质是新列表覆盖旧引用只在方法内部的局部变量上成立——给方法入参重新赋值调用方手里的引用纹丝不动。方法里换的是你手里那份别人手里那份还是旧的。模式 6分组groupingByfor 时代这么写按 key 把列表拆成若干组是报表、归类、按父单聚合子行的常客。手写版是著名的六行模板MapLong,ListJtOrderLinelinesByOrderIdnewHashMap();for(JtOrderLineline:allLines){LongorderIdline.getOrderId();ListJtOrderLinebucketlinesByOrderId.get(orderId);if(bucketnull){// 这组还没有桶bucketnewArrayList();linesByOrderId.put(orderId,bucket);// 给它发一个}bucket.add(line);}Stream 这么写六行模板换一行// 按订单 id 把所有订单行分组MapLong,ListJtOrderLinelinesByOrderIdallLines.stream().collect(Collectors.groupingBy(JtOrderLine::getOrderId));// 结果{ 1001[行1,行2], 1002[行3] }进阶分组后再对组内做转换用groupingBymapping——组里只留行号不要整个对象// 按订单分组组内只要行号不要整个对象MapLong,ListStringlineNosByOrderallLines.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 / noneMatchfor 时代这么写只要一个是/否结论for boolean 标志 break 三件套booleanhasEmptyPricefalse;for(JtOrderLineline:orderLines){if(line.getUnitPrice()null){hasEmptyPricetrue;break;// 找到一个就够了}}Stream 这么写// 旧写法for 循环 boolean 标志 break// 新写法一行booleanhasEmptyPriceorderLines.stream().anyMatch(line-line.getUnitPrice()null);// 任一行没价格 → true// 全部满足用 allMatch全不满足用 noneMatchbooleanallPushedorders.stream().allMatch(o-o.getSalesOrderNumber()!null);坑在哪anyMatch 找到第一个满足的元素就短路返回不遍历全量——for 版的 break 被写进了它的定义里这是保证不是巧合。反过来判存在别写成filter(...).count() 0那是全量遍历。空列表上 allMatch 返回 true数学上的空真。列表为空时全部满足成立业务上未必成立——空单据被判成全部已推送是事故不是特性。模式 8排序sortedfor 时代这么写Java 7 时代的排序匿名内部类写比较器五行起步Collections.sort(orders,newComparatorJtOrder(){Overridepublicintcompare(JtOrdera,JtOrderb){returnb.getCreationDate().compareTo(a.getCreationDate());// 倒序}});Stream 这么写// 按创建时间倒序ListJtOrderrecentorders.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拼逗号串joiningfor 时代这么写把 id 列表拼成逗号串日志、SQL、外部接口参数手写循环 判断最后一项不加逗号StringBuildersbnewStringBuilder([);for(inti0;iorderLineIds.size();i){sb.append(orderLineIds.get(i));if(iorderLineIds.size()-1){// 最后一项不加逗号sb.append(,);}}sb.append(]);StringidStrsb.toString();Stream 这么写分隔符、前缀、后缀都交给 joiningStringidStrorderLineIds.stream().map(String::valueOf)// Long → String.collect(Collectors.joining(,,[,]));// 分隔符, 前缀, 后缀// 结果[101,102,103]坑在哪joining 只吃字符串流。Long 列表要先map(String::valueOf)别的类型同理。null 元素不报错——它会被拼成四个字母的 “null” 混进结果串。安静但脏。模式 10扁平化flatMapfor 时代这么写一层套一层的结构订单→行拍平成一层双层循环ListItemDTOallItemsnewArrayList();for(JtOrderorder:orders){allItems.addAll(order.getItems());// items 为 null 直接 NPE——for 版一样炸}Stream 这么写// 每个订单有多行把所有订单的行拍平成一个大列表ListItemDTOallItemsorders.stream().flatMap(order-order.getItems().stream())// 每个订单贡献一个流.collect(Collectors.toList());记忆点map 是 1 进 1 出对象 → idflatMap 是 1 进 N 出订单 → 它的所有行。坑在哪flatMap 的函数必须返回一个流——每个元素贡献一个流流与流再接起来返回空流等于这个元素不产出任何东西。order.getItems().stream()遇到 items 为 null 会直接 NPE——看上面的 for 版这个坑 for 时代就有不是 Stream 引入的。嵌套集合的 null 先判再进流。模式 11求和与统计for 时代这么写BigDecimal 金额合计循环外初始化 ZERO循环体里累加BigDecimaltotalAmountBigDecimal.ZERO;for(Xline:lines){totalAmounttotalAmount.add(line.getAmount());// amount 为 null 照样炸}Stream 这么写reduce(BigDecimal.ZERO, BigDecimal::add)就是这段循环的声明式写法——初值 ZERO 对应循环外的初始化add对应循环体// BigDecimal 金额合计金额必须用 reduce不能用 mapToIntBigDecimaltotalAmountlines.stream().map(X::getAmount).reduce(BigDecimal.ZERO,BigDecimal::add);// int 求和 / 统计inttotalQtylines.stream().mapToInt(X::getQty).sum();IntSummaryStatisticsstatlines.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 / collectfor 时代这么写收尾就是你 new 集合 for 里 add——自己 new 的天然可变从来没为这个操过心ListCharacteristicDTOresultnewArrayList();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元素或字段为 nulltoMap 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 失败——一行字把一次真实故障变成了后来所有人的免疫。坑踩了不写下来就会有人再踩一遍。