1. 引言

本教程将通过多个实例深入探讨 groupingBy 收集器的工作原理。要充分理解本文内容,你需要具备 Java 8 基础知识,建议先了解 Java 8 Stream 入门Java 8 收集器指南

2. groupingBy 收集器

Java 8 的 Stream API 让我们能够以声明式方式处理数据集合。Collectors.groupingBy()Collectors.groupingByConcurrent() 这两个静态工厂方法,提供了类似 SQL 中 GROUP BY 子句的功能。我们通常用它们按某个属性对对象进行分组,并将结果存储在 Map 实例中

groupingBy 的重载方法包括:

  1. 仅包含分类函数

    static <T,K> Collector<T,?,Map<K,List<T>>> 
      groupingBy(Function<? super T,? extends K> classifier)
    
  2. 包含分类函数和下游收集器

    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 供应函数和下游收集器

    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)
    

2.1 示例代码准备

为演示 groupingBy() 的用法,先定义 BlogPost 类(后续将使用 BlogPost 对象流):

class BlogPost {
    String title;
    String author;
    BlogPostType type;
    int likes;
}

接着定义 BlogPostType 枚举:

enum BlogPostType {
    NEWS,
    REVIEW,
    GUIDE
}

然后创建 BlogPost 对象列表:

List<BlogPost> posts = Arrays.asList( ... );

再定义一个 Tuple 类,用于按 typeauthor 属性组合分组:

class Tuple {
    BlogPostType type;
    String author;
}

2.2 按单列简单分组

从最简单的 groupingBy 方法开始,它仅接受一个分类函数作为参数。该函数会应用于流中的每个元素,返回值将作为结果 Map 的键

按博客文章的 type 分组:

Map<BlogPostType, List<BlogPost>> postsPerType = posts.stream()
  .collect(groupingBy(BlogPost::getType));

2.3 复杂 Map 键类型的分组

分类函数的返回值不限于标量或字符串,结果 Map 的键可以是任何对象,只要正确实现 equalshashCode 方法。

使用 Pair 类实现多字段分组(推荐 javafx.utilorg.apache.commons.lang3.tuple 包):

Map<Pair<BlogPostType, String>, List<BlogPost>> postsPerTypeAndAuthor = posts.stream()
  .collect(groupingBy(post -> new ImmutablePair<>(post.getType(), post.getAuthor())));

使用自定义 Tuple 类(更灵活,可扩展更多字段):

Map<Tuple, List<BlogPost>> postsPerTypeAndAuthor = posts.stream()
  .collect(groupingBy(post -> new Tuple(post.getType(), post.getAuthor())));

Java 16+ 使用 record(更简洁安全):

public class BlogPost {
    // ... 其他字段
    record AuthPostTypesLikes(String author, BlogPostType type, int likes) {};
}

Map<BlogPost.AuthPostTypesLikes, List<BlogPost>> postsPerTypeAndAuthor = posts.stream()
  .collect(groupingBy(post -> new BlogPost.AuthPostTypesLikes(post.getAuthor(), post.getType(), post.getLikes())));

2.4 修改返回的 Map 值类型

groupingBy 的第二个重载方法接受一个下游收集器(downstream collector),用于处理第一级分组的结果。若未指定下游收集器,默认使用 toList()

✅ **使用 toSet() 替代默认的 List**:

Map<BlogPostType, Set<BlogPost>> postsPerType = posts.stream()
  .collect(groupingBy(BlogPost::getType, toSet()));

2.5 按多字段分组

通过嵌套使用下游收集器,可实现多级分组。先按 author 分组,再按 type 分组:

Map<String, Map<BlogPostType, List>> map = posts.stream()
  .collect(groupingBy(BlogPost::getAuthor, groupingBy(BlogPost::getType)));

2.6 计算分组结果的平均值

利用下游收集器对分组结果进行聚合操作。计算每种博客文章类型的平均点赞数:

Map<BlogPostType, Double> averageLikesPerType = posts.stream()
  .collect(groupingBy(BlogPost::getType, averagingInt(BlogPost::getLikes)));

2.7 计算分组结果的总和

计算每种类型的点赞总数:

Map<BlogPostType, Integer> likesPerType = posts.stream()
  .collect(groupingBy(BlogPost::getType, summingInt(BlogPost::getLikes)));

2.8 获取分组结果的最大/最小值

获取每种类型中点赞数最多的文章:

Map<BlogPostType, Optional<BlogPost>> maxLikesPerPostType = posts.stream()
  .collect(groupingBy(BlogPost::getType,
  maxBy(comparingInt(BlogPost::getLikes))));

⚠️ 注意maxBy/minBy 返回 Optional 类型,因为分组结果可能为空。

2.9 获取分组属性的统计摘要

使用 summarizingInt 收集器同时计算计数、总和、平均值、最大值和最小值

Map<BlogPostType, IntSummaryStatistics> likeStatisticsPerType = posts.stream()
  .collect(groupingBy(BlogPost::getType, 
  summarizingInt(BlogPost::getLikes)));

2.10 聚合分组结果的多个属性

方案一:使用 collectingAndThen
先添加聚合结果封装类:

public class BlogPost {
    // ...
    record PostCountTitlesLikesStats(long postCount, String titles, IntSummaryStatistics likesStats){};
}

实现多属性聚合:

Map<String, BlogPost.PostCountTitlesLikesStats> postsPerAuthor = posts.stream()
  .collect(groupingBy(BlogPost::getAuthor, collectingAndThen(toList(), list -> {
    long count = list.stream().map(BlogPost::getTitle).collect(counting());
    String titles = list.stream().map(BlogPost::getTitle).collect(joining(" : "));
    IntSummaryStatistics summary = list.stream().collect(summarizingInt(BlogPost::getLikes));
    return new BlogPost.PostCountTitlesLikesStats(count, titles, summary);
  })));

方案二:使用 toMap 实现复杂聚合
定义结果封装类:

public class BlogPost {
    // ...
    record TitlesBoundedSumOfLikes(String titles, int boundedSumOfLikes) {};
}

实现带上限的点赞数聚合:

int maxValLikes = 17;
Map<String, BlogPost.TitlesBoundedSumOfLikes> postsPerAuthor = posts.stream()
  .collect(toMap(BlogPost::getAuthor, post -> {
    int likes = (post.getLikes() > maxValLikes) ? maxValLikes : post.getLikes();
    return new BlogPost.TitlesBoundedSumOfLikes(post.getTitle(), likes);
  }, (u1, u2) -> {
    int likes = (u2.boundedSumOfLikes() > maxValLikes) ? maxValLikes : u2.boundedSumOfLikes();
    return new BlogPost.TitlesBoundedSumOfLikes(
      u1.titles().toUpperCase() + " : " + u2.titles().toUpperCase(), 
      u1.boundedSumOfLikes() + likes
    );
  }));

2.11 将分组结果映射为不同类型

使用 mapping 下游收集器转换分组结果的类型。将每种类型的文章标题拼接为字符串:

Map<BlogPostType, String> postsPerType = posts.stream()
  .collect(groupingBy(BlogPost::getType, 
  mapping(BlogPost::getTitle, joining(", ", "Post titles: [", "]"))));

2.12 修改返回的 Map 类型

默认返回的 Map 类型不确定。若需指定具体实现(如 EnumMap),使用第三个重载方法:

EnumMap<BlogPostType, List<BlogPost>> postsPerType = posts.stream()
  .collect(groupingBy(BlogPost::getType, 
  () -> new EnumMap<>(BlogPostType.class), toList()));

3. 并发分组收集器

groupingByConcurrentgroupingBy 的并发版本,充分利用多核架构。其重载方法与 groupingBy 完全一致,但返回类型必须是 ConcurrentHashMap 或其子类。

⚠️ 使用前提:流必须是并行的(parallelStream()):

ConcurrentMap<BlogPostType, List<BlogPost>> postsPerType = posts.parallelStream()
  .collect(groupingByConcurrent(BlogPost::getType));

4. Java 9 新增功能

Java 9 引入了两个与 groupingBy 配合良好的新收集器,详情见 Java 9 流收集器指南

5. 总结

本文全面探讨了 Java 8 Collectors API 中 groupingBy 收集器的用法。我们学习了如何:

  • 按属性对流元素分类
  • 对分组结果进行收集、转换和聚合
  • 处理复杂分组场景(多级分组、多属性聚合)
  • 控制返回 Map 的类型

完整示例代码可在 GitHub 项目 中获取。


原始标题:Guide to Java 8 groupingBy Collector