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