Stream API на собеседовании по Java

Промежуточные и терминальные операции называют все, а спрашивают про ленивость и побочные эффекты. Разбираем, что именно проверяет вопрос про Stream API.

К вопросам про Stream API легко готовиться так: выучить, какие операции промежуточные, а какие терминальные. Список выучивается за вечер и почти ничего не даёт, потому что интервьюер, набиравший команду в 2024–2025, описывает свою секцию иначе: задачи на стримы — отдельный класс задач лайвкодинга, и код там пишут. Ниже разобраны четыре свойства Stream API, которые документация записывает прямо и которых нет в списках операций.

Всё, что сказано о поведении, относится к Java SE 21.

Ленивость — это контракт, а не оптимизация §

Слово «ленивый» описывает порядок выполнения, и документация формулирует его точно: промежуточные операции всегда ленивы, а обход источника конвейера не начинается, пока не выполнена терминальная операция.

Отсюда сразу два следствия, которые проверяются одной строкой кода. Первое: конвейер без терминальной операции не делает ничего вообще, потому что обход не начинался. Второе: filter не фильтрует в момент вызова. Он возвращает новый стрим, который при обходе будет содержать подходящие элементы, и из этой разницы вырастает следующая секция.

Из того же контракта следует и запрет на повторное использование. После терминальной операции конвейер считается использованным и больше применяться не может; если нужно пройти по тем же данным снова, возвращаются к источнику и берут новый стрим.

Почему побочный эффект может не выполниться §

Это самое неожиданное место темы, и оно записано в документации прямо. Если параметры операций имеют побочные эффекты, нет никаких гарантий ни на видимость этих эффектов другим потокам, ни на то, что разные операции над одним элементом выполнятся в одном потоке, ни, главное, на то, что параметры вообще будут вызваны: реализация вправе исключить операции или целые стадии конвейера, если может доказать, что на результат вычисления это не повлияет.

Практический вывод формулируется коротко: print внутри map может не напечатать ничего, если результат map никому не нужен. Исключение документация делает для терминальных forEach и forEachOrdered; для остальных случаев побочные эффекты «могут выполняться не всегда».

Рядом в документации лежат два требования к параметрам операций:

  • Non-interference — параметр не должен изменять источник данных стрима, если источник не конкурентный.
  • Stateless — результат параметра не должен зависеть от состояния, которое может меняться во время выполнения конвейера; при нарушении результат может оказаться недетерминированным или попросту неверным.

Оба требования становятся понятнее из предыдущей секции: раз момент обхода не совпадает с моментом описания конвейера, любая опора на изменяющееся состояние — опора на неизвестный момент времени.

Где Collectors ведёт себя не так, как ожидают §

Сборщики выглядят самой безобидной частью API, и ровно поэтому два их документированных свойства стоит знать наизусть: оба расходятся с тем, чего ждёшь по аналогии.

toMap падает на дубликатах ключей. Вариант с двумя аргументами бросает IllegalStateException, если сопоставленные ключи содержат дубликаты; совпадение определяется по Object.equals. Любая группировка по неуникальному полю приводит к этому сразу. Документация называет и лечение: вариант toMap с третьим аргументом, функцией слияния, которая решает, что делать с двумя значениями одного ключа.

groupingBy не обещает, что вернёт. Формулировка документации не оставляет места толкованию: нет никаких гарантий на тип, изменяемость, сериализуемость и потокобезопасность возвращаемых Map и List. Отсюда практическое следствие: код, который берёт результат groupingBy и добавляет в него элемент, опирается на то, что не гарантировано, и может перестать работать при смене версии.

Полезно назвать и groupingByConcurrent: документация предлагает его для параллельных конвейеров, когда сохранение порядка не требуется. Это подводит к последней секции.

Когда параллельность вредит §

Замена stream() на parallelStream() выглядит как бесплатное ускорение: одно слово, и обработка идёт на всех ядрах. Документация называет конкретный случай, где параллельность контрпродуктивна: сложная свёртка через collect(), строящая Map (например, группировка), потому что стадия слияния, объединение одной карты с другой по ключам, для некоторых реализаций Map обходится дорого.

Механизм здесь простой, и его стоит проговорить: параллельная обработка делит данные на части, обрабатывает их независимо и потом объединяет промежуточные результаты. Свёртка вроде суммы объединяется дёшево: достаточно сложить два числа. Свёртка, строящая карту, объединяется дорого: нужно слить два набора ключей. Выигрыш от параллельной обработки съедается стадией слияния.

Документация описывает и условия, при которых свёртка выполняется конкурентно, без слияния: стрим должен быть параллельным, сборщик — обладать характеристикой CONCURRENT, и при этом либо стрим неупорядочен, либо неупорядоченность объявлена у самого сборщика. Все три условия обязательны, и это объясняет, почему groupingByConcurrent существует отдельно от groupingBy.

Последнее — про формат. Тот же интервьюер пишет, что полностью рабочий код на секции не требуется: оценивается ход мысли и способность выстроить алгоритм, а решать допускается итеративно. Это стоит учесть при подготовке: на секции полезно проговаривать вслух, почему выбран именно такой конвейер.

Проверьте себя

Сначала проговорите ответ, потом сверьтесь с текстом.

Почему Stream называют ленивым?

Что проверяют: знаете вы формулировку или пересказываете её как «вычисляется по необходимости». Точная формулировка касается момента начала обхода, и из неё выводятся ответы; общая — нет.

Частая ошибка: объяснить ленивость через экономию: «не обрабатывает лишние элементы». Экономия бывает следствием, а контракт говорит о том, когда начинается обход.

Раздел «Ленивость — это контракт, а не оптимизация»
Что напечатает конвейер, если поставить вывод внутрь map?

Что проверяют: считаете вы операции стрима обычными вызовами или помните, что реализация вправе их не выполнять.

Частая ошибка: ответить «напечатает по строке на каждый элемент». Это верно для forEach, но не гарантировано для map: если результат не влияет на итог, стадию можно выбросить целиком.

Раздел «Почему побочный эффект может не выполниться»
Что произойдёт, если у двух элементов совпадут ключи в toMap?

Что проверяют: знаете вы поведение по умолчанию или предполагаете его по аналогии с put.

Частая ошибка: ответить «второе значение перезапишет первое». Так работает Map.put, а двухаргументный toMap бросает IllegalStateException.

Раздел «Где Collectors ведёт себя не так, как ожидают»
Ускорит ли parallelStream эту обработку?

Что проверяют: есть ли у вас модель того, из чего складывается цена параллельной обработки, или вы считаете её бесплатной.

Частая ошибка: ответить «да, задействует все ядра». Ядра задействуются, но к работе добавляется разбиение и слияние, и на свёртках, строящих карту, слияние может оказаться дороже выигрыша.

Раздел «Когда параллельность вредит»

Проверьте эти темы вслух

В тренажёре вы отвечаете голосом, а разбор показывает, где ответ просел.