🌊 Раздел 8 · Вопрос #27

Как собрать Stream в Map

Собрать Stream в Map можно двумя основными коллекторами:


🟢 Junior Level

Краткий ответ для собеседования (30 секунд)

Собрать Stream в Map можно двумя основными коллекторами:

  1. Collectors.toMap() — используется для отображения «один-к-одному» ($1 \to 1$), где каждый элемент стрима проецируется ровно в одну запись Map.Entry<K, V>. Если в стриме встречаются дублирующиеся ключи без указания функции разрешения коллизий (merge function), метод выбрасывает исключение IllegalStateException.
  2. Collectors.groupingBy() — используется для отображения «один-ко-многим» ($1 \to N$), группируя элементы по общему ключу классификатора в структуру Map<K, List<V>>.

Примеры использования на Java 21

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, "Алексей", "IT"),
            new User(2, "Мария", "HR"),
            new User(3, "Иван", "IT")
        );

        // 1. Базовая сборка тоMap: ID -> Name
        Map<Integer, String> idToName = users.stream()
            .collect(Collectors.toMap(User::id, User::name));
        System.out.println("ID -> Имя: " + idToName);

        // 2. Сборка объекта целиком через Function.identity(): ID -> User
        Map<Integer, User> idToUser = users.stream()
            .collect(Collectors.toMap(User::id, Function.identity()));
        System.out.println("ID -> Объект: " + idToUser.keySet());

        // 3. Группировка: Department -> List<User>
        Map<String, List<User>> byDept = users.stream()
            .collect(Collectors.groupingBy(User::department));
        System.out.println("IT отдел: " + byDept.get("IT").size() + " чел.");
    }
}

🟡 Middle Level

3 перегрузки Collectors.toMap()

Перегрузка Аргументы Поведение при коллизии ключей Тип возвращаемой Map
2 аргумента keyMapper, valueMapper 💥 Выбрасывает IllegalStateException HashMap (по умолчанию)
3 аргумента keyMapper, valueMapper, mergeFunction ✅ Вызывает функцию слияния HashMap (по умолчанию)
4 аргумента keyMapper, valueMapper, mergeFunction, mapFactory ✅ Вызывает функцию слияния Заданный фабрикой (например, TreeMap::new, LinkedHashMap::new)

Примеры разрешения коллизий и сохранения порядка:

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), // Дубликат ISBN!
            new Book("456", "Spring Boot", 2022)
        );

        // 1. С mergeFunction: при дубликате оставляем более свежую книгу (новейший год)
        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 версия: " + latestBooks.get("123").title()); // Java Core v2

        // 2. С фабрикой: сбор в LinkedHashMap для сохранения порядка вставки
        Map<String, String> orderedMap = books.stream()
            .collect(Collectors.toMap(
                Book::isbn,
                Book::title,
                (oldVal, newVal) -> newVal,
                LinkedHashMap::new
            ));

        // 3. Сборка в TreeMap для автоматической сортировки по ключам:
        Map<String, String> sortedMap = books.stream()
            .collect(Collectors.toMap(
                Book::isbn,
                Book::title,
                (oldVal, newVal) -> newVal,
                TreeMap::new
            ));
    }
}

🔴 Senior Level

Ловушка NullPointerException при value == null

Один из самых коварных багов в Java Stream API:

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

// 💥 Выбросит NullPointerException, хотя обычная HashMap разрешает null!
Map<Integer, String> map = list.stream()
    .collect(Collectors.toMap(User::id, User::name));

Почему это происходит:

В исходном коде OpenJDK метод Collectors.toMap() реализован через вызов Map.merge(key, value, mergeFunction):

// Исходный код Map.java:
default V merge(K key, V value, BiFunction<? super V, ? super V, ? extends V> remappingFunction) {
    Objects.requireNonNull(remappingFunction);
    Objects.requireNonNull(value); // <-- ЗДЕСЬ ВЫБРАСЫВАЕТСЯ NPE!
    ...
}

Метод Map.merge считает значение null индикатором удаления записи из мапы, поэтому прямо запрещает передачу null.

Решение для Senior: Если значения могут быть null:

// Способ 1: Сборка через mutable collect напрямую в HashMap
Map<Integer, String> safeMap = list.stream()
    .collect(HashMap::new, (m, u) -> m.put(u.id(), u.name()), HashMap::putAll);

// Способ 2: Обертывание в Optional
Map<Integer, Optional<String>> optMap = list.stream()
    .collect(Collectors.toMap(User::id, u -> Optional.ofNullable(u.name())));

Архитектурные оптимизации: EnumMap и предварительное выделение емкости

  1. Оптимизация для Enum-ключей: Если ключом является Enum, обычная HashMap тратит лишнюю память на объекты Node и вычисление хешей. Использование EnumMap дает прямой доступ по массиву ординалов ($O(1)$) с минимальным memory footprint:
    Map<Role, List<User>> usersByRole = users.stream()
        .collect(Collectors.groupingBy(User::role, () -> new EnumMap<>(Role.class), Collectors.toList()));
    
  2. Предотвращение рехеширования (Presizing HashMap): При известном размере стрима $N$:
    int capacity = (int) Math.ceil(list.size() / 0.75f) + 1;
    Map<Integer, User> preSized = list.stream()
        .collect(Collectors.toMap(User::id, Function.identity(), (a, b) -> a, () -> new HashMap<>(capacity)));
    

🎯 Шпаргалка для интервью

30-секундный ответ

«Собрать Stream в Map можно через Collectors.toMap() (отображение 1-в-1) или Collectors.groupingBy() (отображение 1-ко-многим). Базовый toMap(key, value) при дубликате ключа бросает IllegalStateException, поэтому в реальном коде всегда следует передавать 3-й аргумент mergeFunction ((v1, v2) -> v2). Для контроля типа мапы (например, LinkedHashMap или TreeMap) используется 4-я перегрузка с mapFactory. Главная ловушка toMap: он использует Map.merge(), который категорически запрещает значения null и бросает NullPointerException.»


Сводная таблица параметров toMap

Количество параметров Пример вызова Назначение
2 параметра .toMap(User::id, User::name) Быстрый сбор уникальных ключей; падает при дубликатах
3 параметра .toMap(User::id, User::name, (o, n) -> n) Production-стандарт: безопасное слияние дубликатов
4 параметра .toMap(k, v, merge, LinkedHashMap::new) Сохранение порядка вставки элементов
4 параметра .toMap(k, v, merge, TreeMap::new) Автоматическая сортировка записей по ключам

Топ-4 каверзных вопроса на интервью

1. Почему Collectors.toMap() выбрасывает NullPointerException, если valueMapper возвращает null, даже если целевая HashMap разрешает null?

  • Внутри реализации Collectors.toMap() аккумуляция элементов происходит через метод Map.merge(k, v, mergeFunction). Спецификация метода Map.merge явно требует Objects.requireNonNull(value), так как в контракте Map.merge передача null означает удаление ключа из таблицы.

2. Что произойдет при дублировании ключа в Collectors.toMap(keyMapper, valueMapper)?

  • Будет выброшено исключение IllegalStateException: Duplicate key <key> (attempted merging values <v1> and <v2>). Для предотвращения ошибки необходимо использовать перегрузку с тремя аргументами, передав BinaryOperator<U> mergeFunction.

3. В чем разница между Collectors.toMap() и Collectors.toConcurrentMap() в parallelStream()?

  • toMap в параллельном стриме создает независимые мапы в каждом потоке и затем объединяет их через Map.putAll() в дереве ForkJoinPool.
  • toConcurrentMap() имеет характеристику CONCURRENT: рантайм создает один единственный экземпляр ConcurrentHashMap, и все потоки пишут в него напрямую без фазы слияния через combiner.

4. Как сохранить порядок элементов при сборке стрима в Map?

  • По умолчанию Collectors.toMap() использует стандартную HashMap, которая не сохраняет порядок обхода. Чтобы сохранить порядок вставки (Encounter Order), нужно использовать 4-параметрическую перегрузку с фабрикой: Collectors.toMap(keyMapper, valueMapper, mergeFunction, LinkedHashMap::new).

Типичные ошибки и Red Flags

  • 🚩 Забывание mergeFunction при сборе данных из внешних API или БД (приводит к падению сервиса с IllegalStateException).
  • 🚩 Возврат null из лямбды valueMapper в toMap() (немедленный NPE).
  • 🚩 Попытка использовать toMap() вместо groupingBy(), когда одному ключу должно соответствовать несколько значений.
  • 🚩 Использование обычной HashMap вместо EnumMap, когда ключи являются элементами enum.

Связанные темы