Java Stream分组案例如何分组数据

wen java案例 24

Java Stream 分组案例:如何高效分组数据?从入门到实战

目录导读

  • 什么是 Stream 分组?为什么需要它?
  • Stream 分组的核心方法:Collectors.groupingBy
  • 基础分组案例:按单个字段分组
  • 多级分组:按多个条件嵌套分组
  • 自定义分组:使用自定义键与复杂逻辑
  • 分组后的聚合操作:计数、求和、取最大/最小
  • 常见问答与避坑指南
  • 分组的最佳实践

什么是 Stream 分组?为什么需要它?

在 Java 开发中,我们经常需要对集合数据进行“按类别归类”。

Java Stream分组案例如何分组数据

  • 将订单按用户 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 "老年";
        }));

或者,直接使用 TuplePair 作为复合键:

// 按性别+城市分组(自定义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)
                )
        ));

常见问答与避坑指南

Q1groupingBy 返回的 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:并行流中分组是否线程安全?
AgroupingBy 本身是线程安全的(内部使用 ConcurrentHashMap 特殊实现),但若使用自定义 mapFactory 需注意线程安全。

Q4:分组键重复时,值会不会覆盖?
A:不会,分组是聚合行为,相同 key 的元素会合并到同一个值列表(或下游结果)中,这与 toMap 不同,toMap 默认在 key 重复时会抛异常。


分组的最佳实践

  1. 明确分组粒度:尽量使用最简单直接的键(字段或枚举),避免复杂对象作为 Key(注意 equals/hashCode 实现)。
  2. 善用下游收集器:统计时优先使用 counting, summingInt, averagingDouble 而非手动累加。
  3. 多级分组谨慎使用:超过 3 级嵌套建议拆分成多个步骤或使用辅助类。
  4. 考虑性能:数据量极大时(数百万条),分组会生成较多中间对象,可考虑并行流或使用传统 HashMap 手动分组。
  5. 结合 Optional 处理:当分组结果可能为空时,使用 collectingAndThen 提前转换。

使用 Stream 分组并非炫技,而是真正能提升代码可读性、减少 bug 的实战技巧,掌握 groupingBy,你的 Java 集合处理能力将迈上一个新台阶。

抱歉,评论功能暂时关闭!