ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Java Stream groupingBy 分组后组内排序与Map顺序控制

Java Stream groupingBy 分组后组内排序与Map顺序控制 1. 需求还原分组之后为什么还得再排一遍很多需求第一眼看过去像是分个组就完事了真写起来才发现分组只是前半场。典型场景是电商后台的订单看板把订单按店铺分组每个店铺下面再按创建时间倒序排或者商品列表按品类分组组内按价格从低到高排。分组的 key 用Collectors.groupingBy一行就能拿到可组内的顺序往往是乱的接口返回给前端之后用户第一眼看到的就是上一秒下的单排在了三天前的单后面。Stream的Collectors.groupingBy加上组内排序本质上解决的是两个维度的排序问题一个是外层 Map 的 key 顺序一个是每个 value 里 List 的元素顺序。这两件事经常被混为一谈导致不少人写出了看起来对、跑起来顺序随机的代码。尤其是当返回结果是HashMap的时候key 的顺序依赖哈希桶分布同一批数据在不同 JDK 版本、不同机器上跑出来的顺序都可能不一样本地调试没问题上了测试环境就被测试同学提单。这篇内容适合三类人看。第一类是刚接触 Java 8Stream不久、只会写最基础groupingBy的同学第二类是写过一阵子但总在顺序这件事上反复踩坑的中级开发者第三类是需要在代码评审里给别人讲清楚为什么你的排序没生效的团队负责人。我会把groupingBy三个参数的用法、组内排序的几种落地写法、并行流下的顺序陷阱、以及一组真实订单案例完整走一遍代码都能直接复制改数据跑起来。先说一个结论性的事实方便你带着问题往下读groupingBy本身不负责组内排序它只负责把元素塞进对应的桶里sorted()只有在它处于收集器链路的下游、或者在分组之前作用在整条流上才会影响到最终的组内顺序。理解了这一句后面几种写法的取舍就都能推导出来了。2. groupingBy 的三个参数决定了你能排什么2.1 classifier分组键的生成逻辑groupingBy的第一个参数是classifier本质是一个FunctionT, K把流里的元素映射成一个 key。这个 key 决定了数据被分到哪个桶。看起来简单但这里有两个容易被忽略的细节。第一个细节是 key 的equals和hashCode必须成对重写。如果你用自定义对象当 key只重写了equals没重写hashCode那就会出现明明值一样却被分成了两组的现象。这个坑我在一个统计项目里真实遇到过同事用了一个内部 VO 当分组键IDE 自动生成代码时只勾了equals结果同一批数据分出了几百个重复分组排查了整整一个下午。第二个细节是 key 允许为null但有代价。groupingBy底层用的是HashMapHashMap是支持nullkey 的所以 key 为null的元素会被单独分到一组key 就是null。如果你后续要用这个 key 做字符串拼接、或者丢给前端当字段名null就会变成 NPE 的源头。稳妥的做法是在分组前先做一次map把null兜底成未知分类这样的默认值。MapString, ListProduct byCategory products.stream() .collect(Collectors.groupingBy( p - p.getCategory() null ? 未分类 : p.getCategory() ));第三个细节是 key 的类型会影响你能不能对 key 排序。想按 key 排序最直接的办法是把mapFactory换成TreeMap而TreeMap要求 key 要么实现Comparable要么你额外传一个Comparator。如果你分组键是Integer、String、LocalDate这类天然可比较的类型换成TreeMap是一行的事如果是个复杂对象就得额外写比较器成本立刻上来了。2.2 downstream组内排序真正的落点第二个参数downstream是Collector它决定了每个桶里的元素被收集成什么形态、以什么顺序收集。这是整篇文章的核心因为组内排序几乎所有的正统写法都发生在这一层。先看默认行为。不传downstream的时候groupingBy默认用的是Collectors.toList()也就是一个ArrayList。元素是按照它们在流中出现的顺序依次add进去的。这句话非常关键它直接推出了两个结论如果你在分组之前先对整条流做一次sorted()那么每个桶里的ArrayList天然就是有序的且顺序和你排序的顺序一致如果你没排序就直接分组那桶里的顺序就是原始数据的顺序看起来乱只是因为原始数据本来就没排。很多人说分组后顺序乱其实九成是第二种情况——数据源本身是无序的比如从HashMap的keySet或者某次并发查询的结果里拿出来的集合。这时候你加的sorted()如果位置写错了写在collect之后对 Map 操作或者写在groupingBy外面而不是下游里就完全不起作用。downstream常见的几种形态各自的能力边界差别很大我整理成了一张表downstream 写法桶内类型是否有序典型用途Collectors.toList()ArrayList保持流遇到顺序最常用配合前置 sortedCollectors.toCollection(LinkedHashSet::new)LinkedHashSet保持插入顺序且去重组内去重又想保序Collectors.toCollection(TreeSet::new)TreeSet自然序或比较器序组内去重且要排序Collectors.collectingAndThen(toList(), ...)自定义完全可控组内排序、Top N、聚合Collectors.counting()Long不涉及只统计数量Collectors.mapping(...)视内层而定视内层而定只取对象里某个字段这张表里collectingAndThen是灵活度最高的一档。它的作用是先用前一个收集器收集再对收集结果做一次函数变换语法上就是收完再加工。组内排序用它的思路非常直白先用toList()把桶里的元素收成一个List再把List转成流排一次序最后再收成一个List。2.3 mapFactory控制外层 key 顺序的开关三参数的groupingBy才能指定mapFactory这也是很多人不知道的一点。单参数和双参数的版本内部都写死了HashMap::new所以你拿到的永远是一个HashMapkey 顺序不可控。// 单参数版本key 顺序随机 MapString, ListOrder m1 orders.stream() .collect(Collectors.groupingBy(Order::getShopName)); // 三参数版本用 LinkedHashMapkey 保持首次出现顺序 MapString, ListOrder m2 orders.stream() .collect(Collectors.groupingBy( Order::getShopName, LinkedHashMap::new, Collectors.toList() )); // 三参数版本用 TreeMapkey 按自然序排列 MapString, ListOrder m3 orders.stream() .collect(Collectors.groupingBy( Order::getShopName, TreeMap::new, Collectors.toList() ));这里有个很实用的判断标准要不要让 key 有序取决于前端怎么消费这份数据。如果前端是按接口返回顺序直接渲染分组标题比如店铺 A 下面挂 10 单店铺 B 下面挂 8 单这种列表式布局那 key 顺序就必须稳定优先选LinkedHashMap保持首次出现顺序通常等价于按数据里第一次出现的店铺排或者TreeMap按名称笔画、字母序排。如果前端是把 Map 当成字典用拿到 key 再去查 value那HashMap完全够用没必要为了看着整齐引入排序开销。注意LinkedHashMap保证的是首次插入顺序。因为groupingBy是从流中第一个元素开始依次处理的所以这个顺序实际就是某个 key 在数据中第一次出现的位置。想按 key 大小排还是得用TreeMap。3. 组内排序的三条路线从短到稳3.1 路线一分组前先整体排序这是代码最短、也最容易被新手接受的写法。逻辑是既然桶里的ArrayList按流遇到顺序add那我先让整条流有序分组自然就有序了。MapString, ListOrder result orders.stream() .sorted(Comparator.comparing(Order::getCreateTime).reversed()) .collect(Collectors.groupingBy( Order::getShopName, LinkedHashMap::new, Collectors.toList() ));好处有三个。一是代码极短排序规则只有一处维护成本低。二是只排序一次不是每个桶排一次数据量大时性能更好——排序一次 O(n log n)按桶分别排序的总复杂度虽然也是 O(n log n) 量级但每个桶都要重建流、重建列表常数因子明显更大。三是一旦以后需求从分组展示变成整体导出报表那个前置的sorted可以原封不动复用。但它有一个必须记住的前提downstream必须是顺序友好的收集器。ArrayList、LinkedHashSet、ArrayDeque这类按插入顺序保存的容器没问题一旦换成TreeSet前置排序就白做了因为TreeSet会按自己的比较器重新排序换成HashSet更糟插入顺序直接被丢弃。另外如果中间夹了一个会打乱顺序的操作比如parallel()配合某些无序收集器前置排序同样会失效。我个人的建议是需求是只排组内的时候用这条路线但它不适合并行流。这一点在第五节会展开讲。3.2 路线二collectingAndThen 在下游里排如果组内排序规则和外层分组规则相互独立或者你需要给不同的组套用不同的排序逻辑比如普通店铺按时间倒序、旗舰店按销量倒序那下游排序更合适。MapString, ListOrder result orders.stream() .collect(Collectors.groupingBy( Order::getShopName, LinkedHashMap::new, Collectors.collectingAndThen( Collectors.toList(), list - list.stream() .sorted(Comparator.comparing(Order::getCreateTime).reversed()) .collect(Collectors.toList()) ) ));拆开看这段代码collectingAndThen(toList(), finisher)的意思是先用toList()把桶里的元素收成一个ArrayList再把这个ArrayList交给后面的 lambdalambda 返回什么桶里最终就是什么。lambda 里做的事就是把列表转成流、排序、再收成列表是一个非常直白的转手加工。这种写法的优点是不依赖流的整体顺序每一组进来都会重新排一次语义上更自包含读者不需要往上看 20 行去找那个sorted。缺点是代码偏长而且如果排序规则是共用的容易写成两个地方各排一次出现规则漂移。提示如果你在collectingAndThen里返回的是Collections.unmodifiableList(...)那返回的每个桶都是不可变列表。这是个防御性编程的好习惯但如果下游调用方有修改需求会直接抛UnsupportedOperationException别问我怎么知道的。还有一种更激进的写法用Collectors.toCollection直接产出一个TreeSet靠集合自身的排序能力省掉一次排序调用。这在组内要按某个字段排、且允许按该字段去重的场景下非常省事。MapString, TreeSetOrder result orders.stream() .collect(Collectors.groupingBy( Order::getShopName, Collectors.toCollection( () - new TreeSet(Comparator.comparing(Order::getAmount)) ) ));代价是类型变成了TreeSet前端序列化成数组时通常没问题但如果你想按索引取第几个元素、或者想做分页就得先转List多一步。另外TreeSet用比较器判重比较器返回 0 的两个元素会被视为同一个元素丢掉订单这种金额可能相同的数据用TreeSet排序是非常危险的——金额相同的两单会被合并成一单。这是我强烈建议避开的一个坑。3.3 路线三下游先映射再排序适合只要字段的场景实际业务里经常不需要完整对象只要每个分组下的某个字段列表比如每个店铺的订单号列表按时间倒序。这时候mapping能省掉一次对象搬运。MapString, ListString orderNosByShop orders.stream() .sorted(Comparator.comparing(Order::getCreateTime).reversed()) .collect(Collectors.groupingBy( Order::getShopName, LinkedHashMap::new, Collectors.mapping(Order::getOrderNo, Collectors.toList()) ));注意这里的顺序sorted在最前面mapping在下游。mapping只是把Order转成String它是按流遇到顺序做的转换前面排好的顺序会原样传递到ListString里。这就说明了一个重要事实排序的位置在流的最前面加工的位置在下游两者并不冲突。很多人误以为mapping会打乱顺序其实不会。反过来说如果你把mapping写在前面、sorted写在后面就变成先把对象转成字符串再按字符串排序排序规则完全变了这是另一类常见 bug。排序用的比较器必须和当前流里元素的类型匹配。4. 完整实操订单分组 组内多级排序4.1 数据模型与造数据先把案例的骨架搭起来数据模型用最常见的订单三件套。public class Order { private String orderNo; private String shopName; private BigDecimal amount; private LocalDateTime createTime; // 构造、getter、setter 省略 public Order(String orderNo, String shopName, BigDecimal amount, LocalDateTime createTime) { this.orderNo orderNo; this.shopName shopName; this.amount amount; this.createTime createTime; } // getter 方法 public String getOrderNo() { return orderNo; } public String getShopName() { return shopName; } public BigDecimal getAmount() { return amount; } public LocalDateTime getCreateTime() { return createTime; } Override public String toString() { return orderNo / shopName / amount / createTime; } }造一批带重复店铺、时间乱序的数据这样跑出来的效果最直观。ListOrder orders Arrays.asList( new Order(A003, 旗舰店, new BigDecimal(199.00), LocalDateTime.of(2024, 5, 3, 10, 12)), new Order(B001, 直营店, new BigDecimal(88.50), LocalDateTime.of(2024, 5, 1, 9, 30)), new Order(A001, 旗舰店, new BigDecimal(199.00), LocalDateTime.of(2024, 5, 1, 8, 5)), new Order(B003, 直营店, new BigDecimal(320.00), LocalDateTime.of(2024, 5, 3, 15, 40)), new Order(A002, 旗舰店, new BigDecimal(59.90), LocalDateTime.of(2024, 5, 2, 20, 18)), new Order(B002, 直营店, new BigDecimal(88.50), LocalDateTime.of(2024, 5, 2, 11, 0)), new Order(C001, 特卖店, new BigDecimal(12.00), LocalDateTime.of(2024, 5, 1, 7, 0)) );4.2 需求一每个店铺下的订单按创建时间倒序MapString, ListOrder byShop orders.stream() .sorted(Comparator.comparing(Order::getCreateTime).reversed()) .collect(Collectors.groupingBy( Order::getShopName, LinkedHashMap::new, Collectors.toList() )); byShop.forEach((shop, list) - { System.out.println( shop 共 list.size() 单 ); list.forEach(o - System.out.println( o.getOrderNo() o.getCreateTime())); });跑出来的结果是旗舰店下依次是 A0035-3 10:12、A0025-2 20:18、A0015-1 08:05时间从新到旧直营店下是 B003、B002、B001同理。外层 Map 是LinkedHashMapkey 的顺序是旗舰店、直营店、特卖店正好是数据里首次出现的顺序。这份结果可以稳定复现不依赖 JDK 版本。4.3 需求二时间倒序之外金额还要从高到低这就是典型的多级排序。Comparator提供了thenComparing可以把多个比较规则串联起来前一个比较器判定相等时才用下一个。ComparatorOrder cmp Comparator .comparing(Order::getCreateTime).reversed() .thenComparing(Order::getAmount, Comparator.reverseOrder()); MapString, ListOrder result orders.stream() .sorted(cmp) .collect(Collectors.groupingBy(Order::getShopName, LinkedHashMap::new, Collectors.toList()));这里有一个非常经典的陷阱值得单独拎出来讲。Comparator.comparing(Order::getCreateTime).reversed()里的reversed()作用范围是整个已经构造出来的比较器也就是它会把按时间升序整体翻转成按时间降序。而.thenComparing(...)是在这个翻转之后追加的规则所以最终语义是时间降序时间相同则金额降序这符合预期。但如果写成Comparator.comparing(Order::getCreateTime).thenComparing(Order::getAmount).reversed()那个reversed()会把整条链都反过来变成时间降序时间相同则金额升序金额那一级的方向被静默地翻转了。这种错误不会报错、不会警告只会让业务同学跑过来问你为什么金额排序反了。稳妥的写法是把reversed()绑定到每一级上而不是在链条末尾统一翻转ComparatorOrder safe Comparator .comparing(Order::getCreateTime, Comparator.reverseOrder()) .thenComparing(Order::getAmount, Comparator.reverseOrder());Comparator.reverseOrder()作为比较器参数传给comparing的第二参数方向就锁死在这一级后面加多少级都不受影响。这个写法比.reversed()链更啰嗦一点点但在多级排序里它省下的排查时间远远值回票价。4.4 需求三每个店铺只取最近 3 单collectingAndThen的灵活度在这里体现得最明显——排序之后顺手做一个limit就变成了每组 Top N。MapString, ListOrder top3 orders.stream() .collect(Collectors.groupingBy( Order::getShopName, LinkedHashMap::new, Collectors.collectingAndThen( Collectors.toList(), list - list.stream() .sorted(Comparator.comparing(Order::getCreateTime, Comparator.reverseOrder())) .limit(3) .collect(Collectors.toList()) ) ));这个写法有个隐性优势它不依赖上游的整体顺序所以即使上游的数据来源是HashSet、是并发查询的合并结果每个桶依然会被正确排序。这就是自包含式排序的价值。不过要注意性能。数据量大的时候先全量收集、再每组排序、再截断意味着每个桶都会完整排一次序。如果每组有几万条而只需要 Top 3用优先队列更划算维护一个容量为 3 的小顶堆遍历一遍 O(n log 3) 就能拿到结果不需要完整排序。日常业务里每组几百条sorted limit的可读性优势更大不必过早优化。提示limit必须放在sorted之后不能放在分组之前。放在分组之前就变成从全量数据里取前 3 条再分组只会得到一两个桶各 1 到 2 条数据和需求南辕北辙。4.5 需求四按金额占比区间分组组内按金额排序这是一个把分组规则本身也要计算的场景。比如统计订单金额占总额的百分比按 0-20%、20-40%、40-60% 这样的区间分成几档然后每档内部按金额从高到低排。分组键需要先算出总额才能确定所以逻辑上分成两步走。BigDecimal total orders.stream() .map(Order::getAmount) .reduce(BigDecimal.ZERO, BigDecimal::add); MapString, ListOrder byRatioBand orders.stream() .collect(Collectors.groupingBy( o - { BigDecimal ratio o.getAmount() .multiply(new BigDecimal(100)) .divide(total, 2, RoundingMode.HALF_UP); int band ratio.intValue() / 20; // 0,1,2,3,4 int lower band * 20; return lower %~ (lower 20) %; }, TreeMap::new, Collectors.collectingAndThen( Collectors.toList(), list - list.stream() .sorted(Comparator.comparing(Order::getAmount, Comparator.reverseOrder())) .collect(Collectors.toList()) ) ));这段代码里有三个值得说的点。第一BigDecimal除法必须指定精度和舍入模式否则遇到除不尽的情况比如总额是 3单笔 1会直接抛ArithmeticException。这是BigDecimal使用者最容易踩的坑之一和Stream本身无关但只要有百分比计算就躲不开。第二分组键是拼接出来的字符串比如0%~20%用TreeMap当外层容器的话key 会按字符串字典序排。0%~20%、100%~120%和20%~40%混在一起时字典序会把100%~120%排在20%~40%前面因为字符1小于2。要避免这个问题就得把 key 设计成零填充的格式000%~020%或者干脆改用整数当 key展示层再拼字符串。这是分组键不只是分组键它还是排序依据的一个典型案例。第三这里的 grouping 用的是三参数版本TreeMap::new让 key 有序。如果 key 是自定义的区间对象TreeMap会要求它实现Comparable否则抛ClassCastException。这个报错信息通常比较隐晦堆栈里看不到你自己写的类名只有底层的TreeMap.put第一次遇到容易懵。5. 常见问题与排查实录5.1 分组结果每次跑出来顺序都不一样现象本地跑两遍外层 Map 打印出来的 key 顺序变了。原因基本可以确定——用的是单参数或双参数的groupingBy底层是HashMap。HashMap的遍历顺序由桶下标决定桶下标 hash(key) (capacity - 1)还涉及树化、扩容等内部状态。虽然同一份数据在同一台机器上通常稳定但键的哈希值一旦因为字段变化而变化顺序就跟着变。排查手段很简单在collect之后立刻打印result.getClass()。如果是java.util.HashMap那顺序不保证就是设计使然别去找排序代码的问题。修法就是显式传LinkedHashMap::new或TreeMap::new。需要注意的是Collectors.groupingBy返回的HashMap和用toMap收集的HashMap是一样的性质。区别在于toMap遇到重复 key 会抛IllegalStateException而groupingBy遇到重复 key 是正常的合并行为。经常有人想用toMap做分组写完发现重复 key 报错就是没搞清楚这两者的定位差异。5.2 加了 parallel 之后顺序全丢了并行流是顺序问题的重灾区。当你写orders.parallelStream().sorted(...).collect(groupingBy(...))时sorted()在并行流上依然是稳定排序ArrayList收集器也依然是顺序敏感的理论上最终顺序应该保持。但实际项目里出问题通常是下面几种情况之一。第一种是自己写了collect(supplier, accumulator, combiner)三参数版本combiner里用了list.addAll(other)但两个中间结果的拼接顺序不确定导致最终列表顺序错乱。第二种是把downstream换成了Collectors.toSet()或者HashSet::new集合本身不保序前面排得再认真也没用。第三种是上游数据源本身是无序集合比如HashMap.keySet()、HashSet并行切分时各线程拿到的片段顺序不同虽然sorted会纠正但如果你没排序只是期望它按某顺序来那就没有依据。我的经验是需要严格保序的场景直接在数据源上关掉并行别在收集器层面赌运气。流式操作里加parallel()带来的收益在中小数据量下基本被线程调度开销吃掉了为了省几百毫秒去冒顺序错乱的风险并不划算。5.3 把 sorted 写在 collect 之后这是新手最常犯的错误写出来大概是这样// 错误示范 MapString, ListOrder wrong orders.stream() .collect(Collectors.groupingBy(Order::getShopName)); wrong.forEach((k, v) - v.sort(Comparator.comparing(Order::getCreateTime)));这段代码逻辑上能跑通但有两个问题。一是Map.forEach里对 value 做原地排序如果这个 Map 之后还要被别的地方读取就产生了一次隐藏的副作用调用方完全不知道数据被改过。二是如果downstream返回的是Collections.unmodifiableList或者Arrays.asList产生的定长列表sort会直接抛异常。正确思路是让排序成为收集流程的一部分而不是收集之后的补救动作。要么把sorted提到collect之前要么把它放进collectingAndThen里。这两种方式产出的都是收集完成即有序的结果语义清晰。还有一种变体是把sorted写在groupingBy的classifier里比如groupingBy(o - { o.setXxx(...); return ...; })在分组键的计算过程中偷偷改数据。这种写法极其危险因为classifier会被多次调用尤其是并行流下副作用会重复执行还可能破坏hashCode导致分组错乱。分组键的计算函数必须是纯函数这一点没有例外。5.4 null 值参与排序引发 NPE如果排序字段可能为null直接用Comparator.comparing(Order::getCreateTime)会在遇到null元素时抛NullPointerException。两种应对方式。方式一是用Comparator.nullsLast或nullsFirst显式声明null的位置ComparatorOrder cmp Comparator.comparing( Order::getCreateTime, Comparator.nullsLast(Comparator.reverseOrder()) );注意这里的嵌套层级nullsLast包住reverseOrder表示有值的按倒序排null全部沉底。如果写成Comparator.nullsLast(Comparator.naturalOrder()).reversed()方向又会被搞反null跑到最前面去了。方式二是在分组前先过滤掉关键字段为null的数据或者用Optional兜底。哪种更好取决于业务null时间戳本身可能就是个数据质量问题直接过滤掉并打日志比默默排到末尾更有价值。5.5 常见问题速查表现象大概率原因处理方式外层 key 顺序随机默认HashMap三参数版传LinkedHashMap::new或TreeMap::new组内顺序乱排序写在collect之后sorted提到分组前或用collectingAndThen组内排序没效果downstream是HashSet等不保序容器换成toList/LinkedHashSet组内元素莫名变少用了TreeSet且比较器有相等判定换toList后再去重别用TreeSet排有重复字段的对象多级排序第二级方向反了链尾统一reversed()每一级单独用reverseOrder()并行流下顺序错乱自定义 combiner 或上游无序源去掉parallel()或保证收集器顺序友好key 是null导致后续 NPE分组键存在空值分组前map兜底默认值BigDecimal除法报错未指定精度与舍入模式divide(total, 2, RoundingMode.HALF_UP)key 排序不对字符串 key 字典序与数值序不一致key 用整数或零填充对齐位数这张表是我自己这几年攒下来的每次在代码评审里看到可疑的流式写法都会拿它对照一遍。大部分问题都落在容器选错和排序位置放错这两类上真正属于StreamAPI 本身坑的地方反而不多。6. 选型建议与性能取舍6.1 按数据规模选实现方式如果单次分组的数据量在几千条以内三种写法在耗时上几乎看不出差别这时候应该优先选可读性最好的。我的偏好是排序规则只有一条、且所有组共用用前置 sorted LinkedHashMap需要每组 Top N、或者排序规则依组而变用collectingAndThen。理由是这两种写法在代码评审时一眼就能看出意图不需要读者在脑海里模拟收集器链的执行过程。数据量上万之后前置排序的性能优势会显现出来并且它是唯一一种整条流只排一次的方案。collectingAndThen的写法按组排序每组都要新建流、新建列表ArrayList的扩容和对象分配次数明显更多。如果每组只有十几条而组数上千这个差距会被放大。到了十万级以上、且只需要每组前几条的场景前面提到的优先队列方案小顶堆就值得考虑了。它的实现比Stream方案长一些但复杂度从每组全排序降到每组一次线性扫描收益是实打实的。我的建议是先写可读版本、压测确认瓶颈、再针对性优化不要一上来就为了想象中的性能问题把代码写成没人看得懂的样子。6.2 可读性上的几个小习惯第一把比较器提取成常量。多级排序的比较器通常很长塞在流里会把groupingBy的结构冲散。提取成private static final ComparatorOrder BY_TIME_DESC之后流里只剩一个短名字结构立刻清晰。第二分组键的计算逻辑单独抽方法。像前面按比例分档那种场景classifier里塞七八行计算读起来非常吃力。抽成private String bandOf(Order o, BigDecimal total)之后groupingBy那一行就变成了自解释的。第三显式声明返回类型。MapString, ListOrder和MapString, CollectionOrder在调用方眼里差别很大用var接收会让别人猜不透里面到底是什么容器。方法签名里把类型写清楚是给自己和同事省时间。第四尽量避免在流里做副作用操作。分组 排序这件事本身是纯函数式的完全可以做到无副作用。一旦引入了对元素的原地修改后面就会开始出现分组结果依赖遍历顺序这种没法用单元测试稳稳覆盖的问题。流式代码最大的价值就是可确定性别把它丢掉。最后分享一个小技巧用于验证你的分组结果是否真的稳定把同一份数据连续跑十次每次把结果的keySet顺序和每个桶内元素的hashCode序列拼成一个字符串打印出来比对十次是否完全一致。这个动作两分钟就能写完比在测试环境里等业务同学提 bug 快得多。我在一个列表接口上就是这么发现某个字段的hashCode参与了 key 计算、而它的值来自随机数这个问题的。
返回列表