ARTICLE DETAIL

资讯详情

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

Java Stream groupingBy实现直方图统计与最大值提取

Java Stream groupingBy实现直方图统计与最大值提取 做 Java 开发这些年我越来越觉得 Stream API 里最被低估的收集器就是groupingBy()。一提到直方图很多人先想到图像处理里的直方图均衡化或者概率论里的分布图但放到日常业务开发里直方图的本质非常朴素统计一批数据里每个类别各出现了多少次。订单按城市分组、日志按错误码统计、用户按年龄段聚合、商品按价格区间计数——这些需求全是直方图。有一次我需要处理几百万条埋点日志统计每个接口被调用次数还要找出调用量最高的那个接口。第一版用传统 for 循环加 HashMap 硬写二三十行代码绕来绕去还得手动处理各种边界情况。后来换成 Stream API 的groupingBy()三四行就搞定顺带把最大值、排序全做了。这篇文章就围绕用groupingBy()构建直方图并提取最大值这条链路展开从底层原理到完整代码从基础用法到进阶玩法把我在项目里实际验证过的方案和踩过的坑都翻出来讲清楚。不管你是刚入门的 Java 新人还是写了多年业务代码的老手这里面的内容应该都能直接用上。1. 为什么是 groupingBy()直方图统计的核心思路拆解1.1 直方图在业务开发里的真实场景先明确一个概念Java Stream API 语境下的直方图跟图像处理中的直方图不是一回事但底子是一样的——都是统计某个值出现的频次。图像里的直方图统计每个像素亮度值出现了多少像素点我们的业务直方图统计某个字段值出现了多少条数据。我在实际工作中遇到的直方图场景大致分三类频率统计型统计接口调用次数、错误码出现次数、关键词在文本中出现的次数。分布分析型按年龄、价格、时间区间等连续变量分桶观察数据是否集中比如18-25 岁用户占比多少。汇总归类型按某个维度聚合后做平均值、总和等计算比如每个销售团队的合同总额。这类需求用传统方式做会很啰嗦。我记得早期写代码统计一个 List 中各个元素的出现次数要先 new 一个 HashMap然后 for 循环判断containsKey有则 value1没有则 put 初始值 1。逻辑不难但写起来又臭又长而且很容易漏掉 null 判断。用groupingBy()之后一行就能拿到 Map 结构的统计结果这才是真正解放生产力的地方。1.2 groupingBy() 的底层逻辑分类器、Map 与下游收集器groupingBy()是java.util.stream.Collectors里的静态方法它的核心工作分三步遍历流中的每个元素。用你传入的分类器Classifier本质上是一个FunctionT, K算出每个元素的分组键 K。把分组键相同的元素汇聚到同一个集合里最终得到一个MapK, V。这里最关键的是参数设计。groupingBy()有三个重载版本// 版本一只传分类器返回 MapK, ListT同一个键下的所有元素组成 List static T, K CollectorT, ?, MapK, ListT groupingBy(Function? super T, ? extends K classifier) // 版本二额外传一个下游收集器分组后的集合再交给下游做二次处理 static T, K, A, D CollectorT, ?, MapK, D groupingBy(Function? super T, ? extends K classifier, Collector? super T, A, D downstream) // 版本三再额外指定返回的 Map 工厂比如传 TreeMap::new 实现按 key 排序 static T, K, D, A, M extends MapK, D CollectorT, ?, M groupingBy(Function? super T, ? extends K classifier, SupplierM mapFactory, Collector? super T, A, D downstream)构建直方图最常用的组合是分类器 下游收集器counting()。counting()会统计分组内元素个数最终返回MapK, Long其中 Long 就是频次。我建议你把groupingBy()理解成一个流水线上游是元素流分类器决定每个元素进哪条轨道下游收集器决定每条轨道最终留下什么结果。这个心智模型一旦建立后面所有变体都能一眼看懂。1.3 对比传统写法Stream 方案好在哪当年我用 for 循环写频次统计时代码长这样MapString, Integer countMap new HashMap(); for (String word : wordList) { if (word null) { continue; } countMap.put(word, countMap.getOrDefault(word, 0) 1); }这段代码本身没什么问题但有几个隐性缺陷。第一空值处理要靠自己记得写第二如果想要统计结果按某种顺序输出还得额外 new 一个 TreeMap 或者LinkedHashMap再 putAll第三想要取出频次最高的元素还得再写一轮循环找最大值。换成 Stream APIMapString, Long countMap wordList.stream() .filter(Objects::nonNull) .collect(Collectors.groupingBy(Function.identity(), Collectors.counting()));一行统计一行过滤空值语义一目了然。别人看代码第一眼就知道你要做按值分组计数而不是在那里逐行推理 for 循环的逻辑。不过我要说句公道话Stream 不是万能的。数据量极大千万级以上或者分组键比较复杂时传统循环配合手动调优的 HashMap 可能更快。后面第 5 章我会专门讲性能边界这里先不做评判。2. 构建直方图的完整实战从基础统计到排序直方图2.1 基础版用 counting() 统计元素频次先来一个最经典的例子统计一个字符串列表中每个单词出现的次数。import java.util.List; import java.util.Map; import java.util.function.Function; import java.util.stream.Collectors; public class HistogramDemo { public static void main(String[] args) { ListString words List.of( java, stream, api, java, grouping, stream, api, java, collector, grouping, java ); MapString, Long histogram words.stream() .collect(Collectors.groupingBy(Function.identity(), Collectors.counting())); System.out.println(histogram); // 输出顺序可能不同{stream2, grouping2, api2, collector1, java4} } }这里Function.identity()是把元素本身作为分组键等价于word - word。统计结果中java出现了 4 次stream等各出现 2 次这就是一张标准的频次直方图。如果不想统计整个单词而是统计单词首字母的分布只要换分类器MapCharacter, Long firstLetterHistogram words.stream() .filter(w - !w.isEmpty()) .collect(Collectors.groupingBy(w - w.charAt(0), Collectors.counting())); System.out.println(firstLetterHistogram); // 输出示例{s2, a2, g2, c1, j4}看到没有整个思路的弹性就在分类器上。你可以按对象的某个字段分组按字符串长度分组按下单时间的小时字段分组组合非常灵活。2.2 排序版如何得到一个按键排序的直方图基础版返回的是HashMap遍历顺序不稳定。如果你需要按照分组键顺序展示直方图——比如按年龄段从小到大按字母从 A 到 Z——就得换一个能排序的 Map。groupingBy()的第三个重载版本允许你传入一个SupplierM来指定 Map 的实现类。最常见的做法是传TreeMap::newimport java.util.TreeMap; MapString, Long sortedHistogram words.stream() .collect(Collectors.groupingBy(Function.identity(), TreeMap::new, Collectors.counting())); System.out.println(sortedHistogram); // 输出{api2, collector1, grouping2, java4, stream2}输出顺序严格按 key 的自然顺序排列。如果分组键是自定义对象想让 TreeMap 按照你指定的规则排序可以传入一个带自定义 Comparator 的 TreeMap 工厂MapInteger, Long histogramByLength words.stream() .collect(Collectors.groupingBy( String::length, () - new TreeMap(Comparator.reverseOrder()), Collectors.counting() )); System.out.println(histogramByLength); // 输出按长度降序{82, 72, 62, 43}这里我用String::length把单词按长度分组然后让 TreeMap 按长度倒序排列。注意TreeMap::new这种写法只适用于键是自然可排序的类型一旦你传了一个 Comparator必须用 lambda 形式的() - new TreeMap(comparator)否则编译都不给你过。2.3 多级分组按两个维度同时构建直方图业务上经常遇到既要按城市又要按用户等级这种多维度统计。groupingBy()可以嵌套使用外层分组结果的值是内层分组的结果最终形成两级 Mapclass Order { private String city; private String userLevel; private double amount; // 构造方法、getter 省略 } ListOrder orders List.of( new Order(上海, VIP, 100.0), new Order(北京, 普通, 80.0), new Order(上海, 普通, 50.0), new Order(北京, VIP, 200.0), new Order(上海, VIP, 300.0) ); MapString, MapString, Long twoLevelHistogram orders.stream() .collect(Collectors.groupingBy( Order::getCity, Collectors.groupingBy(Order::getUserLevel, Collectors.counting()) )); System.out.println(twoLevelHistogram); // 输出{上海{普通1, VIP2}, 北京{普通1, VIP1}}两层分组后的 Map 已经能支撑绝大多数报表需求。再往上嵌套第三层也不是不行但可读性会断崖式下降我一般到两层就止步。真需要三层以上更合适的做法是先把多个维度的字段拼成一个复合键对象或者直接定义一个统计用的 DTO 类在collect之前先 map 转换一次。比如把城市 用户等级拼成 keyMapString, Long compositeHistogram orders.stream() .collect(Collectors.groupingBy( o - o.getCity() | o.getUserLevel(), Collectors.counting() )); System.out.println(compositeHistogram); // 输出{北京|普通1, 上海|VIP2, 北京|VIP1, 上海|普通1}这种方式的优势是后续处理扁平化代价是丢失了层级结构。具体用哪种取决于下游是树形展示还是表格展示。3. 提取最大值几种方案与取舍有了直方图最常见的下一步就是提取最大值。但最大值本身是有歧义的可能是出现频次的最大值也可能是出现频次最大的那个分组键。实际开发中两种都有需求我一个个说。3.1 方案一用 maxBy() 下游收集器一步拿到频次最高的分组maxBy()是 Collectors 提供的另一个下游收集器它需要一个 Comparator 来决定最大的定义。组合使用时分组后的结果不是计数而是该分组内值最大的那个元素。直接看代码import java.util.Comparator; import java.util.Map; import java.util.Optional; import java.util.stream.Collectors; MapString, Integer cityInstallBase Map.of( 北京, 320, 上海, 410, 广州, 220, 深圳, 380 ); // 把分组之后的值Integer做比较取出最大值对应的分组 MapString, OptionalInteger result cityInstallBase.entrySet().stream() .collect(Collectors.groupingBy( Map.Entry::getKey, Collectors.mapping(Map.Entry::getValue, Collectors.maxBy(Comparator.naturalOrder())) ));不过说实话这个写法有点绕。我通常更愿意直接在一开始的数据上操作而不是先转成 Map 再二次处理。3.2 方案二对直方图再求 maxCollections / Stream 二选一假设我们已经拿到了直方图MapString, Long要取频次最高的那条记录最直观的方式是把 entrySet 变成流再用max()MapString, Long histogram words.stream() .collect(Collectors.groupingBy(Function.identity(), Collectors.counting())); Map.EntryString, Long maxEntry histogram.entrySet().stream() .max(Map.Entry.comparingByValue()) .orElseThrow(() - new IllegalStateException(直方图为空无法提取最大值)); System.out.println(maxEntry.getKey() : maxEntry.getValue()); // 输出java : 4Map.Entry.comparingByValue()是专门为 entry 比较设计的 Comparator它会按 entry 的 value 做升序比较max()拿到的自然是 value 最大的那个 entry。如果你不想用 StreamCollections.max()也能办到Map.EntryString, Long maxEntry2 Collections.max(histogram.entrySet(), Map.Entry.comparingByValue());两个方案性能差别不大Stream 方案胜在可读性好而且后面接.orElseThrow()处理空列表更顺畅。如果直方图本身可能是空的Collections.max()会直接抛NoSuchElementException反而省了你自己判断看你怎么取舍。这里有个细节要提Map.Entry.comparingByValue()要求 value 类型实现了Comparable。Long、Integer都没问题但如果 value 是自定义对象就要用Map.Entry.comparingByValue(Comparator)传入自定义 Comparator。3.3 方案三提取 Top N而不是只有一个最大值很多时候最大不够用。运营要看的是排行榜前十不是第一名。取 Top N 的通用做法是先按 value 降序排序然后截取前 N 个。Java 里排序 Map 有两个思路我推荐第二种// 思路一先把 entry 收集到 List再排序 ListMap.EntryString, Long entryList new ArrayList(histogram.entrySet()); entryList.sort(Map.Entry.String, LongcomparingByValue().reversed()); ListMap.EntryString, Long top3 entryList.stream().limit(3).toList();// 思路二直接用 Stream 链路 sort limit ListMap.EntryString, Long top3 histogram.entrySet().stream() .sorted(Map.Entry.String, LongcomparingByValue().reversed()) .limit(3) .toList();第二种写法更紧凑。注意comparingByValue().reversed()这里有个经典陷阱如果直接写Map.Entry.comparingByValue().reversed()泛型推断有时候会失败建议加上显式的类型见证String, Long也就是我上面写的样子不然 IDEA 可能会编译报错。如果要输出频次最高且并列多个用filter过滤出所有与最大值相等的 entrylong maxValue maxEntry.getValue(); ListMap.EntryString, Long allMax histogram.entrySet().stream() .filter(e - e.getValue() maxValue) .toList();注意我用了而不是equals因为Long在缓存范围内-128 到 127没问题超出范围就得用Objects.equals()。为保险起见建议直接写e.getValue().equals(maxValue)或者Objects.equals(e.getValue(), maxValue)别省这个事。3.4 面试高频变体怎样同时拿到最大值和它对应的分组键面试里经常出现一个变体给你一个MapString, Integer找出 value 最大的 key。乍看很简单但很多人第一步就绕进去了——先遍历这个 Map 找到最大 value再遍历一次找对应 key。两步遍历效率低代码也啰嗦。一次遍历就能搞定MapString, Integer scoreMap new HashMap(); scoreMap.put(张三, 88); scoreMap.put(李四, 95); scoreMap.put(王五, 73); Map.EntryString, Integer maxScoreEntry scoreMap.entrySet().stream() .max(Map.Entry.comparingByValue()) .orElseThrow(() - new IllegalArgumentException(Map 为空)); String topStudent maxScoreEntry.getKey(); // 李四 int topScore maxScoreEntry.getValue(); // 95这个思路跟第 3.2 节完全一致核心就一句话不要在 value 上找最大值要在 entry 上找最大值。因为 entry 同时持有 key 和 value找到 entry 就等于同时拿到了两边。顺带提一个相关热词滑动窗口最大值那是求一个数组内每个固定长度窗口的最大值跟groupingBy()解决的是两类问题。前者通常用双端队列在 O(n) 内解决后者是分组聚合。面试时不要混淆概念否则会被追问得很惨。4. 分组后的进阶统计下游收集器的组合玩法4.1 分组求和、平均、最大值的组合使用counting()只是下游收集器的其中一种。实际业务里我们对分组后的数据往往有更复杂的要求。比如同一个订单列表我想知道每个城市的总销售额、平均客单价、最大单笔金额。这三个指标可以全用下游收集器一步算完class Order { private String city; private double amount; Order(String city, double amount) { this.city city; this.amount amount; } public String getCity() { return city; } public double getAmount() { return amount; } } ListOrder orders List.of( new Order(上海, 100.0), new Order(北京, 200.0), new Order(上海, 150.0), new Order(北京, 80.0) ); MapString, Double totalByCity orders.stream() .collect(Collectors.groupingBy( Order::getCity, Collectors.summingDouble(Order::getAmount) )); MapString, Double avgByCity orders.stream() .collect(Collectors.groupingBy( Order::getCity, Collectors.averagingDouble(Order::getAmount) )); MapString, OptionalOrder maxOrderByCity orders.stream() .collect(Collectors.groupingBy( Order::getCity, Collectors.maxBy(Comparator.comparingDouble(Order::getAmount)) ));summingDouble得到每个城市的总销售额averagingDouble得到平均客单价maxBy把金额最大的订单对象取出来。注意最后那个 Map 的 value 类型是OptionalOrder因为空分组时没有元素可供比较这是 Collectors 的设计约定不是 Bug。很多教程在这里就停了但我实际项目里更常用的是teeing()——一次分组同时统计多个指标。Java 12 引入的teeing()可以把两个收集器合并成单个结果配合groupingBy()简直绝配MapString, MapString, Double statsByCity orders.stream() .collect(Collectors.groupingBy( Order::getCity, Collectors.teeing( Collectors.summingDouble(Order::getAmount), Collectors.averagingDouble(Order::getAmount), (sum, avg) - Map.of(total, sum, avg, avg) ) )); System.out.println(statsByCity); // 输出{上海{total250.0, avg125.0}, 北京{total280.0, avg140.0}}一次性拿到总和与平均值不用再对同一个流做两次groupingBy()。JDK 版本允许的话我强烈建议试试。4.2 分组后直接排序并截取前几名回到提取最大值的场景如果最大值只是开始排名才是终点那就没必要先构造出整个 Map 再排序。直接在流上完成ListMap.EntryString, Long top2 words.stream() .collect(Collectors.groupingBy(Function.identity(), Collectors.counting())) .entrySet() .stream() .sorted(Map.Entry.String, LongcomparingByValue().reversed()) .limit(2) .toList();这是先用groupingBy()统计再对 entrySet 流排序取前二。要注意的是整个过程会被 JVM 拆成两段流水线第一段生成 Map第二段排序中间没有多少优化空间。不过对于百万量级以下的数据这种写法完全够用可读性远胜手写排序循环。如果你用的是 Java 8没有toList()那就用collect(Collectors.toList())效果一样少一点语法糖而已。5. 常见问题与避坑实录5.1 空分组键null 到底能不能作为 keyHashMap允许 null 键所以groupingBy()遇到分类器返回 null 时默认的 HashMap 实现能正常存进去。但如果你用了TreeMap::new情况就不一样了——TreeMap 不允许 null key直接抛NullPointerException。举一个真实场景。统计订单按优惠券 ID 分组但很多订单没有使用优惠券优惠券 ID 是 null。用默认的groupingBy()没问题统计结果里会出现{null123}但一旦你为了排序换成 TreeMap就会在 collect 阶段爆异常。解决办法有两个我按推荐程度排序// 方案 A在分类器里把 null 替换成占位符 MapString, Long safeHistogram orders.stream() .collect(Collectors.groupingBy( o - o.getCouponId() null ? NO_COUPON : o.getCouponId(), TreeMap::new, Collectors.counting() )); // 方案 B先过滤掉空值仅当业务允许丢弃这些数据时 MapString, Long filteredHistogram orders.stream() .filter(o - o.getCouponId() ! null) .collect(Collectors.groupingBy(Order::getCouponId, TreeMap::new, Collectors.counting()));方案 A 保留数据方案 B 丢弃数据。具体选哪个取决于业务上无券订单是否要参与统计。这是我踩过的真实坑提醒各位先把决策做在前头。5.2 并行流分组并行处理直方图的性能陷阱parallelStream()配合groupingBy()看着很美——分组天然适合分而治之但实际用起来有许多隐藏成本。groupingBy()内部是ConcurrentHashMap与reduce方法的组合并行时多个线程会尝试合并 Map合并本身有锁开销而且分组后往往要排序、要提取最大值这些后续操作又是单线程的算下来总耗时可能比串行还慢。我用几十万条数据做过粗略测试串行stream()大约 300msparallelStream()反而到了 500ms 以上。数据量不到百万级并行没有任何优势。什么时候用并行元素数量在千万级、分组键数量比较少、且后续没有排序等串行操作时才可能收益明显。大多数业务场景老老实实用串行流就好不要把parallelStream()当成万金油。5.3 比较器方向写反为什么最大值变成了最小值Map.Entry.comparingByValue()默认是升序。max()配合升序比较器拿到的是最后一个元素正好是最大值。很多人在这一步脑子一热想着要求最大值是不是应该先reversed()一下结果一反转max()拿到的是反转后最大的也就是原始最小的正好反了。记住两条铁律想取最大值直接max(Map.Entry.comparingByValue())不要反转。想取 Top N 降序列表排序时才用comparingByValue().reversed()。我自己在这上面栽过一次统计当月销售额最高的门店结果输出的是销售额最低的那家。查了半天才发现比较器多写了一个reversed()。这种问题编译器不报错逻辑也通纯粹是语义错误排查起来最费时间。5.4 HashMap 无序为什么直方图遍历顺序不稳定groupingBy()默认返回HashMap而 HashMap 的遍历顺序取决于 key 的哈希值以及扩容状态不保证任何顺序。你以为按字母应该排好序结果输出顺序杂乱无章。如果你依赖直方图的展示顺序最好的办法是显式指定 Map 类型// 按 key 自然顺序 .collect(Collectors.groupingBy(Function.identity(), TreeMap::new, Collectors.counting())) // 按插入顺序如果希望保持元素的第一次出现顺序 .collect(Collectors.groupingBy(Function.identity(), LinkedHashMap::new, Collectors.counting()))LinkedHashMap保持插入顺序适合按照业务遍历顺序展示直方图的需求TreeMap保持 key 排序适合按字典序或数字大小展示。别指望默认实现能给出稳定顺序。5.5 Optional 误用直接 get() 的隐患前文提到maxBy()返回的 value 类型是Optional。如果你只想要最大值对应的元素很多人贪图方便直接.get()一旦流为空就抛NoSuchElementException。正确的处理方式MapString, Order maxOrderByCity orders.stream() .collect(Collectors.groupingBy( Order::getCity, Collectors.collectingAndThen( Collectors.maxBy(Comparator.comparingDouble(Order::getAmount)), optional - optional.orElse(null) ) ));collectingAndThen()可以把结果从OptionalOrder转成Order或任意你需要的类型。我习惯在最后一步用orElse(null)兜底或者orElseThrow(() - new RuntimeException(该分组没有数据))看业务场景决定。绝不要在流处理链路里埋Optional.get()那是给未来的自己埋雷。5.6 高频面试追问直方图的输出顺序与性能如何权衡面试官问groupingBy()时常常会追问两个角度。第一输出顺序为什么不稳定这就是我要讲的 Map 类型问题回答默认 HashMap 无序可以传 TreeMap 或 LinkedHashMap 改变策略就能过关。第二性能怎么看这个问题要从两个层面回答内存占用和 CPU 消耗。groupingBy()必然要把所有元素加载到流中再生成一个 Map所以空间复杂度是 O(n)不可能低于这个下限时间复杂度接近 O(n)因为每次插入 HashMap 平均是 O(1)。如果数据量超过千万建议考虑分治处理或直接用数据库的 GROUP BY而不是硬扛到 JVM 里算。结尾一些个人经验最后分享一个我自己常用的工程化套路。每次用groupingBy()构建直方图之前我会先问自己三个问题分组键有没有可能是 null直方图的遍历顺序有没有要求最终要输出全部直方图还是只关心 Top N这三个问题想清楚代码基本不会返工。比如分组键可能为 null就提前在分类器里做兜底遍历顺序有要求就提前决定用 TreeMap 还是 LinkedHashMap只要 Top N就直接走 sort limit 链路避免先输出完整 Map 再过滤。还有一个习惯性操作在写统计代码时把Collectors.counting()、Collectors.summingDouble()、Collectors.maxBy()这类下游收集器单独拎出来思考而不是一股脑堆在一起。groupingBy()的第一参数决定分几组第二参数决定每组算啥——想清楚这两层职责再复杂的统计需求也能拆成清晰的 Stream 链路。这样写出来的代码自己三个月后能看懂同事接手也少骂两句。
返回列表