1. 嵌套 groupingBy:语法与工作原理
在实际业务中,很少只按一个维度分组就足够了。例如,若你有一份公司员工列表,通常不止想知道各部门有多少人,还想知道各部门中各个职位各有多少人。再比如学生库——想知道每个年级下各专业有多少学生。
嵌套分组可以构建这种“图中之图”的层级结构——例如“部门 → 职位 → 员工列表”,或“年级 → 专业 → 学生列表”。
没有 Stream API,这类任务通常要写多层嵌套循环并手动在 Map 里套 Map。借助流与收集器,往往一两行就能完成。
所谓嵌套分组,是指把另一个收集器(例如再来一个 groupingBy)作为方法 Collectors.groupingBy 的第二个参数。最终得到的 Map 中,每个键对应的值还是一张 Map。
通用模板
Map<Key1, Map<Klyuch2, List<T>>> result =
stream.collect(Collectors.groupingBy(
object -> klyuch1,
Collectors.groupingBy(object -> klyuch2)
));
示例:按部门和职位分组员工
假设我们有这样一个类:
class Employee {
private String name;
private String department;
private String position;
private int salary;
// ... 构造器、getter、toString
}
员工列表:
List<Employee> employees = List.of(
new Employee("Ivan", "IT", "Developer", 120_000),
new Employee("Mariya", "IT", "Tester", 90_000),
new Employee("Pyotr", "HR", "Manager", 80_000),
new Employee("Ol’ga", "IT", "Developer", 130_000),
new Employee("Svetlana", "HR", "Recruiter", 70_000)
);
按部门与职位进行分组:
Map<String, Map<String, List<Employee>>> grouped = employees.stream()
.collect(Collectors.groupingBy(
Employee::getDepartment,
Collectors.groupingBy(Employee::getPosition)
));
我们得到了什么?
一张 Map:键为部门,值为另一张 Map(其键为职位,值为该职位的员工列表)。
结构可视化
IT:
Developer: [Ivan, Ol’ga]
Tester: [Mariya]
HR:
Manager: [Pyotr]
Recruiter: [Svetlana]
2. 分组并聚合:组合 groupingBy 与聚合器
嵌套分组并不只是在每个组里收集列表!你也可以在每个子组内直接做聚合。
示例:按部门求最高工资
Map<String, Optional<Employee>> maxSalaryByDept = employees.stream()
.collect(Collectors.groupingBy(
Employee::getDepartment,
Collectors.maxBy(Comparator.comparingInt(Employee::getSalary))
));
这里我们为每个部门得到工资最高的员工(结果包在 Optional 中,因为某个部门可能为空)。
嵌套分组 + 聚合
例如,想要知道每个部门中各职位的最高工资员工:
Map<String, Map<String, Optional<Employee>>> maxSalaryByDeptAndPos = employees.stream()
.collect(Collectors.groupingBy(
Employee::getDepartment,
Collectors.groupingBy(
Employee::getPosition,
Collectors.maxBy(Comparator.comparingInt(Employee::getSalary))
)
));
这意味着什么?
- 对于每个部门——一张职位的 Map。
- 对于每个职位——工资最高的员工(或一个空的 Optional,如果没有人)。
3. 分组并转换:mapping 在 groupingBy 中
有时我们不仅要分组,还只想保留组内的某些字段。
示例:按部门收集员工姓名
Map<String, List<String>> namesByDept = employees.stream()
.collect(Collectors.groupingBy(
Employee::getDepartment,
Collectors.mapping(Employee::getName, Collectors.toList())
));
结果:
IT: [Ivan, Mariya, Ol’ga]
HR: [Pyotr, Svetlana]
嵌套的 mapping
可以把 mapping 与嵌套的 groupingBy 组合起来:
Map<String, Map<String, List<String>>> namesByDeptAndPos = employees.stream()
.collect(Collectors.groupingBy(
Employee::getDepartment,
Collectors.groupingBy(
Employee::getPosition,
Collectors.mapping(Employee::getName, Collectors.toList())
)
));
结果:
IT:
Developer: [Ivan, Ol’ga]
Tester: [Mariya]
HR:
Manager: [Pyotr]
Recruiter: [Svetlana]
4. 分组 + 数值聚合
很多时候不仅要分组,还要在组内计算总和、平均值或数量。
示例:按部门计算平均工资
Map<String, Double> avgSalaryByDept = employees.stream()
.collect(Collectors.groupingBy(
Employee::getDepartment,
Collectors.averagingInt(Employee::getSalary)
));
嵌套聚合
每个部门中,各职位的平均工资:
Map<String, Map<String, Double>> avgSalaryByDeptAndPos = employees.stream()
.collect(Collectors.groupingBy(
Employee::getDepartment,
Collectors.groupingBy(
Employee::getPosition,
Collectors.averagingInt(Employee::getSalary)
)
));
5. partitioningBy + 聚合
有时按布尔条件把集合分成两组更方便,并在组内继续聚合。
示例:每个部门中工资高于 100_000 的员工数量
Map<String, Map<Boolean, Long>> countByDeptAndSalary = employees.stream()
.collect(Collectors.groupingBy(
Employee::getDepartment,
Collectors.partitioningBy(
e -> e.getSalary() > 100_000,
Collectors.counting()
)
));
结果:
对每个部门得到一张地图:true/false → 员工数量。
6. 实践练习:应用嵌套分组
练习 1:按年级与专业分组学生
class Student {
private String name;
private int course;
private String speciality;
private double grade;
// ... getter、构造器
}
List<Student> students = ... // 假设已经存在
Map<Integer, Map<String, List<Student>>> byCourseAndSpec = students.stream()
.collect(Collectors.groupingBy(
Student::getCourse,
Collectors.groupingBy(Student::getSpeciality)
));
练习 2:按年级计算平均分
Map<Integer, Double> avgGradeByCourse = students.stream()
.collect(Collectors.groupingBy(
Student::getCourse,
Collectors.averagingDouble(Student::getGrade)
));
练习 3:只保留分组后的姓名
Map<Integer, Map<String, List<String>>> namesByCourseAndSpec = students.stream()
.collect(Collectors.groupingBy(
Student::getCourse,
Collectors.groupingBy(
Student::getSpeciality,
Collectors.mapping(Student::getName, Collectors.toList())
)
));
7. 一些实用细节
如何读取并从嵌套 Map 中取数
刚开始操作嵌套 Map 可能不太习惯。下面是一个基础示例:
for (var deptEntry : grouped.entrySet()) {
String dept = deptEntry.getKey();
Map<String, List<Employee>> byPosition = deptEntry.getValue();
System.out.println("部门:" + dept);
for (var posEntry : byPosition.entrySet()) {
String pos = posEntry.getKey();
List<Employee> emps = posEntry.getValue();
System.out.println(" 职位:" + pos + " -> " + emps);
}
}
嵌套分组示意图
Map<Department, Map<Position, List<Employee>>>
│
├── "IT"
│ ├── "Developer" → [Ivan, Ol’ga]
│ └── "Tester" → [Mariya]
└── "HR"
├── "Manager" → [Pyotr]
└── "Recruiter" → [Svetlana]
表格:不同组合的产出类型
| 收集器 | 结果 |
|---|---|
|
|
|
|
|
|
|
|
|
|
8. 使用嵌套分组时的常见错误
错误 1:误解嵌套 Map 的结构。
做完嵌套分组后,很容易搞不清每层 Map 的值到底是什么。务必查看结果类型的签名——IDE 会提示类型。如果不确定,可以用 System.out.println(grouped) 打印出来,或者使用调试器。
错误 2:取数时出现 NullPointerException。
当键不存在(例如某部门没有某个职位的员工)时,Map.get(key) 会返回 null。请先用 containsKey 检查,或者从 Java 8 起可以使用 Map.getOrDefault,从 Java 9 起——Map.ofNullable 以及 Optional 的相关方法。
错误 3:嵌套层级过深。
如果分组层级过深(3–4 层),也许应重新审视数据结构,或把任务拆分成更小的步骤。
错误 4:把聚合器放在了错误的层级。
有时会把聚合收集器(averagingInt、counting)放在了不该放的 groupingBy 外层,导致结果出乎意料。一定要留意括号与层级!
错误 5:在 collect 过程中修改元素。
不要在分组过程中修改原集合或对象——这可能导致难以察觉的 Bug。
GO TO FULL VERSION