1. Overview

Since the introduction of Java 8, working with streams of data has become common. Often, these streams contain complex structures like maps, which can pose a challenge when processing them further.

In this tutorial, we’ll explore how to flatten a stream of maps into a single map.

2. Introduction to the Problem

Before diving into the solution, let’s clarify what we mean by “flattening a stream of maps.” Essentially, we want to transform a stream of maps into a single map containing all the key-value pairs from each map in the stream.

As usual, an example can help us understand the problem quickly. Let’s say we have three maps storing associations between player names and scores:

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);
}};

Our input is a stream that contains these maps. For simplicity, we’ll use Stream.of(playerMap1, playerMap2 , …) to build up the input stream in this tutorial. However, it’s worth noting a stream doesn’t necessarily have a defined encounter order.

Now, we aim to merge a stream containing the above three maps into one name-score map:

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

It’s worth mentioning that since we’re working with HashMap objects, the entry order in the final result isn’t guaranteed.

Moreover, maps in the stream may contain duplicate keys and null values. Later, we’ll extend the example to cover these scenarios in this tutorial.

Next, let’s dive into the code.

3. Using flatMap() and Collectors.toMap()

One way to merge the maps is to use the flatMap() method and the toMap() collector:

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);

In the code above, the flatMap() method flattens each map in the stream into a stream of its entries. Then, we employ the toMap() collector to collect the stream’s elements into a single map.

The toMap() collector requires two functions as arguments: one to extract keys (Map.Entry::getKey) and one to extract values (Map.Entry::getValue). Here, we use method references to represent the two functions. These functions are applied to each entry in the stream to construct the resulting map.

4. Handling Duplicate Keys

We’ve learned how to merge a stream of HashMaps into one single map using the toMap() collector. However, this approach will fail if the map stream contains duplicate keys. For example, if we add a new map with duplicate key “Kai” into the stream, it throws an IllegalStateException:

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)");

To solve the duplicate keys issue, we can pass a merge function to the toMap() method as the third parameter to handle the values associated with the duplicate keys.

We may have different merge requirements for the duplicate keys scenario. In our example, we would like to pick the higher score once a duplicate name occurs. Therefore, we aim to get this map as the result:

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

Next, let’s see how to achieve it:

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);

As demonstrated in the code, we used the method reference Integer::max as the merge function within toMap(). This ensures that when duplicate keys occur, the resulting value in the final map will be the larger of the two values associated with those keys.

5. Handling null Values

We’ve seen Collectors.toMap() is convenient to collect entries into a single map. However, the Collectors.toMap() method cannot handle null as map’s value. Our solution throws NullPointerException if any map entry’s value is null.

Let’s add a new map to verify that:

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)));

Now, the maps in the input stream contain duplicate keys and null values. This time, we still want a higher score for duplicate player names. Further, we treat null as the lowest score. Then, our expected result looks like:

Map<String, Integer> expectedMap = new HashMap<String, Integer>() {{
    put("Saajan", 35);
    put("Liam", 100);
    put("Kai", 92); // <- max of 92, 76, and null
    put("Eric", 42);
    put("Kevin", 77);
    put("Jerry", null);
}};

Since Integer.max() cannot handle null values, let’s create a null-safe method to get the larger value from two nullable Integer objects:

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

Next, let’s solve this problem.

5.1. Using flatMap() and forEach()

A straightforward method to solve this problem is first initializing an empty map and then put() required key-value pairs into it within 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. Using groupingBy()mapping(), and reducing()

The flatMap() + forEach() solution is straightforward. However, it’s not a functional approach and requires us to write some boilerplate merging logic.

Alternatively, *we can combine the groupingBy(), the mapping(), and the reducing() collectors to solve it functionally*:

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);

As the code above shows, we combined three collectors in the collect() method. Next, let’s quickly understand how they worked together:

  • groupingBy(Map.Entry::getKey, mapping(…)) – Group map entries by their keys to get the key -> Entries structure, and those Entries go to mapping()
  • mapping(Map.Entry::getValue, reducing(…)) – Downstream collector that maps each Entry to Integer using Map.Entry::getValue and hands over Integer values to another downstream collector reducing()
  • reducing(null, this::maxInteger) – Downstream collector to apply the logic of reducing the Integer values for duplicate keys by executing the maxInteger function, which returns the maximum of two integer values

6. Convert List of Maps to a Single Map in Java

In Java, various methods are available for converting a list of maps into a single map, each suited to different preferences and coding styles. Let’s explore two approaches: one employing a for loop and the other using Java Streams.

While these methods efficiently merge maps, it’s important to note that they overwrite entries with duplicate keys. This behavior should be considered in scenarios where preserving existing values for duplicate keys is necessary.

6.1. Using Simple For Loop

A common approach is to iterate through each map in the list using a simple for loop. Within each iteration, we’ll use the putAll() method to insert all key-value mappings from the current map into the resulting single map.

Below is an example of how this approach can be implemented:

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;
}

This method offers direct control over the iteration and map manipulation, which enhances its clarity and ease of comprehension.

6.2. Using Java Streams

with the introduction of Java 8 and its support for streams, a more expressive solution has become available. Using streams, we can seamlessly convert a list of maps into a single map. This process involves streaming the list, flattening the entries of each map, and then collecting them into a single map using the Collectors.toMap() method.

Below demonstrates how to implement this stream-based solution:

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));
}

Leveraging the functional programming paradigm, this stream-based approach provides an elegant and efficient solution while ensuring readability and maintainability.

7. Conclusion

In this article, we delved into merging a stream of maps in Java and presented several methods to handle various scenarios, including merging maps containing duplicate keys and gracefully handling null values.

As always, the examples in this article are available over on GitHub.