做 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,有则 value+1,没有则 put 初始值 1。逻辑不难,但写起来又臭又长,而且很容易漏掉 null 判断。用groupingBy()之后,一行就能拿到 Map 结构的统计结果,这才是真正解放生产力的地方。
1.2 groupingBy() 的底层逻辑:分类器、Map 与下游收集器
groupingBy()是java.util.stream.Collectors里的静态方法,它的核心工作分三步:
- 遍历流中的每个元素。
- 用你传入的"分类器"(Classifier,本质上是一个
Function<T, K>)算出每个元素的分组键 K。 - 把分组键相同的元素汇聚到同一个集合里,最终得到一个
Map<K, V>。
这里最关键的是参数设计。groupingBy()有三个重载版本:
// 版本一:只传分类器,返回 Map<K, List<T>>,同一个键下的所有元素组成 List static <T, K> Collector<T, ?, Map<K, List<T>>> groupingBy(Function<? super T, ? extends K> classifier) // 版本二:额外传一个下游收集器,分组后的集合再交给下游做二次处理 static <T, K, A, D> Collector<T, ?, Map<K, 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 Map<K, D>> Collector<T, ?, M> groupingBy(Function<? super T, ? extends K> classifier, Supplier<M> mapFactory, Collector<? super T, A, D> downstream)构建直方图,最常用的组合是"分类器 + 下游收集器counting()"。counting()会统计分组内元素个数,最终返回Map<K, Long>,其中 Long 就是频次。
我建议你把groupingBy()理解成一个流水线:上游是元素流,分类器决定每个元素进哪条轨道,下游收集器决定每条轨道最终留下什么结果。这个心智模型一旦建立,后面所有变体都能一眼看懂。
1.3 对比传统写法:Stream 方案好在哪
当年我用 for 循环写频次统计时,代码长这样:
Map<String, 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 API:
Map<String, 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) { List<String> words = List.of( "java", "stream", "api", "java", "grouping", "stream", "api", "java", "collector", "grouping", "java" ); Map<String, Long> histogram = words.stream() .collect(Collectors.groupingBy(Function.identity(), Collectors.counting())); System.out.println(histogram); // 输出(顺序可能不同):{stream=2, grouping=2, api=2, collector=1, java=4} } }这里Function.identity()是"把元素本身作为分组键",等价于word -> word。统计结果中,java出现了 4 次,stream等各出现 2 次,这就是一张标准的频次直方图。
如果不想统计整个单词,而是统计"单词首字母"的分布,只要换分类器:
Map<Character, Long> firstLetterHistogram = words.stream() .filter(w -> !w.isEmpty()) .collect(Collectors.groupingBy(w -> w.charAt(0), Collectors.counting())); System.out.println(firstLetterHistogram); // 输出示例:{s=2, a=2, g=2, c=1, j=4}看到没有,整个思路的弹性就在分类器上。你可以按对象的某个字段分组,按字符串长度分组,按下单时间的小时字段分组,组合非常灵活。
2.2 排序版:如何得到一个按键排序的直方图
基础版返回的是HashMap,遍历顺序不稳定。如果你需要按照分组键顺序展示直方图——比如按年龄段从小到大,按字母从 A 到 Z——就得换一个能排序的 Map。
groupingBy()的第三个重载版本允许你传入一个Supplier<M>来指定 Map 的实现类。最常见的做法是传TreeMap::new:
import java.util.TreeMap; Map<String, Long> sortedHistogram = words.stream() .collect(Collectors.groupingBy(Function.identity(), TreeMap::new, Collectors.counting())); System.out.println(sortedHistogram); // 输出:{api=2, collector=1, grouping=2, java=4, stream=2}输出顺序严格按 key 的自然顺序排列。如果分组键是自定义对象,想让 TreeMap 按照你指定的规则排序,可以传入一个带自定义 Comparator 的 TreeMap 工厂:
Map<Integer, Long> histogramByLength = words.stream() .collect(Collectors.groupingBy( String::length, () -> new TreeMap<>(Comparator.reverseOrder()), Collectors.counting() )); System.out.println(histogramByLength); // 输出(按长度降序):{8=2, 7=2, 6=2, 4=3}这里我用String::length把单词按长度分组,然后让 TreeMap 按长度倒序排列。注意,TreeMap::new这种写法只适用于键是自然可排序的类型;一旦你传了一个 Comparator,必须用 lambda 形式的() -> new TreeMap<>(comparator),否则编译都不给你过。
2.3 多级分组:按两个维度同时构建直方图
业务上经常遇到"既要按城市,又要按用户等级"这种多维度统计。groupingBy()可以嵌套使用,外层分组结果的值是内层分组的结果,最终形成两级 Map:
class Order { private String city; private String userLevel; private double amount; // 构造方法、getter 省略 } List<Order> 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) ); Map<String, Map<String, Long>> twoLevelHistogram = orders.stream() .collect(Collectors.groupingBy( Order::getCity, Collectors.groupingBy(Order::getUserLevel, Collectors.counting()) )); System.out.println(twoLevelHistogram); // 输出:{上海={普通=1, VIP=2}, 北京={普通=1, VIP=1}}两层分组后的 Map 已经能支撑绝大多数报表需求。再往上嵌套第三层也不是不行,但可读性会断崖式下降,我一般到两层就止步。真需要三层以上,更合适的做法是先把多个维度的字段拼成一个复合键对象,或者直接定义一个统计用的 DTO 类,在collect之前先 map 转换一次。
比如把"城市 + 用户等级"拼成 key:
Map<String, Long> compositeHistogram = orders.stream() .collect(Collectors.groupingBy( o -> o.getCity() + "|" + o.getUserLevel(), Collectors.counting() )); System.out.println(compositeHistogram); // 输出:{北京|普通=1, 上海|VIP=2, 北京|VIP=1, 上海|普通=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; Map<String, Integer> cityInstallBase = Map.of( "北京", 320, "上海", 410, "广州", 220, "深圳", 380 ); // 把分组之后的值(Integer)做比较,取出最大值对应的分组 Map<String, Optional<Integer>> result = cityInstallBase.entrySet().stream() .collect(Collectors.groupingBy( Map.Entry::getKey, Collectors.mapping(Map.Entry::getValue, Collectors.maxBy(Comparator.naturalOrder())) ));不过说实话,这个写法有点绕。我通常更愿意直接在一开始的数据上操作,而不是先转成 Map 再二次处理。
3.2 方案二:对直方图再求 max:Collections / Stream 二选一
假设我们已经拿到了直方图Map<String, Long>,要取频次最高的那条记录,最直观的方式是把 entrySet 变成流,再用max():
Map<String, Long> histogram = words.stream() .collect(Collectors.groupingBy(Function.identity(), Collectors.counting())); Map.Entry<String, 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。
如果你不想用 Stream,Collections.max()也能办到:
Map.Entry<String, 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,再排序 List<Map.Entry<String, Long>> entryList = new ArrayList<>(histogram.entrySet()); entryList.sort(Map.Entry.<String, Long>comparingByValue().reversed()); List<Map.Entry<String, Long>> top3 = entryList.stream().limit(3).toList();// 思路二:直接用 Stream 链路 sort + limit List<Map.Entry<String, Long>> top3 = histogram.entrySet().stream() .sorted(Map.Entry.<String, Long>comparingByValue().reversed()) .limit(3) .toList();第二种写法更紧凑。注意comparingByValue().reversed()这里有个经典陷阱:如果直接写Map.Entry.comparingByValue().reversed(),泛型推断有时候会失败,建议加上显式的类型见证<String, Long>,也就是我上面写的样子,不然 IDEA 可能会编译报错。
如果要输出"频次最高且并列多个",用filter过滤出所有与最大值相等的 entry:
long maxValue = maxEntry.getValue(); List<Map.Entry<String, 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 面试高频变体:怎样同时拿到最大值和它对应的分组键
面试里经常出现一个变体:给你一个Map<String, Integer>,找出 value 最大的 key。乍看很简单,但很多人第一步就绕进去了——先遍历这个 Map 找到最大 value,再遍历一次找对应 key。两步遍历效率低,代码也啰嗦。
一次遍历就能搞定:
Map<String, Integer> scoreMap = new HashMap<>(); scoreMap.put("张三", 88); scoreMap.put("李四", 95); scoreMap.put("王五", 73); Map.Entry<String, 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; } } List<Order> orders = List.of( new Order("上海", 100.0), new Order("北京", 200.0), new Order("上海", 150.0), new Order("北京", 80.0) ); Map<String, Double> totalByCity = orders.stream() .collect(Collectors.groupingBy( Order::getCity, Collectors.summingDouble(Order::getAmount) )); Map<String, Double> avgByCity = orders.stream() .collect(Collectors.groupingBy( Order::getCity, Collectors.averagingDouble(Order::getAmount) )); Map<String, Optional<Order>> maxOrderByCity = orders.stream() .collect(Collectors.groupingBy( Order::getCity, Collectors.maxBy(Comparator.comparingDouble(Order::getAmount)) ));summingDouble得到每个城市的总销售额,averagingDouble得到平均客单价,maxBy把金额最大的订单对象取出来。注意最后那个 Map 的 value 类型是Optional<Order>,因为空分组时没有元素可供比较,这是 Collectors 的设计约定,不是 Bug。
很多教程在这里就停了,但我实际项目里更常用的是teeing()——一次分组同时统计多个指标。Java 12 引入的teeing()可以把两个收集器合并成单个结果,配合groupingBy()简直绝配:
Map<String, Map<String, 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); // 输出:{上海={total=250.0, avg=125.0}, 北京={total=280.0, avg=140.0}}一次性拿到总和与平均值,不用再对同一个流做两次groupingBy()。JDK 版本允许的话,我强烈建议试试。
4.2 分组后直接排序并截取前几名
回到"提取最大值"的场景,如果最大值只是开始,排名才是终点,那就没必要先构造出整个 Map 再排序。直接在流上完成:
List<Map.Entry<String, Long>> top2 = words.stream() .collect(Collectors.groupingBy(Function.identity(), Collectors.counting())) .entrySet() .stream() .sorted(Map.Entry.<String, Long>comparingByValue().reversed()) .limit(2) .toList();这是先用groupingBy()统计,再对 entrySet 流排序取前二。要注意的是整个过程会被 JVM 拆成两段流水线,第一段生成 Map,第二段排序,中间没有多少优化空间。不过对于百万量级以下的数据,这种写法完全够用,可读性远胜手写排序循环。
如果你用的是 Java 8,没有toList(),那就用collect(Collectors.toList()),效果一样,少一点语法糖而已。
5. 常见问题与避坑实录
5.1 空分组键:null 到底能不能作为 key
HashMap允许 null 键,所以groupingBy()遇到分类器返回 null 时,默认的 HashMap 实现能正常存进去。但如果你用了TreeMap::new,情况就不一样了——TreeMap 不允许 null key,直接抛NullPointerException。
举一个真实场景。统计订单按优惠券 ID 分组,但很多订单没有使用优惠券,优惠券 ID 是 null。用默认的groupingBy()没问题,统计结果里会出现{null=123};但一旦你为了排序换成 TreeMap,就会在 collect 阶段爆异常。
解决办法有两个,我按推荐程度排序:
// 方案 A:在分类器里把 null 替换成占位符 Map<String, Long> safeHistogram = orders.stream() .collect(Collectors.groupingBy( o -> o.getCouponId() == null ? "NO_COUPON" : o.getCouponId(), TreeMap::new, Collectors.counting() )); // 方案 B:先过滤掉空值(仅当业务允许丢弃这些数据时) Map<String, 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()大约 300ms,parallelStream()反而到了 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。
正确的处理方式:
Map<String, Order> maxOrderByCity = orders.stream() .collect(Collectors.groupingBy( Order::getCity, Collectors.collectingAndThen( Collectors.maxBy(Comparator.comparingDouble(Order::getAmount)), optional -> optional.orElse(null) ) ));collectingAndThen()可以把结果从Optional<Order>转成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 链路。这样写出来的代码,自己三个月后能看懂,同事接手也少骂两句。