Stream API в Java: что спрашивают и где ошибаются
Что такое стрим на самом деле
Stream — это не коллекция и не структура данных. Это описание конвейера вычислений над источником данных, который выполняется лениво и один раз. Стрим не хранит элементы — он их прогоняет через цепочку операций в момент терминальной операции. Пока вы вызываете map, filter, sorted — ничего не происходит, JVM просто строит граф трансформаций.
Промежуточные и терминальные операции
Ключевое разделение, которое проверяют на собесе: map, filter, sorted, distinct, peek — промежуточные, они ленивые и возвращают новый Stream. collect, forEach, reduce, count, anyMatch — терминальные, они запускают выполнение и «сжигают» стрим — повторно использовать его нельзя, будет IllegalStateException.
Stream<String> s = list.stream().filter(x -> x.length() > 3); s.count(); s.forEach(System.out::println); // IllegalStateException: stream has already been operated upon
Важный нюанс про порядок выполнения: элементы проходят весь конвейер по одному, а не поэтапно по всей коллекции. Это можно проверить через peek: если в списке из трёх элементов вызвать map и filter, лог покажет, что каждый элемент сначала маппится, потом фильтруется, и только потом переходят к следующему.
Ленивость и short-circuiting
Из ленивости следует важное следствие: если терминальная операция — findFirst или anyMatch, стрим не обязан обрабатывать все элементы источника. Это позволяет работать даже с бесконечными стримами:
Stream.iterate(1, x -> x + 1)
.filter(x -> x % 7 == 0)
.findFirst()
.get(); // вернёт 7, не зависнетsorted() перед limit() в бесконечном или очень большом стриме, ожидая ленивости. Сортировка — операция с состоянием (stateful), она обязана материализовать все элементы, прежде чем отдать первый результат. На бесконечном стриме это подвесит программу навечно.Стейтфул vs стейтлес операции
На собесе любят спрашивать разницу между stateless операциями (map, filter) и stateful (sorted, distinct, limit в параллельном режиме). Stateless обрабатывает каждый элемент независимо. Stateful должен видеть контекст — либо весь стрим (сортировка), либо предыдущие элементы (distinct хранит set увиденных значений). Это напрямую влияет на память и на то, можно ли безопасно распараллелить операцию.
Коллекторы — не просто toList()
Реальная сила Stream API — в Collectors: groupingBy, partitioningBy, toMap, комбинация с downstream-коллектором.
Map<Boolean, List<Employee>> byActive = employees.stream()
.collect(Collectors.partitioningBy(Employee::isActive));
Map<String, Long> countByDept = employees.stream()
.collect(Collectors.groupingBy(Employee::getDept, Collectors.counting()));Collectors.toMap: если в источнике встречаются дублирующиеся ключи, будет IllegalStateException: Duplicate key. Нужно передавать третий аргумент — merge function, например (a, b) -> a, чтобы явно решить конфликт.Параллельные стримы — не бесплатный ускоритель
Вызов .parallel() использует общий ForkJoinPool.commonPool(), который делится с остальным приложением, включая, например, CompletableFuture. Если запустить тяжёлую параллельную операцию с блокирующим IO внутри, можно посадить весь пул и замедлить не связанный с этим код в других частях приложения. Параллелизм имеет смысл на CPU-bound задачах с большим объёмом данных и дешёвым разделением (массивы, ArrayList), а не на связных списках или маленьких коллекциях, где оверхед на распределение работы съедает выигрыш.
Как это звучит на собеседовании
Если спрашивают «что происходит при вызове map» — правильный ответ: ничего, кроме построения описания операции. Если спрашивают про reduce — важно проговорить identity, accumulator и combiner, и что combiner нужен именно для параллельного выполнения, чтобы объединить частичные результаты из разных потоков. Если спрашивают про побочные эффекты в forEach или peek — стоит сказать, что стримы задуманы как декларативные и без side-effects, а мутация внешнего состояния внутри лямбды — плохая практика и источник багов при параллелизации.