多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

Java Stream分组后排序:groupingBy顺序控制与并行流避坑

Java Stream分组后排序:groupingBy顺序控制与并行流避坑 上周组里的小李把一个分类汇总接口交过来让我 review逻辑很简单把订单按品类分组每组内部按金额从高到低排。单测跑通了条数也对得上他就提了 PR。联调的时候前端在群里发了张截图——同一个品类下金额顺序是乱的而且刷新一次换一个样。他第一反应是排序没生效第二反应是Stream 的 sorted 有坑。实际问题既不在分组也不在排序而在两件事的组合方式上他用的是并行流加groupingByConcurrent下游却挂了最普通的Collectors.toList()。Stream、Collectors.groupingBy、分组、排序这四个词凑在一起看着像是三行代码的事但真正写起来坑比想象的多。因为分组后排序这句话本身是含糊的——你要排的到底是组内元素的顺序、组与组之间的顺序还是分组键本身的顺序这三件事用的是三套完全不同的机制混在一起写就会出现看起来对、跑起来随机的结果。这篇文章我把这几年在报表、对账、埋点聚合这些场景里踩过的路都摊开讲四种组内排序写法的取舍、mapFactory控制键顺序的实战用法、组内 TopN 的正确位置、并行流和返回值可变性带来的坑最后给一份量级不同时的选型建议。先说清楚适用人群Java 8 以上、写过stream().collect()但没深究过收集器语义的同学读完能直接改代码已经熟练的同学可以直接跳到第 5 节和第 7 节那里是我认为最容易出线上问题的地方。1. 分组之后顺序为什么会乱从一次订单报表说起1.1 默认返回值里藏着的两个容器Collectors.groupingBy(Order::category)这个单参数版本返回的是MapString, ListOrder。很多人以为这里只有一个容器其实有两个外层是一个HashMap内层每个分组是一个ArrayList。这两个容器的顺序语义完全不同问题往往出在把它们当成一回事。内层的ArrayList是有序的元素按它们进入收集器的先后顺序追加也就是所谓遭遇顺序encounter order。只要你的流是有序流、下游是有序收集器组内顺序就是稳定的不需要额外做任何事。外层HashMap就不一样了它的迭代顺序由 key 的哈希值和桶容量共同决定——同一个 JVM、同一批 key顺序是稳定的但它和你数据出现的顺序完全无关。你往 map 里按 A、B、C 的顺序放遍历出来可能是 C、A、B。小李那次事故的根因在别处。他的上游是一批从多个分片拉回来的订单代码里写的是list.parallelStream().collect(Collectors.groupingByConcurrent(Order::category, Collectors.toList()))。groupingByConcurrent返回的是ConcurrentMap多个线程会用computeIfAbsent拿到同一个ArrayList然后并发地往里add。ArrayList的add本身不加任何同步扩容时还会把元素数组整体复制一遍多线程同时进来就是丢数据、读脏长度、顺序彻底不可控。改成普通groupingBy之后现象立刻消失。1.2 先把三种排序分清楚在动手写代码之前我习惯先问自己一句我要排的是什么这三者的实现位置完全不一样。排序目标影响的是什么实现手段对应的容器组内元素顺序每个分组 List 里元素的先后collectingAndThen排 List或先sorted再分组下游收集器组键顺序遍历 Map 时 key 的先后mapFactory传LinkedHashMap/TreeMap外层 Map组间顺序分组按某个聚合值总数、总额排名分组完成后再对entrySet()排一次外层 Map小李报的顺序不对前端真正在意的是组内顺序但他排查时盯着的是map.keySet()的输出两件事压根不在一个层面。做过报表导出的同学应该有体会导出的 Excel 里分组标题的行序属于第二类和第三类明细行的顺序属于第一类客户投诉顺序乱时八成是在说明细行。1.3 一个可以照着跑的复现例子为了后面所有代码有个统一的载体我用 JDK 17 的 record 定义一个订单public record Order(String id, String category, String channel, BigDecimal amount, long createdAt) {}造 12 条数据故意让同一品类下的金额乱序然后执行最朴素的代码MapString, ListOrder byCategory orders.stream() .collect(Collectors.groupingBy(Order::category)); byCategory.forEach((k, v) - System.out.println(k - v.stream().map(Order::id).collect(Collectors.joining(,))));跑出来大概是这样顺序取决于你造数据的顺序我这里是按品类连续输入的数码 - O3,O5,O7,O9 服饰 - O2,O4,O8,O10 食品 - O1,O6,O11,O12这里组内其实是对的——O3、O5、O7、O9就是它们进入流的顺序也是我造数据的顺序。但如果你把源数据换成一个HashMap缓存里遍历出来的 list或者中途加了.parallel()这个顺序就不再是你期望的业务顺序。所以分组后组内顺序天然正确这句话只在流有序 下游有序 串行或有序并行归约三个条件同时成立时才对。这三个条件后面会一个个拆开讲。2. 组内排序的四种写法以及我更推荐哪一种2.1 collectingAndThen 包一层 toList最直白网上搜到的第一个答案通常是这样MapString, ListOrder byCategory orders.stream() .collect(Collectors.groupingBy( Order::category, Collectors.collectingAndThen( Collectors.toList(), list - list.stream() .sorted(Comparator.comparing(Order::amount).reversed()) .collect(Collectors.toList()) ) ));collectingAndThen(downstream, finisher)的语义是先用下游收集器把组收完再对收完的结果做一次变换。放在这里正好合适每个分组先被收成一个ArrayList然后这个 list 在组内部被排一次序。finisher是每个分组各执行一次的不是全局执行一次这一点非常关键——很多人第一次看这段代码会以为它会破坏分组结构。这段代码唯一的毛病是啰嗦而且多了一次拷贝toList()已经生成了一个ArrayListlist.stream().sorted().collect(toList())又生成了一个。对 5000 条一组的数据来说就是 5000 个引用多搬一遍量级上不致命但完全没必要。2.2 在 collectingAndThen 里直接调 list.sort()我更常用同样是collectingAndThen把中间的流管道换成List.sort()MapString, ListOrder byCategory orders.stream() .collect(Collectors.groupingBy( Order::category, Collectors.collectingAndThen( Collectors.toList(), list - { list.sort(Comparator.comparing(Order::amount).reversed()); return list; } ) ));List.sort是接口上的默认方法Collectors.toList()实际返回的ArrayList支持原地排序不产生新数组。相比 2.1 的写法省掉了一次列表构造和一次全量拷贝。在组数几十个、每组几千条的场景里这个改动能省掉相当一部分 GC 压力——我实测过 10 万条、20 个分组的报表接口光是这个替换就能让 Young GC 次数少掉一小截。list.sort()要求 list 支持set原地替换ArrayList可以。但注意如果你下游用的是Collectors.toUnmodifiableList()或者Stream.toList()Java 16拿到的 list 是不允许修改的sort会直接扔UnsupportedOperationException。这个坑在第 5 节展开。2.3 mapping 先把字段抽出来再排如果你的分组结果根本不需要保留整个对象只想拿到每个品类下排好序的 id 列表可以先用mapping做投影再排字符串MapString, ListString idsByCategory orders.stream() .collect(Collectors.groupingBy( Order::category, Collectors.collectingAndThen( Collectors.mapping(Order::id, Collectors.toList()), ids - { ids.sort(Comparator.naturalOrder()); return ids; } ) ));这种写法的好处是内存占用直接降下来。Order对象是几百字节的话一个 id 字符串可能只有几十字节几万条数据的投影能省出一大截堆空间。如果结果还要序列化成 JSON 返给前端投影的收益就更明显——没必要把createdAt、channel这些前端用不到的字段传出去。Comparator 这里想说的是Comparator.naturalOrder()对 String 用的是字典序中文会被按 Unicode 码点排得到的结果不是拼音序也不是笔画序。要按中文习惯排得换成CollatorComparatorString cn Collator.getInstance(Locale.SIMPLIFIED_CHINESE); ids.sort(cn);Collator实现了ComparatorObject直接传给List.sort或Comparator.comparing的第二个参数都能编译通过。这个细节在给国内客户做报表时几乎一定会遇到默认字典序排出来的北京、上海、广州会变成按码点的顺序客户一眼就能看出不对。2.4 先整体排序再用 LinkedHashMap 分组这是我个人最偏爱的写法尤其在分组键很多的时候MapString, ListOrder byCategory orders.stream() .sorted(Comparator.comparing(Order::amount).reversed()) .collect(Collectors.groupingBy( Order::category, LinkedHashMap::new, Collectors.toList() ));思路是先排完再切分先把整个流按金额排好groupingBy按遭遇顺序把元素依次塞进各自的组因为遇到元素的顺序已经是排序好的每个组收完之后自然就是有序的。所以组内排序这一目标不需要写任何collectingAndThen。这里有两个隐含前提值得点名。第一sorted()会给流打上有序标志哪怕源本身是无序的比如从HashMap.keySet()来的排完序之后groupingBy也能按稳定顺序收集。这一点是 JDK 实现层面的保证SortedOps在构造时就注入了IS_ORDERED。第二mapFactory必须给LinkedHashMap否则外层还是HashMap键顺序依旧随机。用LinkedHashMap之后键的首次出现顺序会被保留——这带来的一个副作用是分组键的顺序变成了排序后第一条记录的品类顺序而不一定是业务想要的顺序。要做成按客户名单顺序排列品类还得用第 3 节的TreeMap方案。2.5 四种写法的横向对比写法组内有序需要几次排序额外拷贝适合场景2.1 collectingAndThen stream().sorted()是每组一次一次全量可读优先、数据量小2.2 collectingAndThen list.sort()是每组一次无通用首选2.3 mapping 投影排序是投影后每组一次无只需要少数字段2.4 先 sorted 再分组是全局一次无分组键多、组小选哪一条没有绝对答案要看分组数量和每组规模。这个取舍在第 7 节会用粗略实测数据再讲一遍。3. 分组键的顺序控制mapFactory 的实战用法3.1 LinkedHashMap 保住首次出现顺序三参数版本的groupingBy(classifier, mapFactory, downstream)给了我们替换外层 Map 的权力mapFactory就是一个SupplierM。最常见的两个选择是LinkedHashMap和TreeMap。LinkedHashMap::new的语义是按插入顺序迭代。在分组场景里插入顺序就是某个分组键第一次出现的顺序也就是该组第一条数据在流里的位置。这个语义在做按数据源顺序展示分组时非常好用比如从一张已经排好序的 SQL 结果里读出来的明细用户希望分组标签按明细里第一次出现的顺序排。MapString, ListOrder byCategory orders.stream() .collect(Collectors.groupingBy( Order::category, LinkedHashMap::new, Collectors.toList() ));一个容易忽略的点LinkedHashMap只是在迭代顺序上稳定它不会改变任何哈希行为也不会影响get的性能。如果你用它纯粹是为了输出好看不用担心性能上的代价——除了多两个指针字段它和HashMap基本一致。3.2 TreeMap 让分组键自己有序如果键本身有自然顺序数字、日期、枚举名TreeMap::new是最省事的MapString, ListOrder byCategory orders.stream() .collect(Collectors.groupingBy( Order::category, TreeMap::new, Collectors.toList() ));键会按自然顺序排。中文键要按拼音或笔画就得传比较器MapString, ListOrder byCategory orders.stream() .collect(Collectors.groupingBy( Order::category, () - new TreeMapString, ListOrder( Collator.getInstance(Locale.SIMPLIFIED_CHINESE)), Collectors.toList() ));还有个很实用的技巧当你希望分组按业务定义的顺序排列而不是自然顺序时可以维护一个顺序表用它的下标做比较器。private static final ListString CATEGORY_ORDER List.of(数码, 服饰, 食品, 家居); MapString, ListOrder byCategory orders.stream() .collect(Collectors.groupingBy( Order::category, () - new TreeMap(Comparator.comparingInt(CATEGORY_ORDER::indexOf)), Collectors.toList() ));注意这里indexOf对不在表里的键返回-1所有未知键会被排在第一位并互相视为相等。要么保证分类函数只会产出表里有的值要么把兜底逻辑写进比较器ComparatorString byOrder (a, b) - { int ia CATEGORY_ORDER.indexOf(a); int ib CATEGORY_ORDER.indexOf(b); if (ia 0) ia Integer.MAX_VALUE; if (ib 0) ib Integer.MAX_VALUE; return Integer.compare(ia, ib); };TreeMap的代价在于每次插入都要做 O(log n) 的比较。分组键几十上百个的话完全可以忽略但如果你的分组键有几万个比如按用户 id 分组TreeMap的比较开销会叠加得比较明显这种情况还是老老实实用HashMap需要顺序时再单独排一次。3.3 组间排序按聚合值给分组结果排队这是实际业务里出镜率最高、但容易被忽略的一种排序。客户说帮我把品类按销售额从高到低排一下指的是组与组的顺序跟组内元素无关。做法分两步先分组算聚合值再把entrySet排一遍重新收集。MapString, BigDecimal amountByCategory orders.stream() .collect(Collectors.groupingBy( Order::category, Collectors.reducing(BigDecimal.ZERO, Order::amount, BigDecimal::add) )); LinkedHashMapString, BigDecimal ranked amountByCategory.entrySet().stream() .sorted(Map.Entry.String, BigDecimalcomparingByValue().reversed()) .collect(Collectors.toMap( Map.Entry::getKey, Map.Entry::getValue, (a, b) - a, LinkedHashMap::new ));几个细节。Collectors.toMap的四参数版本必须传合并函数(a, b) - a即使entrySet流里不可能有重复键——因为编译期没法证明不传就编译不过。Map.Entry.comparingByValue()前面的String, BigDecimal显式类型参数也不能省Java 里entrySet().stream()的类型推断在这里经常推不出来漏了就是一堆编译错误。如果聚合值是double或int用Collectors.summingDouble/summingInt更直观。但金额类字段我一律建议用BigDecimal加reducing浮点累加在几万条数据上就会出现尾差对账场景下这个尾差是要命的。3.4 多级分组时每一层都要单独决策两级分组看起来像套娃但顺序控制得逐层处理MapString, MapString, ListOrder nested orders.stream() .collect(Collectors.groupingBy( Order::category, LinkedHashMap::new, Collectors.groupingBy( Order::channel, TreeMap::new, Collectors.collectingAndThen( Collectors.toList(), list - { list.sort(Comparator.comparing(Order::createdAt)); return list; } ) ) ));第一层的LinkedHashMap管品类顺序第二层的TreeMap管渠道顺序最内层的collectingAndThen管明细行顺序。三层各管各的互不干扰。这里有个反直觉的点内层的mapFactory是在每个外层分组里各创建一次的不是全局一个。所以某个品类下只有两个渠道时那个内部TreeMap就只有两个键完全没问题。顺带说一句嵌套层级别超过两层。三层以上的groupingBy代码可读性会断崖式下跌MapString, MapString, MapString, ListT这种类型签名本身就该被封装成一个record或者干脆在 SQL 层解决。我见过一个五层嵌套的分组代码原作者离职之后没人敢动。4. 组内只取前 N 条sorted 该放在管道的哪一环4.1 组内 TopN 的标准写法需求从每组排序往前一步通常就变成每组取前 3 条。标准写法是在collectingAndThen的 finisher 里排完序顺手limitMapString, ListOrder top3 orders.stream() .collect(Collectors.groupingBy( Order::category, LinkedHashMap::new, Collectors.collectingAndThen( Collectors.toList(), list - list.stream() .sorted(Comparator.comparing(Order::amount).reversed()) .limit(3) .collect(Collectors.toList()) ) ));这里的limit放在sorted之后语义是排完全部再截前三条。因为sorted是有状态操作整组数据必须全部经过排序才能确定前三条所以limit并不能帮你减少排序量它只是减少了输出量。想真正减少计算量得用堆见 4.3。还有一个更省事的版本用list.sort加subListCollectors.collectingAndThen( Collectors.toList(), list - { list.sort(Comparator.comparing(Order::amount).reversed()); return new ArrayList(list.subList(0, Math.min(3, list.size()))); } )Math.min不能省。某个分组只有 1 条数据时subList(0, 3)会抛IndexOutOfBoundsException而线上数据里总会出现某个品类只卖出去一件的情况。这类边界在测试环境很难覆盖上线之后靠监控报警才发现。4.2 全局 TopN 和组内 TopN 是两码事非常高频的一个误解stream.sorted(...).limit(3)之后再groupingBy得到的是全局前三条分到哪几个组里每个组可能只有 0 到 3 条而不是每个组各取前三。顺序反过来写结果完全不同。// 全局 Top3然后按品类看它们分布在哪 MapString, ListOrder wrong orders.stream() .sorted(Comparator.comparing(Order::amount).reversed()) .limit(3) .collect(Collectors.groupingBy(Order::category)); // 每个品类各取 Top3 MapString, ListOrder right orders.stream() .collect(Collectors.groupingBy( Order::category, Collectors.collectingAndThen( Collectors.toList(), list - list.stream() .sorted(Comparator.comparing(Order::amount).reversed()) .limit(3) .collect(Collectors.toList()) ) ));写的时候心里默念一句分组是一个终止操作分组之前的所有操作都是全局的。这个原则能避开一大半的误用。4.3 大分组下用堆换掉全量排序当一个组有几百万条数据、只需要前 100 名时全量排序的代价就很扎眼了。这时候可以用PriorityQueue维护一个小顶堆堆里始终只有 N 个元素static CollectorOrder, ?, ListOrder topNByAmount(int n) { return Collector.of( () - new PriorityQueue(n 1, Comparator.comparing(Order::amount)), (heap, order) - { heap.offer(order); if (heap.size() n) { heap.poll(); } }, (left, right) - { right.forEach(o - { left.offer(o); if (left.size() n) { left.poll(); } }); return left; }, heap - { ListOrder top new ArrayList(heap); top.sort(Comparator.comparing(Order::amount).reversed()); return top; } ); }原理不复杂维护一个小顶堆堆顶是最小值新元素入堆后如果堆大小超过 N就把堆顶弹掉。遍历结束时堆里留下的就是最大的 N 个。时间复杂度从 O(m log m) 降到 O(m log N)m 是组内条数。有三个地方容易写错。第一堆的初始容量要给n 1Java 的PriorityQueue不接受容量小于 1 的值直接写new PriorityQueue(n)在 n 为 0 时会抛异常。第二比较器要给升序让堆顶是最小值——很多人直觉上会写reversed()结果堆顶变成最大值弹掉的恰好是要保留的那条。第三PriorityQueue不保证并列元素的相对顺序稳定金额相同的两条数据谁进谁出是不确定的。业务上如果有金额相同时按时间优先的要求比较器里必须补上第二排序键ComparatorOrder cmp Comparator.comparing(Order::amount) .thenComparingLong(Order::createdAt);1那个容量设置也有讲究它让堆永远不会触发扩容因为任何时刻堆里最多 N1 个元素。4.4 teeing 一次拿到多个统计量如果组内不仅需要排序还要同时拿到条数、总额、最大值Java 12 引入的Collectors.teeing可以一次遍历出来两个结果避免多次分组。MapString, String summary orders.stream() .collect(Collectors.groupingBy( Order::category, Collectors.teeing( Collectors.counting(), Collectors.reducing(BigDecimal.ZERO, Order::amount, BigDecimal::add), (count, total) - count 单 / 合计 total ) ));teeing的签名是两个下游收集器 一个合并函数两个下游会同时对同一批元素做归约最后把结果喂给合并函数。它的价值在于省掉一次全量分组——两次groupingBy意味着两次遍历和两次 map 构建。需要三个统计量时嵌套一个teeing就行Collectors.teeing( Collectors.counting(), Collectors.teeing( Collectors.reducing(BigDecimal.ZERO, Order::amount, BigDecimal::add), Collectors.maxBy(Comparator.comparing(Order::amount)), (total, max) - new String[]{total.toPlainString(), max.map(o - o.id()).orElse(-)} ), (count, pair) - count 单 / 合计 pair[0] / 最大单号 pair[1] )中间用数组传值确实丑。如果统计口径超过两个我会直接写一个自定义收集器把一个可变的结果对象当累加器比嵌套teeing好读得多。teeing适合两三个指标的轻量场景别为了用上新 API把代码写成俄罗斯套娃。5. 踩过的坑null 键、并行流、共享容器与可变返回值5.1 分类函数返回 null 直接 NPECollectors.groupingBy对分类结果做了非空校验分类函数返回 null 会立刻抛NullPointerException异常信息是element cannot be mapped to a null key。这个校验是有意为之的因为HashMap虽然允许 null 键但分组语义下 null 键往往意味着数据有问题。// category 为 null 时直接炸 MapString, ListOrder m orders.stream() .collect(Collectors.groupingBy(Order::category)); // 兜底写法 MapString, ListOrder m orders.stream() .collect(Collectors.groupingBy( o - o.category() null ? 未分类 : o.category()));流元素本身为 null 时也一样Order::category这个 method reference 会在 null 上调用抛 NPE。要么在源头filter(Objects::nonNull)要么在分类函数里处理。我一般倾向在源头过滤因为一个 category 为 null 的订单本身就该在入库环节被拦截在聚合层兜底等于把数据质量问题藏起来。5.2 groupingByConcurrent 的下游必须并发安全回到小李那个事故。groupingByConcurrent的定位是用于并发场景的 groupingBy它返回ConcurrentMap内部用computeIfAbsent保证每个 key 只创建一次容器。但它对下游收集器本身有并发安全的要求多个线程会同时往同一个容器里累加。Collectors.toList()返回的收集器不是并发收集器底层是ArrayList并发add会丢元素、读到脏 size甚至在某些扩容时序下产生空槽。counting()、summingInt()这类基于LongAdder/ 原子累加的收集器反而是安全的。我的实际建议更简单别用groupingByConcurrent。普通的groupingBy在并行流下是安全的它采用的是 map-merge 模式——每个线程用自己的一份局部 map 累加最后通过收集器的 combiner 合并。下游容器不会被多线程共享所以哪怕下游是ArrayList也不会有并发问题。只有在分组键非常多、合并开销明显成为瓶颈时groupingByConcurrent才有意义而那时候下游也该换成toConcurrentMap之类的并发收集器而不是toList。5.3 把 collect 的结果塞进缓存之后谁动了那个 ListCollectors.toList()返回的是一个可变的ArrayList而且这个 list 的引用就是 map 里的值。如果你把这个 map 缓存起来然后在下游代码里对某个 list 做了sort、add、remove你改的是缓存里的内容——下一个请求拿到的就是被污染的数据。MapString, ListOrder cached cache.get(order:byCategory); ListOrder digital cached.get(数码); digital.sort(Comparator.comparing(Order::amount)); // 缓存被就地改了这类问题最难排查的地方在于它只在高并发下偶发单机测试永远复现不了。防御手段有两个一是缓存之前就换成不可变集合MapString, ListOrder immutable byCategory.entrySet().stream() .collect(Collectors.toMap( Map.Entry::getKey, e - List.copyOf(e.getValue()), (a, b) - a, LinkedHashMap::new ));List.copyOf会做一次防御性拷贝任何修改尝试都会抛UnsupportedOperationException。二是直接在下游收集器里就用toUnmodifiableList()Collectors.groupingBy(Order::category, Collectors.toUnmodifiableList())用了toUnmodifiableList之后2.2 那种list.sort()的写法就跑不通了必须先toList()收完再排排完再用Collectors.collectingAndThen包一层Collections.unmodifiableList。这是可变性和就地排序省拷贝之间的取舍得看你更在意哪一头。也顺带提一句Stream.toList()Java 16它返回的也是不可变列表而且它比Collectors.toList()更宽松——允许 null 元素但同样不允许修改。区分这两种toList是新人最常犯的混淆之一。5.4 并行流下组内顺序还能不能保住这是很多人关心的问题。答案是对有序流、下游是有序收集器toList的情况普通的groupingBy在并行流下也能保持组内遭遇顺序因为收集器的 combiner 是按左右顺序做addAll合并的归约树的结构和流的遭遇顺序是对齐的。但这个保证很脆弱。中间的任何一个环节把有序这个属性去掉顺序就没了操作对顺序的影响.unordered()直接放弃顺序保证分组结果完全随机Collectors.toSet()作下游组内变成HashSet迭代顺序与插入顺序无关groupingByConcurrent容器是多线程共享的累加顺序不可控源是无序集合且中途没排序遭遇顺序本身就是随机的我的做法是需要稳定顺序时不依赖并行流的顺序保证。要么先sorted再分组要么在collectingAndThen里显式排一次。这两种写法把顺序的来源摆到明面上而不是依赖 JDK 实现细节。以后换个人维护也不用重新推一遍。6. 实战按分数段分组、组内按提交时间升序6.1 分桶边界与百分比分组的处理把排序换成分桶本质还是分组加排序。假设有一批试卷提交记录需求是按分数段分组段内按提交时间升序同时输出每个分数段的占比。public record Paper(String studentId, String subject, int score, long submitAt) {}分数段用固定字符串做键private static final ListString BUCKETS List.of(90-100, 80-89, 70-79, 60-69, 0-59); static String bucketOf(int score) { if (score 90) return 90-100; if (score 80) return 80-89; if (score 70) return 70-79; if (score 60) return 60-69; return 0-59; }分桶的边界条件是这里最需要交代的地方 90而不是 90 80而不是 80。只要有一个边界写错就会出现一条既不属于 80-89 也不属于 90-100 的记录或者两条区间重叠。我在 code review 里见过最离谱的一版是每个if都用了结果 89 分和 90 分都落进了 80-8990-100 那一档永远是空的。另外分数值理论上应该被约束在 0 到 100 之间。如果数据源不可信最后那个return 0-59会把 -10 分和 200 分也吞进去我一般会加一个前置过滤或者在最后返回异常分数这一档让它显式暴露出来而不是悄悄混进某一个正常区间。6.2 完整可跑的代码ComparatorString byBucketOrder Comparator.comparingInt(BUCKETS::indexOf); MapString, ListPaper grouped papers.stream() .sorted(Comparator.comparingLong(Paper::submitAt)) .collect(Collectors.groupingBy( p - bucketOf(p.score()), () - new TreeMapString, ListPaper(byBucketOrder), Collectors.toList() ));这段代码同时解决了三件事。sorted让明细按提交时间升序因为groupingBy按遭遇顺序收元素组内自然有序。TreeMap加自定义比较器让分数段按从高到低排列而不是按字典序把0-59排在最前面。LinkedHashMap在这里不合适因为键的首次出现顺序取决于最高分那条记录落在哪一档和从高到低完全是两码事。如果某个分数段一个人都没有TreeMap里就不会有这个键遍历时直接跳过。要不要补齐空档是产品决策报表里通常需要显示90-100 档 0 人这一行那就得在分组完成后手动补for (String bucket : BUCKETS) { grouped.putIfAbsent(bucket, List.of()); }putIfAbsent配合List.of()是安全的但如果后面有人对空列表做sort又会踩到不可变集合的坑。用new ArrayList()更保险。6.3 占比计算与除零边界分数段占比用分组结果再算一遍long total papers.size(); MapString, String share grouped.entrySet().stream() .collect(Collectors.toMap( Map.Entry::getKey, e - total 0 ? 0.00% : String.format(%.2f%%, e.getValue().size() * 100.0 / total), (a, b) - a, LinkedHashMap::new ));total 0这个分支必须写。试卷列表为空时e.getValue().size() * 100.0 / 0得到的是NaNString.format会把NaN原样输出前端拿到字符串 NaN% 直接就报错。这种除零在测试环境几乎碰不到但在新学期的第一天、报表系统刚上线还没数据的时候必然出现。百分比保留两位小数用String.format就够了。真要做精确的比例计算比如财务口径得用BigDecimal.divide(value, 4, RoundingMode.HALF_UP)把精度和舍入模式显式指定BigDecimal的除法不指定精度在除不尽时会抛ArithmeticException。还有一个容易被问到的问题各个分数段的百分比加起来不等于 100%。这是四舍五入的必然结果除非做最大余额法这类舍入补偿。报表里如果客户对不上这一分钱的差通常要在页脚加一句说明或者干脆在服务端做一次补齐。7. 量级不同的选型建议与一次粗略实测7.1 比较次数的账要算清楚第 2 节列了四种写法到底哪种快先算理论账。整体排序的比较次数是 n·log n分组内排序的总和是 Σkᵢ·log kᵢ其中 kᵢ 是第 i 组的元素数Σkᵢ n。因为对数函数是凹函数由 Jensen 不等式可以推出来 Σkᵢ·log kᵢ ≤ n·log n也就是说分组内排序的总比较次数不会超过整体排序。但比较次数不等于耗时。整体排序只跑一次 TimSort数据在连续内存上缓存命中率高分组内排序要跑 k 次独立的排序每次都有流管道创建、数组分配的开销而且分组越多这些固定开销占比越大。所以理论账和实际耗时经常是反着来的。7.2 我在本机上跑的粗数据拿 10 万条订单、20 个品类每组约 5000 条、JDK 17 上随手跑了几轮不是 JMH也没做预热只能看量级写法单次耗时粗测备注2.1 collectingAndThen stream().sorted()75-90 ms多一次全量拷贝GC 压力最大2.2 collectingAndThen list.sort()45-55 ms通用首选2.4 先 sorted 再 LinkedHashMap 分组50-65 ms比较次数最少但要处理键顺序分组完成后再逐组 list.sort()55-70 ms多一次 map 遍历把分组数从 20 提到 2000每组 50 条左右之后情况反过来2.4 稳定在 60 ms 上下2.2 掉到 110 ms 以上。原因很直白——2000 次小排序的固定开销叠加起来了而整体排序只有一次。所以经验值是分组键数量在几十个的量级用 2.2分组键上千、每组只有几条用 2.4。内存方面2.2 和 2.4 都不产生额外的中间列表峰值内存基本就是原始数据加上分组结果本身。2.1 因为多一次list.stream().sorted().collect(toList())会短暂地让堆上多出一份全量引用10 万条数据下这点差别不明显但到了千万级就会直接体现在 GC 日志上。7.3 我一般怎么选把这几年写聚合代码的习惯压缩成几条供参考。数据量在几万条以内、分组数在几十个就直接用collectingAndThen加list.sort()代码短、语义清楚、性能也不差。这种情况没必要为了省几毫秒去折腾mapFactory和预排序。分组数上千、每组只有几条或者数据源已经排好序比如 SQL 里已经有ORDER BY用先sorted再groupingByLinkedHashMap一次排序解决所有分组。SQL 已经排好序的时候甚至连sorted都能省掉直接在groupingBy里用LinkedHashMap分组组内顺序就是 SQL 给的顺序——但这时候千万别并发处理一旦用了parallelStream顺序保证就悬了。需要组内 TopN 的时候先看 N 和组大小的比例。N 是组大小的十分之一以上直接排完limit代码可读性优先。N 是组大小的千分之一甚至更小才值得上PriorityQueue而且要记得补上第二排序键保证并列稳定。最后一点是给自己留后路凡是依赖顺序的聚合结果在方法注释里写清楚顺序由谁保证。是数据源给的是sorted给的还是finisher里显式排的。我接手过一个接口返回值顺序看起来完全正常直到某天分片数从 4 变成 8顺序全变了——翻遍代码也找不到顺序应该是怎样的说明最后只能靠和测试环境比对输出反推。这个注释成本是几分钟收益是几个小时的排查时间非常划算。
返回列表