🌊 Section 8 · Question #27

How to collect Stream into Map

Collecting elements from a Java Stream into a java.util.Map is primarily accomplished through two built-in collectors:


🟢 Junior Level

30-Second Summary

Collecting elements from a Java Stream into a java.util.Map is primarily accomplished through two built-in collectors:

  1. Collectors.toMap(): Used for one-to-one ($1 \to 1$) projections, where each stream element corresponds to exactly one Map.Entry<K, V>. If duplicate keys exist without a provided merge function, it throws an IllegalStateException.
  2. Collectors.groupingBy(): Used for one-to-many ($1 \to N$) mappings, grouping elements that share a common classification key into a Map<K, List<V>>.

Java 21 Code Examples

import java.util.List;
import java.util.Map;
import java.util.function.Function;
import java.util.stream.Collectors;

public class StreamToMapDemo {

    public record User(int id, String name, String department) {}

    public static void main(String[] args) {
        List<User> users = List.of(
            new User(1, "Alex", "IT"),
            new User(2, "Maria", "HR"),
            new User(3, "Ivan", "IT")
        );

        // 1. Basic toMap: ID -> Name
        Map<Integer, String> idToName = users.stream()
            .collect(Collectors.toMap(User::id, User::name));
        System.out.println("ID -> Name: " + idToName);

        // 2. Mapping to self via Function.identity(): ID -> User
        Map<Integer, User> idToUser = users.stream()
            .collect(Collectors.toMap(User::id, Function.identity()));
        System.out.println("User IDs in map: " + idToUser.keySet());

        // 3. Grouping: Department -> List<User>
        Map<String, List<User>> byDept = users.stream()
            .collect(Collectors.groupingBy(User::department));
        System.out.println("IT Department count: " + byDept.get("IT").size());
    }
}

🟡 Middle Level

The 3 Overloads of Collectors.toMap()

Overload Signature Arguments Duplicate Key Behavior Resulting Map Implementation
2 Arguments keyMapper, valueMapper 💥 Throws IllegalStateException HashMap (default)
3 Arguments keyMapper, valueMapper, mergeFunction ✅ Resolves via binary merge operator HashMap (default)
4 Arguments keyMapper, valueMapper, mergeFunction, mapFactory ✅ Resolves via binary merge operator Factory-supplied (e.g. LinkedHashMap, TreeMap)

Collision Resolution and Ordering Examples

import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
import java.util.stream.Collectors;

public class ToMapAdvancedDemo {

    public record Book(String isbn, String title, int year) {}

    public static void main(String[] args) {
        List<Book> books = List.of(
            new Book("123", "Java Core v1", 2020),
            new Book("123", "Java Core v2", 2023), // Duplicate ISBN!
            new Book("456", "Spring Boot", 2022)
        );

        // 1. With mergeFunction: on collision, keep the newer edition
        Map<String, Book> latestBooks = books.stream()
            .collect(Collectors.toMap(
                Book::isbn,
                b -> b,
                (existing, replacement) -> replacement.year() > existing.year() ? replacement : existing
            ));
        System.out.println("ISBN 123 edition: " + latestBooks.get("123").title()); // Java Core v2

        // 2. With mapFactory: preserve stream insertion order via LinkedHashMap
        Map<String, String> orderedMap = books.stream()
            .collect(Collectors.toMap(
                Book::isbn,
                Book::title,
                (oldVal, newVal) -> newVal,
                LinkedHashMap::new
            ));

        // 3. With mapFactory: sort entries naturally by key using TreeMap
        Map<String, String> sortedMap = books.stream()
            .collect(Collectors.toMap(
                Book::isbn,
                Book::title,
                (oldVal, newVal) -> newVal,
                TreeMap::new
            ));
    }
}

🔴 Senior Level

The NullPointerException Hazard on null Values

Even though a standard HashMap permits null values, Collectors.toMap() throws a NullPointerException if valueMapper returns null:

List<User> list = List.of(new User(1, null, "IT"));

// 💥 CRASH: Throws NullPointerException at runtime!
Map<Integer, String> map = list.stream()
    .collect(Collectors.toMap(User::id, User::name));

Root Cause in OpenJDK:

Inside Collectors.toMap(), element accumulation is delegated to Map.merge():

// OpenJDK Map.java default implementation:
default V merge(K key, V value, BiFunction<? super V, ? super V, ? extends V> remappingFunction) {
    Objects.requireNonNull(remappingFunction);
    Objects.requireNonNull(value); // <-- THROWS NPE HERE!
    ...
}

In the Map.merge() specification, passing a null value signifies the removal of that key. Hence, null values are explicitly disallowed.

Senior Production Solutions:

// Option 1: Mutable reduction directly into HashMap (allows nulls):
Map<Integer, String> safeMap = list.stream()
    .collect(HashMap::new, (m, u) -> m.put(u.id(), u.name()), HashMap::putAll);

// Option 2: Wrapping nullable values in Optional:
Map<Integer, Optional<String>> optMap = list.stream()
    .collect(Collectors.toMap(User::id, u -> Optional.ofNullable(u.name())));

Architectural Optimizations: EnumMap and Capacity Pre-sizing

  1. Enum Key Optimization: When keys are Java enum constants, standard HashMap incurs memory overhead from Node instances and hashing. An EnumMap uses a flat array indexed by ordinal ($O(1)$) with minimal heap footprint:
    Map<Role, List<User>> usersByRole = users.stream()
        .collect(Collectors.groupingBy(
            User::role,
            () -> new EnumMap<>(Role.class),
            Collectors.toList()
        ));
    
  2. Pre-sizing to Avoid Rehashing: When collecting large datasets of known size $N$:
    int initialCapacity = (int) Math.ceil(users.size() / 0.75f) + 1;
    Map<Integer, User> preSized = users.stream()
        .collect(Collectors.toMap(
            User::id,
            Function.identity(),
            (a, b) -> a,
            () -> new HashMap<>(initialCapacity)
        ));
    

4 Tricky Questions

1. Why does Collectors.toMap() throw a NullPointerException if a mapped value is null, even though HashMap permits null values?

Answer: Under the hood, OpenJDK’s Collectors.toMap() accumulates entries using Map.merge(k, v, mergeFunction).

In the Map.merge() contract, a null value is reserved as an indicator to remove the existing mapping from the map. Because of this contract, Map.merge() explicitly enforces Objects.requireNonNull(value). When valueMapper.apply(element) evaluates to null, this precondition fails, resulting in an immediate NullPointerException.


2. What happens if duplicate keys are encountered when using the two-argument Collectors.toMap(keyMapper, valueMapper)?

Answer: The collector throws an IllegalStateException with a message formatted as: Duplicate key <key> (attempted merging values <v1> and <v2>).

The two-argument overload supplies an internal merge function throwingMerger() that always throws an IllegalStateException. To safely handle collections where duplicate keys may arise, you must use the three- or four-argument overload and provide an explicit BinaryOperator merge function (e.g., (existing, replacement) -> replacement).


3. How does Collectors.toMap() differ from Collectors.toConcurrentMap() in a parallelStream()?

Answer:

  • Collectors.toMap(): In a parallel stream, each worker thread constructs its own independent map. At the completion of each subtask, the combiner() function merges these maps via Map.putAll(), incurring memory allocations and copying overhead.
  • Collectors.toConcurrentMap(): Has the Characteristics.CONCURRENT flag. The stream engine instantiates a single shared ConcurrentHashMap. All worker threads concurrently invoke thread-safe put or merge calls directly into this shared instance, bypassing the combiner phase entirely.

4. How can you ensure that encounter/insertion order is preserved when collecting a stream into a Map?

Answer: By default, the two- and three-argument overloads of Collectors.toMap() use HashMap::new, which does not guarantee any iteration order.

To preserve encounter order, use the four-argument overload and supply a supplier that constructs a LinkedHashMap:

stream.collect(Collectors.toMap(
    keyMapper,
    valueMapper,
    (oldVal, newVal) -> newVal,
    LinkedHashMap::new
));

🎯 Interview Cheat Sheet

toMap Overload Signature Matrix

| Arguments | Method Signature | Collision Behavior | Target Container | | :— | :— | :— | :— | | 2 Args | .toMap(keyMapper, valueMapper) | Throws IllegalStateException | HashMap | | 3 Args | .toMap(keyMapper, valueMapper, mergeFn) | Custom resolution | HashMap | | 4 Args | .toMap(keyMapper, valueMapper, mergeFn, mapFactory) | Custom resolution | Custom (e.g., LinkedHashMap, TreeMap) |


Key Takeaways

  • 1-to-1 Mapping: Use toMap() for unique key-to-value associations; use groupingBy() for 1-to-many aggregations.
  • Mandatory Merge Function: Always specify a merge function when data sources may contain duplicate keys.
  • Null Value Trap: toMap() forbids null values because of Map.merge().
  • Order Preservation: Supply LinkedHashMap::new to preserve insertion order.

Red Flags & Anti-Patterns to Avoid

  • ❌ Omitting the merge function on dynamic data (APIs, databases): Vulnerable to sudden IllegalStateException on unexpected duplicate keys.
  • ❌ Returning null from valueMapper in toMap(): Crashes immediately with NullPointerException.
  • ❌ Using toMap() instead of groupingBy() for 1-to-many data: Fails to aggregate multiple values per key.
  • ❌ Using standard HashMap for enum keys: Incurs unnecessary hashing overhead; use EnumMap.