Stream API ·
‹ Предыдущий Следующий ›
⏱ 5 минут чтения Обновлено: 2026-09-16

Методы класса Collectors в Java

Попробуйте предсказать вывод этого кода:

Map<Integer, List<Integer>> byDecade = Stream.of(30, 10, 20, 10, 50)
        .collect(Collectors.groupingBy(n -> n / 10));

System.out.println(byDecade); // {1=[10, 10], 2=[20], 3=[30], 5=[50]}

Первым в потоке был ключ 3, а в выводе он третий. Причина в том, что groupingBy() без явной фабрики складывает результат в HashMap, а её порядок обхода не совпадает с порядком элементов в потоке — он зависит от хешей ключей. Нужен предсказуемый порядок — берите трёхаргументную форму: groupingBy(classifier, TreeMap::new, downstream) или LinkedHashMap::new.

java.util.stream.Collectors — утилитный класс с готовыми реализациями интерфейса Collector, которые передаются в терминальный метод Stream.collect(). Коллектор описывает, как накапливать элементы потока в итоговый объект: коллекцию, Map, строку, число или произвольную структуру. Что такое сам метод collect() и как работают базовые Collectors.toList(), toMap() и groupingBy() — разобрано в отдельном уроке «Методы Stream API». Здесь сразу идёт глубина: вложенные коллекторы, фабрики Map, статистика одним проходом и собственный коллектор.

Во всех примерах используется один набор данных:

record Employee(String name, String department, int salary) { }

List<Employee> staff = List.of(
        new Employee("Анна",  "IT",    180_000),
        new Employee("Борис", "IT",    150_000),
        new Employee("Вера",  "HR",    120_000),
        new Employee("Глеб",  "Sales", 140_000),
        new Employee("Дина",  "Sales", 160_000));

1. Коллекторы в коллекцию: toSet, toCollection, неизменяемые

Collectors.toSet() собирает элементы в множество, попутно убирая дубликаты по equals(). Конкретный класс не оговорён контрактом — сейчас это HashSet, поэтому ни порядок, ни изменяемость гарантировать нельзя.

Set<Integer> lengths = Stream.of("sun", "sea", "sand", "surf")
        .map(String::length)
        .collect(Collectors.toSet());
System.out.println(lengths); // [3, 4] - четыре слова, но всего две разные длины

Если нужен конкретный тип коллекции, есть toCollection(Supplier): он принимает ссылку на конструктор и складывает элементы именно туда.

// TreeSet - уникальные значения сразу в отсортированном виде
TreeSet<String> sorted = Stream.of("sun", "sea", "sand", "sea")
        .collect(Collectors.toCollection(TreeSet::new));
System.out.println(sorted); // [sand, sea, sun]

// LinkedList - когда нужны быстрые вставки в начало и конец
LinkedList<String> queue = Stream.of("sun", "sea", "sand")
        .collect(Collectors.toCollection(LinkedList::new));
queue.addFirst("surf");
System.out.println(queue); // [surf, sun, sea, sand]

// ArrayList - если важно получить гарантированно изменяемый список
List<String> mutable = Stream.of("sun", "sea")
        .collect(Collectors.toCollection(ArrayList::new));
mutable.add("sand"); // работает всегда

Неизменяемые коллекторы (Java 10+)

toUnmodifiableList(), toUnmodifiableSet() и toUnmodifiableMap() возвращают неизменяемые коллекции: любая попытка их поменять бросает UnsupportedOperationException.

List<String> names = staff.stream()
        .map(Employee::name)
        .collect(Collectors.toUnmodifiableList());

names.add("Егор"); // java.lang.UnsupportedOperationException

У этих коллекторов есть важная особенность: они не допускают null. Элемент null внутри потока приведёт к NullPointerException прямо во время сборки, тогда как обычный toList() спокойно положит его в список.

Stream.of("sun", null).collect(Collectors.toList());             // [sun, null] - ок
Stream.of("sun", null).collect(Collectors.toUnmodifiableList()); // NullPointerException
Stream.of("sun", null).toList();                                 // [sun, null] - Java 16+, null разрешён

Важно

Javadoc Collectors.toList() не гарантирует ни тип, ни изменяемость результата — сегодня это ArrayList, но полагаться на это нельзя. Если список точно нужно править, пишите toCollection(ArrayList::new); если наоборот нужна защита от изменений — toUnmodifiableList() или короткое stream().toList() из Java 16.

2. toMap(): merge-функция и своя реализация Map

Двухаргументный toMap(keyMapper, valueMapper) прекрасно работает ровно до первого повторяющегося ключа. В нашем наборе данных два сотрудника из отдела IT, поэтому такой код падает:

Map<String, Employee> byDept = staff.stream()
        .collect(Collectors.toMap(Employee::department, e -> e));
// java.lang.IllegalStateException: Duplicate key IT
//   (attempted merging values Employee[name=Анна, ...] and Employee[name=Борис, ...])

Третий аргумент — функция слияния (merge). Она получает старое и новое значение и решает, что останется в карте.

// оставить того, у кого зарплата выше
Map<String, Employee> topByDept = staff.stream()
        .collect(Collectors.toMap(
                Employee::department,
                e -> e,
                (oldValue, newValue) -> oldValue.salary() >= newValue.salary() ? oldValue : newValue));

// сложить зарплаты отдела
Map<String, Integer> payroll = staff.stream()
        .collect(Collectors.toMap(
                Employee::department,
                Employee::salary,
                Integer::sum));
// IT=330000, HR=120000, Sales=300000 (порядок ключей задаёт HashMap)

// оставить первое встреченное значение
(oldValue, newValue) -> oldValue
// перезаписать последним
(oldValue, newValue) -> newValue

Четвёртый аргумент — фабрика Map. Она задаёт конкретную реализацию: TreeMap::new отсортирует по ключу, LinkedHashMap::new сохранит порядок появления, EnumMap подойдёт для ключей-перечислений.

TreeMap<String, Integer> sortedPayroll = staff.stream()
        .collect(Collectors.toMap(
                Employee::department,
                Employee::salary,
                Integer::sum,
                TreeMap::new));
System.out.println(sortedPayroll);            // {HR=120000, IT=330000, Sales=300000}
System.out.println(sortedPayroll.firstKey()); // HR

3. joining(): сборка потока в строку

joining() работает только с потоком CharSequence, поэтому объекты сначала преобразуют в строки через map(). Внутри коллектор использует StringBuilder, поэтому склейка не создаёт лишних промежуточных строк, как наивный reduce("", String::concat).

List<String> names = staff.stream().map(Employee::name).toList();

// без аргументов - просто подряд
names.stream().collect(Collectors.joining());
// АннаБорисВераГлебДина

// с разделителем
names.stream().collect(Collectors.joining(", "));
// Анна, Борис, Вера, Глеб, Дина

// с разделителем, префиксом и суффиксом
names.stream().collect(Collectors.joining(", ", "[", "]"));
// [Анна, Борис, Вера, Глеб, Дина]

// готовый SQL-фрагмент одной строкой
staff.stream()
     .map(Employee::department)
     .distinct()
     .map(d -> "'" + d + "'")
     .collect(Collectors.joining(", ", "WHERE department IN (", ")"));
// WHERE department IN ('IT', 'HR', 'Sales')

На пустом потоке joining(", ", "[", "]") вернёт [] — префикс и суффикс добавляются всегда, даже если склеивать нечего.

4. groupingBy() с downstream-коллектором и фабрикой Map

У groupingBy() три перегрузки, и вся сила в двух последних. Второй (или третий) аргумент — downstream-коллектор, то есть вложенный коллектор, который обрабатывает элементы уже внутри каждой группы.

// 1 аргумент: значения групп - списки объектов
Map<String, List<Employee>> byDept = staff.stream()
        .collect(Collectors.groupingBy(Employee::department));
// IT=[Анна, Борис], HR=[Вера], Sales=[Глеб, Дина] (порядок ключей задаёт HashMap)

// 2 аргумента: сколько человек в отделе
Map<String, Long> headcount = staff.stream()
        .collect(Collectors.groupingBy(Employee::department, Collectors.counting()));
// IT=2, HR=1, Sales=2

// 2 аргумента: фонд оплаты труда по отделам
Map<String, Integer> payroll = staff.stream()
        .collect(Collectors.groupingBy(Employee::department,
                 Collectors.summingInt(Employee::salary)));
// IT=330000, HR=120000, Sales=300000

// 2 аргумента: только имена, а не целые объекты
Map<String, List<String>> namesByDept = staff.stream()
        .collect(Collectors.groupingBy(Employee::department,
                 Collectors.mapping(Employee::name, Collectors.toList())));
// IT=[Анна, Борис], HR=[Вера], Sales=[Глеб, Дина]

// 2 аргумента: уникальные зарплаты в группе
Map<String, Set<Integer>> salariesByDept = staff.stream()
        .collect(Collectors.groupingBy(Employee::department,
                 Collectors.mapping(Employee::salary, Collectors.toSet())));

Третья перегрузка добавляет фабрику Map между классификатором и downstream-коллектором. Именно она решает проблему из начала урока — непредсказуемый порядок ключей.

// TreeMap - ключи отсортированы
TreeMap<String, Long> sortedHeadcount = staff.stream()
        .collect(Collectors.groupingBy(Employee::department,
                 TreeMap::new,
                 Collectors.counting()));
System.out.println(sortedHeadcount); // {HR=1, IT=2, Sales=2}

// LinkedHashMap - ключи в порядке первого появления в потоке
Map<String, Long> insertionOrder = staff.stream()
        .collect(Collectors.groupingBy(Employee::department,
                 LinkedHashMap::new,
                 Collectors.counting()));
System.out.println(insertionOrder); // {IT=2, HR=1, Sales=2}

Сами списки внутри групп порядок источника сохраняют всегда — перемешиваются только ключи.

Группировки можно вкладывать друг в друга: downstream-коллектором может быть другой groupingBy().

Map<String, Map<Boolean, List<String>>> nested = staff.stream()
        .collect(Collectors.groupingBy(Employee::department, TreeMap::new,
                 Collectors.groupingBy(e -> e.salary() >= 150_000,
                          Collectors.mapping(Employee::name, Collectors.toList()))));
System.out.println(nested);
// {HR={false=[Вера]}, IT={true=[Анна, Борис]}, Sales={false=[Глеб], true=[Дина]}}

5. partitioningBy(): деление ровно на две группы

partitioningBy(Predicate) — частный случай группировки по логическому условию. Результат — Map<Boolean, List<T>> ровно с двумя ключами: false и true.

Map<Boolean, List<String>> byHighSalary = staff.stream()
        .collect(Collectors.partitioningBy(
                e -> e.salary() >= 150_000,
                Collectors.mapping(Employee::name, Collectors.toList())));
System.out.println(byHighSalary); // {false=[Вера, Глеб], true=[Анна, Борис, Дина]}

Ключевое отличие от groupingBy(): оба ключа присутствуют всегда, даже если одна половина пуста. Сравните два вызова на потоке, где нет ни одного чётного числа:

List<Integer> odd = List.of(1, 3, 5);

Map<Boolean, List<Integer>> partitioned = odd.stream()
        .collect(Collectors.partitioningBy(n -> n % 2 == 0));
System.out.println(partitioned);                  // {false=[1, 3, 5], true=[]}
System.out.println(partitioned.get(true).size()); // 0

Map<Boolean, List<Integer>> grouped = odd.stream()
        .collect(Collectors.groupingBy(n -> n % 2 == 0));
System.out.println(grouped);                      // {false=[1, 3, 5]} - ключа true нет
System.out.println(grouped.get(true).size());     // NullPointerException

Поэтому там, где условие бинарное, partitioningBy() безопаснее: не нужны проверки на null и вызовы getOrDefault(). Работает это и с downstream-коллектором — пустая группа тогда получает «нулевое» значение самого коллектора:

Map<Boolean, Long> counts = odd.stream()
        .collect(Collectors.partitioningBy(n -> n % 2 == 0, Collectors.counting()));
System.out.println(counts); // {false=3, true=0}

6. Подсчёт и статистика: counting, summing, averaging, summarizing

Эти коллекторы почти не используют в одиночку — у потока для того же есть count(), sum() и average(). Их место — позиция downstream-коллектора внутри groupingBy() или partitioningBy(), где методы потока недоступны.

long headcount = staff.stream().collect(Collectors.counting());                    // 5 (тип Long!)
int total      = staff.stream().collect(Collectors.summingInt(Employee::salary));   // 750000
double average = staff.stream().collect(Collectors.averagingInt(Employee::salary)); // 150000.0

Для каждого числового типа есть своя тройка: summingInt/summingLong/summingDouble и averagingInt/averagingLong/averagingDouble. Обратите внимание: все три версии averaging* возвращают Double, а на пустом потоке дают 0.0, а не пустой Optional — отличить «данных не было» от «среднее равно нулю» по результату невозможно.

Если нужны сразу минимум, максимум, сумма, среднее и количество — есть summarizingInt/summarizingLong/summarizingDouble. Один проход по потоку, один объект со всеми показателями.

IntSummaryStatistics stats = staff.stream()
        .collect(Collectors.summarizingInt(Employee::salary));

System.out.println(stats.getCount());   // 5
System.out.println(stats.getSum());     // 750000 (тип long)
System.out.println(stats.getMin());     // 120000
System.out.println(stats.getMax());     // 180000
System.out.println(stats.getAverage()); // 150000.0
System.out.println(stats);
// IntSummaryStatistics{count=5, sum=750000, min=120000, average=150000.000000, max=180000}

Тот же коллектор отлично работает как downstream — получаем статистику по каждому отделу:

Map<String, IntSummaryStatistics> statsByDept = staff.stream()
        .collect(Collectors.groupingBy(Employee::department, TreeMap::new,
                 Collectors.summarizingInt(Employee::salary)));

statsByDept.forEach((dept, s) ->
        System.out.println(dept + ": max=" + s.getMax() + ", avg=" + s.getAverage()));
// HR: max=120000, avg=120000.0
// IT: max=180000, avg=165000.0
// Sales: max=160000, avg=150000.0

7. minBy, maxBy, mapping, reducing, collectingAndThen

Это коллекторы-адаптеры: сами по себе они дублируют методы Stream, но внутри группировки становятся незаменимыми.

minBy() и maxBy()

Аналоги Stream.min() и Stream.max(), возвращают Optional, потому что поток или группа теоретически могут оказаться пустыми.

Optional<Employee> topPaid = staff.stream()
        .collect(Collectors.maxBy(Comparator.comparingInt(Employee::salary)));
System.out.println(topPaid.map(Employee::name).orElse("нет данных")); // Анна

// самый высокооплачиваемый сотрудник каждого отдела
Map<String, Optional<Employee>> topByDept = staff.stream()
        .collect(Collectors.groupingBy(Employee::department, TreeMap::new,
                 Collectors.maxBy(Comparator.comparingInt(Employee::salary))));
// {HR=Optional[Employee[name=Вера, ...]], IT=Optional[...Анна...], Sales=Optional[...Дина...]}

collectingAndThen(): убрать Optional из результата

Optional в значениях карты неудобен, а внутри groupingBy() группа пустой быть не может по определению: ключ появляется, только если в него что-то попало. collectingAndThen() применяет финальное преобразование к результату другого коллектора:

Map<String, String> topNames = staff.stream()
        .collect(Collectors.groupingBy(Employee::department, TreeMap::new,
                 Collectors.collectingAndThen(
                         Collectors.maxBy(Comparator.comparingInt(Employee::salary)),
                         opt -> opt.map(Employee::name).orElseThrow())));
System.out.println(topNames); // {HR=Вера, IT=Анна, Sales=Дина}

// второй частый сценарий - сделать собранный список неизменяемым
List<String> frozen = staff.stream()
        .map(Employee::name)
        .collect(Collectors.collectingAndThen(Collectors.toList(), List::copyOf));

mapping(): преобразовать элементы перед сборкой

mapping(mapper, downstream) применяет функцию к каждому элементу и передаёт результат вложенному коллектору. По сути это map(), перенесённый внутрь группы.

Map<String, String> namesByDept = staff.stream()
        .collect(Collectors.groupingBy(Employee::department, TreeMap::new,
                 Collectors.mapping(Employee::name, Collectors.joining(", "))));
System.out.println(namesByDept.get("IT")); // Анна, Борис

Рядом живут ещё два коллектора из Java 9: filtering(predicate, downstream) отсеивает элементы уже внутри группы, а flatMapping(mapper, downstream) разворачивает вложенные потоки. Разница между filter() до группировки и filtering() внутри неё видна сразу:

// filter() до группировки: отдел HR исчезает из результата целиком
staff.stream()
     .filter(e -> e.salary() >= 150_000)
     .collect(Collectors.groupingBy(Employee::department, TreeMap::new, Collectors.counting()));
// {IT=2, Sales=1}

// filtering() внутри группировки: отдел HR остаётся с нулём
staff.stream()
     .collect(Collectors.groupingBy(Employee::department, TreeMap::new,
              Collectors.filtering(e -> e.salary() >= 150_000, Collectors.counting())));
// {HR=0, IT=2, Sales=1}

reducing(): коллекторный аналог reduce()

У reducing() три формы, повторяющие формы Stream.reduce().

// 1 аргумент: только оператор - результат в Optional
Optional<Integer> product = Stream.of(1, 2, 3, 4)
        .collect(Collectors.reducing((a, b) -> a * b)); // Optional[24]

// 2 аргумента: начальное значение + оператор
int sum = Stream.of(1, 2, 3, 4)
        .collect(Collectors.reducing(0, Integer::sum)); // 10

// 3 аргумента: начальное значение + функция извлечения + оператор
Map<String, Integer> payroll = staff.stream()
        .collect(Collectors.groupingBy(Employee::department, TreeMap::new,
                 Collectors.reducing(0, Employee::salary, Integer::sum)));
System.out.println(payroll); // {HR=120000, IT=330000, Sales=300000}

Последний пример эквивалентен summingInt(Employee::salary) и читается хуже. Правило простое: есть готовый специализированный коллектор — берите его, а reducing() оставьте для нестандартных операций свёртки вроде произведения или побитового ИЛИ.

8. teeing(): два коллектора за один проход

Collectors.teeing() появился в Java 12 и решает задачу, которая раньше требовала двух потоков: посчитать две разные агрегации по одним и тем же данным. Он принимает два коллектора и функцию-объединитель, которая получает оба результата.

record Payroll(long headcount, int total) { }

Payroll payroll = staff.stream().collect(Collectors.teeing(
        Collectors.counting(),                     // первый результат
        Collectors.summingInt(Employee::salary),   // второй результат
        (count, sum) -> new Payroll(count, sum))); // как их соединить

System.out.println(payroll); // Payroll[headcount=5, total=750000]

Поток обходится ровно один раз — это принципиально, когда источник одноразовый (файл, сетевой ответ, результат запроса) и повторный обход попросту невозможен.

// минимальная и максимальная зарплата одним проходом
String range = staff.stream().collect(Collectors.teeing(
        Collectors.minBy(Comparator.comparingInt(Employee::salary)),
        Collectors.maxBy(Comparator.comparingInt(Employee::salary)),
        (min, max) -> min.get().salary() + " - " + max.get().salary()));
System.out.println(range); // 120000 - 180000

// teeing можно вкладывать в groupingBy
Map<String, Payroll> byDept = staff.stream()
        .collect(Collectors.groupingBy(Employee::department, TreeMap::new,
                 Collectors.teeing(
                         Collectors.counting(),
                         Collectors.summingInt(Employee::salary),
                         Payroll::new)));
System.out.println(byDept);
// {HR=Payroll[headcount=1, total=120000], IT=Payroll[headcount=2, total=330000],
//  Sales=Payroll[headcount=2, total=300000]}

9. Свой коллектор через Collector.of()

Когда готового коллектора нет, интерфейс Collector не нужно реализовывать отдельным классом — достаточно статического метода Collector.of(). Он принимает четыре функции:

  • supplier — создаёт пустой контейнер-накопитель;
  • accumulator — добавляет очередной элемент потока в контейнер;
  • combiner — объединяет два контейнера (нужен только параллельному потоку);
  • finisher — превращает контейнер в итоговый результат.

Коллектор, считающий произведение чисел:

Collector<Integer, long[], Long> multiplying = Collector.of(
        () -> new long[]{1L},                  // supplier: массив как изменяемая ячейка
        (acc, n) -> acc[0] *= n,               // accumulator
        (a, b) -> { a[0] *= b[0]; return a; }, // combiner
        acc -> acc[0]);                        // finisher

long result = Stream.of(1, 2, 3, 4, 5).collect(multiplying);
System.out.println(result); // 120

Обратите внимание на типы: Collector<T, A, R>, где T — тип элемента потока, A — тип промежуточного контейнера, R — тип результата. Контейнер обязан быть изменяемым, поэтому вместо Long здесь массив из одного элемента.

Если контейнер и результат совпадают по типу, finisher не нужен — есть перегрузка из трёх функций:

Collector<String, StringJoiner, StringJoiner> toJoiner = Collector.of(
        () -> new StringJoiner(" | ", "<", ">"),
        StringJoiner::add,
        StringJoiner::merge);

StringJoiner joiner = staff.stream().map(Employee::name).collect(toJoiner);
System.out.println(joiner); // <Анна | Борис | Вера | Глеб | Дина>

Для одноразовой сборки собственный Collector часто избыточен — у метода collect() есть трёхаргументная форма, принимающая те же supplier, accumulator и combiner напрямую:

List<String> list = Stream.of("sun", "sea", "sand")
        .collect(ArrayList::new, ArrayList::add, ArrayList::addAll);

Обратите внимание

В последовательном потоке combiner не вызывается ни разу. Ошибка в нём годами не проявляется и всплывает в тот день, когда кто-то добавит .parallel(). Проверяйте собственный коллектор и на параллельном потоке тоже: list.parallelStream().collect(myCollector).

10. Сводная таблица методов Collectors

Полные сигнатуры и характеристики коллекторов всегда можно свериться в официальном javadoc класса Collectors.

Метод Что делает Пример
toList() Собирает в List; тип и изменяемость не гарантированы collect(toList())
toSet() Собирает в Set, убирая дубликаты по equals() collect(toSet())
toCollection() Собирает в указанную реализацию коллекции toCollection(TreeSet::new)
toUnmodifiableList(), toUnmodifiableSet(), toUnmodifiableMap() (Java 10+) Неизменяемые коллекции; элементы null запрещены collect(toUnmodifiableList())
toMap() Строит Map; 3-й аргумент — merge-функция, 4-й — фабрика Map toMap(Employee::department, Employee::salary, Integer::sum, TreeMap::new)
toConcurrentMap() То же для параллельных потоков: складывает в ConcurrentHashMap toConcurrentMap(Employee::name, e -> e)
joining() Склеивает CharSequence: без аргументов, с разделителем или с префиксом и суффиксом joining(", ", "[", "]")
counting() Считает количество элементов, возвращает Long groupingBy(Employee::department, counting())
summingInt(), summingLong(), summingDouble() Сумма значений, извлечённых функцией summingInt(Employee::salary)
averagingInt(), averagingLong(), averagingDouble() Среднее арифметическое; всегда Double, на пустом потоке 0.0 averagingInt(Employee::salary)
summarizingInt(), summarizingLong(), summarizingDouble() Count, sum, min, max, average одним проходом summarizingInt(Employee::salary)
minBy(), maxBy() Минимум и максимум по компаратору, результат в Optional maxBy(comparingInt(Employee::salary))
groupingBy() Группирует по ключу; 2-й аргумент — downstream, 3-й — фабрика Map groupingBy(Employee::department, TreeMap::new, counting())
groupingByConcurrent() Группировка в ConcurrentMap для параллельного потока groupingByConcurrent(Employee::department)
partitioningBy() Делит поток на две группы по предикату; ключи false и true есть всегда partitioningBy(e -> e.salary() >= 150_000)
mapping() Применяет функцию к элементам перед передачей в downstream mapping(Employee::name, toList())
filtering() (Java 9+) Фильтрует элементы внутри группы, сохраняя пустые группы filtering(e -> e.salary() >= 150_000, counting())
flatMapping() (Java 9+) Разворачивает вложенные потоки внутри группы flatMapping(e -> e.skills().stream(), toSet())
reducing() Свёртка внутри коллектора: три формы, как у Stream.reduce() reducing(0, Employee::salary, Integer::sum)
collectingAndThen() Применяет финальное преобразование к результату другого коллектора collectingAndThen(toList(), List::copyOf)
teeing() (Java 12+) Объединяет результаты двух коллекторов за один проход teeing(counting(), summingInt(Employee::salary), Payroll::new)
Collector.of() Свой коллектор из supplier, accumulator, combiner и finisher Collector.of(() -> new long[]{1L}, ..., acc -> acc[0])

Совет

Версии groupingByConcurrent() и toConcurrentMap() имеют смысл только вместе с parallelStream() и только когда порядок не важен: они пишут в общую ConcurrentMap без дорогого слияния промежуточных карт. В последовательном потоке они лишь добавят накладных расходов — берите обычные groupingBy() и toMap().

11. Нюансы поведения коллекторов

  1. groupingBy() без фабрики возвращает HashMap. Порядок ключей не связан ни с порядком в потоке, ни с сортировкой. Нужен предсказуемый результат — трёхаргументная форма с TreeMap::new или LinkedHashMap::new. Списки внутри групп при этом порядок источника сохраняют.
  2. toMap() без merge-функции падает на дубликате ключа. Сообщение IllegalStateException: Duplicate key появляется только на реальных данных, поэтому ошибка почти всегда уезжает в продакшен. Если ключ не уникален железно — сразу пишите третий аргумент.
  3. toMap() не переносит null в значениях. Внутри вызывается Map.merge(), который на null бросает NullPointerException. У groupingBy() такой проблемы нет, зато null-ключ классификатора уронит и его.
  4. partitioningBy() всегда отдаёт оба ключа, groupingBy() — только встреченные. Отсюда разница: partitioned.get(true) вернёт пустой список, а grouped.get(true)null.
  5. counting() возвращает Long, а не Integer. Объявление Map<String, Integer> с counting() просто не скомпилируется, а сравнение результата через == для значений больше 127 даст false: сравниваются ссылки, а кеш Long на них уже не распространяется.
  6. summingInt() молча переполняется. Накопление идёт в int: сумма больше Integer.MAX_VALUE уйдёт в минус без всякого исключения. Для денег и больших счётчиков берите summingLong().
  7. averagingInt() на пустом потоке возвращает 0.0. Это не то же самое, что IntStream.average(), который честно отдаёт пустой OptionalDouble. Пустой набор данных и набор из одних нулей по результату неразличимы.
  8. joining() работает только с CharSequence. На Stream<Employee> код не скомпилируется — нужен предварительный map(Employee::name) или map(Object::toString).
  9. Место фильтрации меняет результат. filter() до группировки удаляет группы целиком, filtering() внутри группировки оставляет их с нулевым или пустым значением.

Часто задаваемые вопросы

Чем groupingBy отличается от partitioningBy?

groupingBy принимает функцию-классификатор и создаёт столько ключей, сколько разных значений она вернула; ключа для группы, которая не встретилась, в карте не будет. partitioningBy принимает предикат и всегда возвращает Map с ровно двумя ключами, false и true, даже если одна из половин пуста. Практический вывод: при бинарном условии partitioningBy безопаснее, потому что get(true) вернёт пустой список, а не null.

Как отсортировать результат groupingBy по ключу?

Использовать трёхаргументную форму с фабрикой Map: groupingBy(Employee::department, TreeMap::new, Collectors.toList()). TreeMap отсортирует ключи по естественному порядку или по переданному компаратору, а LinkedHashMap::new сохранит порядок первого появления ключей в потоке. Сортировать поток через sorted перед группировкой бесполезно: HashMap всё равно переставит ключи по хешам.

Что делать, если toMap бросает Duplicate key?

Добавить третий аргумент - функцию слияния. Она получает старое и новое значение и возвращает то, которое останется в карте: первое встреченное, последнее или результат их объединения, например сумму через Integer::sum. Если нужны все значения с одинаковым ключом, а не одно, используйте groupingBy вместо toMap.

Можно ли изменять список, полученный через Collectors.toList()?

Формально нет: javadoc не гарантирует ни конкретный класс, ни изменяемость результата, хотя фактически сейчас возвращается ArrayList. Если список точно нужно править, пишите toCollection(ArrayList::new). Метод stream().toList() из Java 16 и коллектор toUnmodifiableList() возвращают неизменяемые списки, и разница между ними в том, что toList из Stream допускает элементы null, а toUnmodifiableList на null бросает NullPointerException.

Когда нужен свой коллектор вместо готового?

Почти никогда: комбинация groupingBy, mapping, collectingAndThen, reducing и teeing покрывает подавляющее большинство задач. Свой коллектор через Collector.of оправдан, если результат накапливается в нестандартную изменяемую структуру - собственный билдер, гистограмму, буфер - и эту логику нужно переиспользовать в разных местах. Для одноразового случая проще трёхаргументный collect с supplier, accumulator и combiner. Главное при своей реализации - написать корректный combiner: в последовательном потоке он не вызывается, и ошибка в нём проявится только после перехода на parallel.

Комментарии

Зарегистрируйтесь или войдите, чтобы иметь возможность оставить комментарий.