Java Stream 分组案例:如何高效分组数据?从入门到实战
目录导读
- 什么是 Stream 分组?为什么需要它?
- Stream 分组的核心方法:
Collectors.groupingBy - 基础分组案例:按单个字段分组
- 多级分组:按多个条件嵌套分组
- 自定义分组:使用自定义键与复杂逻辑
- 分组后的聚合操作:计数、求和、取最大/最小
- 常见问答与避坑指南
- 分组的最佳实践
什么是 Stream 分组?为什么需要它?
在 Java 开发中,我们经常需要对集合数据进行“按类别归类”。

- 将订单按用户 ID 分组
- 将学生按班级分组
- 将商品按类别统计销量
传统方式需要手动循环、创建 Map、判断键是否存在等操作,代码冗余且易出错,而 Java Stream 的 Collectors.groupingBy 提供了一种 声明式、链式、线程安全 的分组方案,只需一行核心代码即可完成。
Stream 分组的核心方法:Collectors.groupingBy
方法签名(三种常见重载):
// 1. 基本分组:按分类器分组,值默认为 List public static <T, K> Collector<T, ?, Map<K, List<T>>> groupingBy(Function<? super T, ? extends K> classifier) // 2. 分组后自定义下游收集器(如计数、求和、转为Set) public static <T, K, A, D> Collector<T, ?, Map<K, D>> groupingBy(Function<? super T, ? extends K> classifier, Collector<? super T, A, D> downstream) // 3. 分组后指定Map类型(如 TreeMap 保证排序) public 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)
核心思路:
classifier:定义分组的键(Key),通常为一个字段或自定义逻辑downstream:对每组内的元素进行进一步操作,默认是toList()mapFactory:指定返回的 Map 类型(如 TreeMap, LinkedHashMap)
基础分组案例:按单个字段分组
场景:有一个 Student 类,字段为 name, classId, score。
需求:将所有学生按班级分组成 Map<Integer, List<Student>>。
import java.util.*;
import java.util.stream.Collectors;
public class GroupingByExample {
public static void main(String[] args) {
List<Student> students = Arrays.asList(
new Student("Alice", 1, 85),
new Student("Bob", 1, 90),
new Student("Charlie", 2, 78),
new Student("David", 2, 88),
new Student("Eve", 3, 92)
);
// 按班级分组
Map<Integer, List<Student>> groupByClass = students.stream()
.collect(Collectors.groupingBy(Student::getClassId));
System.out.println(groupByClass);
}
}
输出效果:
{1=[Alice, Bob], 2=[Charlie, David], 3=[Eve]}
问答环节
Q:如果分组键为 null,会怎样?
A:会抛出 NullPointerException,建议先使用 filter() 过滤 null,或分组时使用 Objects::nonNull 保护。
多级分组:按多个条件嵌套分组
需求变更:先按班级分组,再按成绩等级分组(优秀 >= 90,良好 >= 80,及格 < 80)。
Map<Integer, Map<String, List<Student>>> multiGroup = students.stream()
.collect(Collectors.groupingBy(
Student::getClassId,
Collectors.groupingBy(student -> {
if (student.getScore() >= 90) return "优秀";
else if (student.getScore() >= 80) return "良好";
else return "及格";
})
));
结果示例:
{1={良好=[Bob? 实际上Bob 90分应归为优秀]}, 2={良好=[David], 及格=[Charlie]}, 3={优秀=[Eve]}}
(注意:需要根据实际分数修正)
关键点:外层 groupingBy 产生第一级 Map,内层 groupingBy 产生第二级 Map,形成嵌套结构。
自定义分组:使用自定义键与复杂逻辑
分组键不一定是简单字段,也可以是根据业务逻辑动态生成的对象。
案例:按年龄区间分组(<18 未成年,18-60 成年,>60 老年)。
Map<String, List<Person>> ageGroup = people.stream()
.collect(Collectors.groupingBy(person -> {
int age = person.getAge();
if (age < 18) return "未成年";
else if (age <= 60) return "成年";
else return "老年";
}));
或者,直接使用 Tuple 或 Pair 作为复合键:
// 按性别+城市分组(自定义Pair类,或用Map.Entry)
Map<Map.Entry<String, String>, List<Person>> complexGroup = people.stream()
.collect(Collectors.groupingBy(
p -> Map.entry(p.getGender(), p.getCity()) // Java 9+可用
));
分组后的聚合操作:计数、求和、取最大/最小
分组常常不是为了单纯得到元素列表,而是为了统计。
分组计数(统计每组人数)
Map<Integer, Long> countByClass = students.stream()
.collect(Collectors.groupingBy(
Student::getClassId,
Collectors.counting()
));
分组求和
Map<Integer, Integer> sumScoreByClass = students.stream()
.collect(Collectors.groupingBy(
Student::getClassId,
Collectors.summingInt(Student::getScore)
));
分组求平均数
Map<Integer, Double> avgScoreByClass = students.stream()
.collect(Collectors.groupingBy(
Student::getClassId,
Collectors.averagingDouble(Student::getScore)
));
分组求最大/最小值(取每个组最高分学生)
Map<Integer, Optional<Student>> topStudentByClass = students.stream()
.collect(Collectors.groupingBy(
Student::getClassId,
Collectors.maxBy(Comparator.comparingInt(Student::getScore))
));
注意:maxBy 返回 Optional,建议用 collectingAndThen 去除 Optional:
Map<Integer, Student> topStudentMap = students.stream()
.collect(Collectors.groupingBy(
Student::getClassId,
Collectors.collectingAndThen(
Collectors.maxBy(Comparator.comparingInt(Student::getScore)),
opt -> opt.orElse(null)
)
));
常见问答与避坑指南
Q1:groupingBy 返回的 Map 是无序的,如何保证顺序?
A:使用 groupingBy(key, TreeMap::new, downstream) 或 groupingBy(key, LinkedHashMap::new, downstream)。
Q2:分组后值类型必须是 List 吗?
A:不是,可以通过下游收集器转为 Set、Map 或自定义集合。
// 转为 Set 去重
Map<Integer, Set<Student>> groupBySet = students.stream()
.collect(Collectors.groupingBy(Student::getClassId, Collectors.toSet()));
Q3:并行流中分组是否线程安全?
A:groupingBy 本身是线程安全的(内部使用 ConcurrentHashMap 特殊实现),但若使用自定义 mapFactory 需注意线程安全。
Q4:分组键重复时,值会不会覆盖?
A:不会,分组是聚合行为,相同 key 的元素会合并到同一个值列表(或下游结果)中,这与 toMap 不同,toMap 默认在 key 重复时会抛异常。
分组的最佳实践
- 明确分组粒度:尽量使用最简单直接的键(字段或枚举),避免复杂对象作为 Key(注意 equals/hashCode 实现)。
- 善用下游收集器:统计时优先使用
counting,summingInt,averagingDouble而非手动累加。 - 多级分组谨慎使用:超过 3 级嵌套建议拆分成多个步骤或使用辅助类。
- 考虑性能:数据量极大时(数百万条),分组会生成较多中间对象,可考虑并行流或使用传统 HashMap 手动分组。
- 结合 Optional 处理:当分组结果可能为空时,使用
collectingAndThen提前转换。
使用 Stream 分组并非炫技,而是真正能提升代码可读性、减少 bug 的实战技巧,掌握 groupingBy,你的 Java 集合处理能力将迈上一个新台阶。