1. 概述

自Java 8引入流式处理以来,处理数据流已成为常见操作。这些流中常包含Map等复杂结构,给后续处理带来挑战。

本文将探讨如何将Map流扁平化为单个Map,合并所有键值对。

2. 问题引入

首先明确"扁平化Map流"的含义:将流中的多个Map合并为一个包含所有键值对的单个Map。

通过示例快速理解问题。假设有三个存储玩家名称与分数的Map:

Map<String, Integer> playerMap1 = new HashMap<String, Integer>() {{
    put("Kai", 92);
    put("Liam", 100);
}};
Map<String, Integer> playerMap2 = new HashMap<String, Integer>() {{
    put("Eric", 42);
    put("Kevin", 77);
}};
Map<String, Integer> playerMap3 = new HashMap<String, Integer>() {{
    put("Saajan", 35);
}};

输入流包含这些Map(本文使用Stream.of()构建)。注意:流没有固定的遍历顺序

目标是将包含三个Map的流合并为一个名称-分数Map:

Map<String, Integer> expectedMap = new HashMap<String, Integer>() {{
    put("Saajan", 35);
    put("Liam", 100);
    put("Kai", 92);
    put("Eric", 42);
    put("Kevin", 77);
}};

注意:由于使用HashMap,最终结果的条目顺序不保证

此外,流中的Map可能包含重复键和null值,后续将扩展示例覆盖这些场景。

3. 使用flatMap和Collectors.toMap

合并Map的一种方法是使用flatMap()toMap()收集器:

Map<String, Integer> mergedMap = Stream.of(playerMap1, playerMap2, playerMap3)
  .flatMap(map -> map.entrySet()
    .stream())
  .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue));

assertEquals(expectedMap, mergedMap);

flatMap()将流中的每个Map扁平化为条目流
toMap()收集器将条目流收集为单个Map
✅ 使用方法引用提取键(Map.Entry::getKey)和值(Map.Entry::getValue)

4. 处理重复键

前述方法在遇到重复键时会失败。例如添加包含重复键"Kai"的新Map:

Map<String, Integer> playerMap4 = new HashMap<String, Integer>() {{
    put("Kai", 76);
}};

assertThrows(IllegalStateException.class, () -> Stream.of(playerMap1, playerMap2, playerMap3, playerMap4)
  .flatMap(map -> map.entrySet()
    .stream())
  .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)), "Duplicate key Kai (attempted merging values 92 and 76)");

⚠️ 抛出IllegalStateException:重复键Kai(尝试合并值92和76)

解决方案:为toMap()传递合并函数作为第三个参数,处理重复键的值。本例要求保留较高分数

Map<String, Integer> expectedMap = new HashMap<String, Integer>() {{
    put("Saajan", 35);
    put("Liam", 100);
    put("Kai", 92); // <- 92和76中的最大值
    put("Eric", 42);
    put("Kevin", 77);
}};

实现代码:

Map<String, Integer> mergedMap = Stream.of(playerMap1, playerMap2, playerMap3, playerMap4)
  .flatMap(map -> map.entrySet()
    .stream())
  .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue, Integer::max));
 
assertEquals(expectedMap, mergedMap);

✅ 使用Integer::max作为合并函数
✅ 重复键时保留较大值

5. 处理null值

Collectors.toMap()无法处理值为null的情况,会抛出NullPointerException。验证如下:

Map<String, Integer> playerMap5 = new HashMap<String, Integer>() {{
    put("Kai", null);
    put("Jerry", null);
}};

assertThrows(NullPointerException.class, () -> Stream.of(playerMap1, playerMap2, playerMap3, playerMap4, playerMap5)
  .flatMap(map -> map.entrySet()
    .stream())
  .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue, Integer::max)));

❌ 抛出NullPointerException

需求

  • 重复键仍保留较高分数
  • null视为最低分数

期望结果:

Map<String, Integer> expectedMap = new HashMap<String, Integer>() {{
    put("Saajan", 35);
    put("Liam", 100);
    put("Kai", 92); // <- 92, 76和null中的最大值
    put("Eric", 42);
    put("Kevin", 77);
    put("Jerry", null);
}};

由于Integer.max()无法处理null,创建空安全方法:

private Integer maxInteger(Integer int1, Integer int2) {
    if (int1 == null) {
        return int2;
    }
    if (int2 == null) {
        return int1;
    }
    return max(int1, int2);
}

5.1. 使用flatMap和forEach

简单粗暴的解决方案:初始化空Map,用forEach()合并键值对:

Map<String, Integer> mergedMap = new HashMap<>();
Stream.of(playerMap1, playerMap2, playerMap3, playerMap4, playerMap5)
  .flatMap(map -> map.entrySet()
    .stream())
  .forEach(entry -> {
      String k = entry.getKey();
      Integer v = entry.getValue();
      if (mergedMap.containsKey(k)) {
          mergedMap.put(k, maxInteger(mergedMap.get(k), v));
      } else {
          mergedMap.put(k, v);
      }
    });
assertEquals(expectedMap, mergedMap);

✅ 直观易理解
❌ 非函数式风格,需手写合并逻辑

5.2. 使用groupingBy、mapping和reducing

函数式解决方案:组合三个收集器:

Map<String, Integer> mergedMap = Stream.of(playerMap1, playerMap2, playerMap3, playerMap4, playerMap5)
  .flatMap(map -> map.entrySet()
    .stream())
  .collect(groupingBy(Map.Entry::getKey, mapping(Map.Entry::getValue, reducing(null, this::maxInteger))));
 
assertEquals(expectedMap, mergedMap);

工作原理:

  1. groupingBy(Map.Entry::getKey, mapping(...))
    → 按键分组,形成key -> Entries结构,将Entries传递给mapping()
  2. mapping(Map.Entry::getValue, reducing(...))
    → 下游收集器:将每个Entry映射为Integer,传递给reducing()
  3. reducing(null, this::maxInteger)
    → 下游收集器:对重复键的值执行maxInteger函数,返回最大值

✅ 纯函数式风格
✅ 无需手写合并逻辑

6. 将Map列表转换为单个Map

在Java中,将Map列表合并为单个Map有多种方法。注意:这些方法会覆盖重复键的条目

6.1. 使用简单for循环

传统方法:遍历列表,用putAll()合并:

public static <K, V> Map<K, V> mergeMapsUsingLoop(List<Map<K, V>> listOfMaps) {
    Map<K, V> result = new HashMap<>();
    for (Map<K, V> map : listOfMaps) {
        result.putAll(map);
    }
    return result;
}

✅ 直接控制迭代和合并逻辑
✅ 代码清晰易读

6.2. 使用Java Streams

Java 8+的流式处理方案:

public static <K, V> Map<K, V> mergeMapsUsingStream(List<Map<K, V>> listOfMaps) {
    return listOfMaps.stream()
      .flatMap(map -> map.entrySet().stream())
      .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue, (v1, v2) -> v2));
}

✅ 函数式风格
✅ 简洁优雅
✅ 重复键时保留后出现的值((v1, v2) -> v2

7. 结论

本文探讨了在Java中合并Map流的多种方法,包括:

  • 基础合并方案
  • 处理重复键的技巧
  • 安全处理null值的策略
  • Map列表的转换方法

不同场景可灵活选择合适方案,完整示例代码可在GitHub获取。


原始标题:Flatten a Stream of Maps to a Single Map in Java | Baeldung