Stream API分组统计操作指南:从入门到实战
目录导读
- 什么是Stream API分组统计?
- 分组统计的核心方法:Collectors.groupingBy()
- 基础分组操作:根据单个字段分组
- 多级分组:GroupingBy嵌套实现
- 分组后统计:计数、求和、平均值
- 自定义分组条件:使用Lambda表达式
- 分组与汇总结合:下游收集器详解
- 实际案例:电商订单分组统计
- 注意事项与性能优化
什么是Stream API分组统计?
在Java 8引入的Stream API中,分组统计是指将集合中的元素按照某个或多个属性进行分类,并对每个分组进行聚合计算(如计数、求和、求平均值等)的操作,它类似于SQL中的GROUP BY语句,但使用函数式编程风格,代码更简洁、可读性更强。

问:Stream分组统计与传统的for循环分组相比,优势在哪里? 答:Stream API利用内部迭代和并行流机制,代码量减少约60%,且天然支持多线程并行处理,在大数据量场景下性能提升显著,更重要的是,分组统计通过声明式编程让业务逻辑更加清晰。
分组统计的核心方法:Collectors.groupingBy()
Collectors.groupingBy()是分组统计的"发动机",它接收一个分类函数,返回一个Map,其中键是分组类别,值是属于该组的元素列表。
基本语法:
Map<K, List<T>> map = list.stream().collect(Collectors.groupingBy(T::getField));
K:分组键的类型T:集合元素的类型T::getField:提取分组字段的方法引用
基础分组操作:根据单个字段分组
假设我们有一个Student类,包含name、grade(年级)和score属性,现在需要按年级分组:
List<Student> students = Arrays.asList(
new Student("张三", 1, 85),
new Student("李四", 2, 92),
new Student("王五", 1, 78),
new Student("赵六", 3, 88)
);
Map<Integer, List<Student>> groupByGrade = students.stream()
.collect(Collectors.groupingBy(Student::getGrade));
// 输出结果
// {1=[张三(85), 王五(78)], 2=[李四(92)], 3=[赵六(88)]}
问:如果分组字段为null会怎样? 答:默认情况会抛出NullPointerException,建议使用
groupingBy(keyMapper, HashMap::new, downstream)重载方法,或者确保分组字段非空。
多级分组:GroupingBy嵌套实现
有时需要同时按多个维度分组,例如先按年级分组,再按成绩等级分组:
Map<Integer, Map<String, List<Student>>> multiGroup = students.stream()
.collect(Collectors.groupingBy(
Student::getGrade,
Collectors.groupingBy(student -> student.getScore() >= 90 ? "优秀" : "一般")
));
// 输出结构:{1:{一般=[张三, 王五]}, 2:{优秀=[李四]}, 3:{一般=[赵六]}}
这里内层的groupingBy就是一个下游收集器(downstream collector),它负责对每个分组内的元素进行二次分组。
分组后统计:计数、求和、平均值
分组统计的精髓在于下游汇总,通过配合不同的Collectors,可以实现丰富的统计功能。
1 统计每个组的人数
Map<Integer, Long> countByGrade = students.stream()
.collect(Collectors.groupingBy(
Student::getGrade,
Collectors.counting()
));
// {1=2, 2=1, 3=1}
2 计算每组的总分
Map<Integer, Integer> sumByGrade = students.stream()
.collect(Collectors.groupingBy(
Student::getGrade,
Collectors.summingInt(Student::getScore)
));
// {1=163, 2=92, 3=88}
3 计算每组的平均分
Map<Integer, Double> avgByGrade = students.stream()
.collect(Collectors.groupingBy(
Student::getGrade,
Collectors.averagingDouble(Student::getScore)
));
// {1=81.5, 2=92.0, 3=88.0}
4 同时获取多项统计值
Map<Integer, IntSummaryStatistics> stats = students.stream()
.collect(Collectors.groupingBy(
Student::getGrade,
Collectors.summarizingInt(Student::getScore)
));
// 每个分组都包含count、sum、min、max、average
问:
Collectors.summarizingInt()返回的IntSummaryStatistics能直接获取最大值吗? 答:可以,通过stats.get(1).getMax()即可获得年级1的最高分。
自定义分组条件:使用Lambda表达式
当分组逻辑不依赖于对象自身的getter时,可以使用Lambda自定义分组键:
// 按分数段分组:<60不及格,60-80良好,>80优秀
Map<String, List<Student>> scoreLevel = students.stream()
.collect(Collectors.groupingBy(s -> {
if (s.getScore() < 60) return "不及格";
else if (s.getScore() <= 80) return "良好";
else return "优秀";
}));
或者使用更复杂的条件,例如按名字长度分组:
Map<Integer, List<Student>> byNameLength = students.stream()
.collect(Collectors.groupingBy(s -> s.getName().length()));
分组与汇总结合:下游收集器详解
groupingBy的完整签名为:
groupingBy(Function<? super T, ? extends K> classifier,
Collector<? super T, A, D> downstream)
常见下游收集器组合:
| 下游收集器 | 功能 | 示例 |
|---|---|---|
toList() |
默认行为,返回List | 分组后获取列表 |
toSet() |
去重分组 | 去除组内重复元素 |
counting() |
计数 | 每组元素个数 |
summingInt() |
求和 | 每组某个字段总和 |
averagingDouble() |
平均值 | 每组某字段平均值 |
maxBy() |
最大值 | 每组记录中最大元素 |
minBy() |
最小值 | 每组记录中最小元素 |
mapping() |
先转换再收集 | 提取特定字段到新集合 |
示例:找出每组分数最高的学生
Map<Integer, Optional<Student>> topStudent = students.stream()
.collect(Collectors.groupingBy(
Student::getGrade,
Collectors.maxBy(Comparator.comparing(Student::getScore))
));
示例:将每组的学生姓名收集为列表
Map<Integer, List<String>> nameByGrade = students.stream()
.collect(Collectors.groupingBy(
Student::getGrade,
Collectors.mapping(Student::getName, Collectors.toList())
));
// {1=[张三, 王五], 2=[李四], 3=[赵六]}
实际案例:电商订单分组统计
假设我们有一个电商系统,需要统计每个用户的订单汇总信息:
public class Order {
private Long userId;
private String category;
private BigDecimal amount;
private LocalDateTime createTime;
}
List<Order> orders = getOrders(); // 获取订单数据
// 需求1:统计每个用户的订单总金额和订单数量
Map<Long, Map<String, Object>> userStats = orders.stream()
.collect(Collectors.groupingBy(
Order::getUserId,
Collectors.collectingAndThen(
Collectors.toList(),
list -> {
BigDecimal totalAmount = list.stream()
.map(Order::getAmount)
.reduce(BigDecimal.ZERO, BigDecimal::add);
return Map.of("count", list.size(), "totalAmount", totalAmount);
}
)
));
// 需求2:统计每个商品类别的销售金额占比
Map<String, BigDecimal> categorySales = orders.stream()
.collect(Collectors.groupingBy(
Order::getCategory,
Collectors.mapping(
Order::getAmount,
Collectors.reducing(BigDecimal.ZERO, BigDecimal::add)
)
));
BigDecimal totalSales = categorySales.values().stream()
.reduce(BigDecimal.ZERO, BigDecimal::add);
// 计算占比
Map<String, Double> percentage = categorySales.entrySet().stream()
.collect(Collectors.toMap(
Map.Entry::getKey,
e -> e.getValue().divide(totalSales, 4, RoundingMode.HALF_UP).doubleValue()
));
问:在分组统计时,如果数据集很大(百万级),如何优化? 答:1)使用
parallelStream()开启并行流 2)手动指定HashMap初始容量减少扩容 3)使用groupingByConcurrent()支持并发 4)避免在Lambda内进行重量级I/O操作。
注意事项与性能优化
常见陷阱
- Null分组键:如果分组字段可能为null,使用
groupingBy重载方法传入Supplier指定Map类型。 - 未排序的Map:默认返回的是
HashMap,不保证顺序,如需有序分组,使用groupingBy(classifier, TreeMap::new, downstream)。 - 并行流线程安全:并行流分组建议使用
groupingByConcurrent()。
性能优化建议
// 优化前:默认HashMap可能频繁扩容
Map<Integer, List<Student>> result = students.stream()
.collect(Collectors.groupingBy(Student::getGrade));
// 优化后:预估容量,减少rehash
int estimatedSize = students.size() / 10 + 1; // 假设平均每组10人
Map<Integer, List<Student>> result = students.stream()
.collect(Collectors.groupingBy(
Student::getGrade,
() -> new HashMap<>(estimatedSize),
Collectors.toList()
));
与SQL的对比
| 操作 | Stream API | SQL |
|---|---|---|
| 分组 | groupingBy() |
GROUP BY |
| 计数 | counting() |
COUNT(*) |
| 求和 | summingInt() |
SUM() |
| 最大值 | maxBy() |
MAX() |
| 多级分组 | 嵌套groupingBy |
GROUP BY a, b |
Stream API的分组统计功能通过函数式编程,将复杂的数据分组和聚合任务变得优雅高效,掌握groupingBy与各种下游收集器的组合,能让你在处理集合数据时事半功倍,建议在实际开发中,先用简单分组验证逻辑,再逐步添加统计汇总,最后根据数据量进行必要的性能优化。
关键点回顾:
groupingBy(keyMapper)是分组基础- 下游收集器决定分组后做什么
counting()summingInt()averagingDouble()是常用统计- 自定义分组条件用Lambda表达式
- 大数据量考虑并行流和Map容量优化