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.
Ускорит ли parallelStream эту обработку?
Что проверяют: есть ли у вас модель того, из чего складывается цена параллельной обработки, или вы считаете её бесплатной.
Частая ошибка: ответить «да, задействует все ядра». Ядра задействуются, но к работе добавляется разбиение и слияние, и на свёртках, строящих карту, слияние может оказаться дороже выигрыша.
Проверьте эти темы вслух
В тренажёре вы отвечаете голосом, а разбор показывает, где ответ просел.