Серия подготовки: к обзору тем Java-собеседования.
Stream API позволяет описать обработку данных цепочкой: откуда взять элементы, какие оставить, во что превратить и какой результат получить. Например, из списка заказов выбрать оплаченные и сложить их суммы. Лямбда задаёт небольшое передаваемое действие, а Optional представляет один найденный результат или его отсутствие. Ниже разберём девятнадцать вопросов, объясняя каждую часть этих механизмов.
Для чтения достаточно базового синтаксиса Java: переменных, методов и списков. Запись List<String> означает список строк; угловые скобки задают тип элемента. Список хранит объекты через ссылки — значения, дающие доступ к объектам. Несколько ссылок могут вести к одному изменяемому объекту. Это важно, когда действие в цепочке меняет данные, доступные другому коду.
Основные правила разобраны для Java 17/21. Метод Stream.toList() доступен с Java 16. Все данные и задачи учебные. Слово «поток» имеет два значения: Stream описывает обработку элементов, а Thread — отдельный ход выполнения программы. Stream не становится отдельным Thread просто из-за своего названия; совместное выполнение несколькими потоками включают отдельно.
- Вход
Из какого списка, файла или другого источника берутся элементы?
- Действия
Какие элементы оставляем и что вычисляем из каждого?
- Результат
Нужны сумма, список, количество или только один подходящий элемент?
- Условия
Могут ли данные отсутствовать, меняется ли общий объект и нужно ли закрыть файл?
Лямбды и выполнение цепочки
1. Что такое лямбда и функциональный интерфейс?
Лямбда — короткая запись действия, которое передают другому коду. Например, order -> order.isPaid() означает: «получи заказ order и верни, оплачен ли он». Слева от стрелки расположен параметр — имя входного значения внутри действия; справа вычисляется результат. Объявив такое правило, мы ещё не выполняем его для всех заказов: вызывающий код сам решит, когда его использовать.
Функциональный интерфейс задаёт одно абстрактное действие по правилам Java: то есть метод, которому нужна реализация. Predicate<Order> принимает заказ и возвращает true или false, а Function<Order, String> принимает заказ и возвращает строку. Типы внутри угловых скобок уточняют вход и, у Function, выход. Интерфейс может также содержать готовые default-методы или static-методы класса; они не добавляют ещё одно требующее реализации действие.
Компилятору нужен целевой тип: какой именно интерфейс требуется в этом месте. Поэтому при перегрузках, когда есть несколько методов с одним именем, одна лямбда иногда не позволяет выбрать единственный вариант. Запись с ::, например Order::isPaid, называется ссылкой на метод и передаёт уже существующее совместимое действие.
Лямбда может читать локальные переменные окружающего метода, если их не переназначают: они final или effectively final, «фактически неизменные». Это ограничение переменной, а не объекта за ссылкой. Список по такой ссылке всё ещё может меняться; для безопасного одновременного изменения несколькими потоками одного запрета переназначения недостаточно.
2. Чем Stream отличается от коллекции?
Коллекция, например List, хранит элементы. Stream задаёт способ пройти по источнику и вычислить нужный результат. Вызов orders.stream() не означает создание второго полного списка заказов. Он начинает описание обработки. Последующий отбор через filter тоже не удаляет неподходящие заказы из исходного списка.
Представьте исходные заказы и отчёт только по оплаченным. Сам список остаётся доступным для других задач, а Stream описывает получение отчёта. Если в конце собрать данные в новый список, этот результат займёт память. Некоторые промежуточные операции, например сортировка, также сохраняют данные для своей работы. Поэтому Stream не даёт автоматического обещания «ничего не хранить в памяти».
Источником может быть не только список, но и строки файла или генератор, создающий новые значения без заранее заданного конца. Перед выбором обработки проверьте, можно ли прочитать источник заново и существует ли у него последний элемент. Эти свойства влияют на завершение и освобождение ресурсов.
3. Что означает ленивость и обязательно ли выполняется peek?
Ленивость, или lazy evaluation, означает отложенную обработку элементов. Промежуточные операции, такие как filter для отбора и map для преобразования, пока строят описание цепочки. Терминальная, то есть завершающая, операция запрашивает результат: например, collect собирает элементы, count считает их, findFirst ищет первый. Без такого запроса промежуточная обработка не запускается.
Даже после запуска не обязательно обрабатывать каждый элемент. Чтобы найти первый подходящий, может хватить начала источника. Кроме того, реализация имеет право пропустить этапы, не влияющие на ответ. Если количество элементов уже известно и промежуточные операции его не меняют, count может получить число без вызова преобразования для каждого элемента.
peek задаёт действие наблюдения за проходящими элементами, например вывод в консоль при отладке. Но если элементы не требуется просматривать, такое действие может вообще не вызваться. Поэтому обязательную запись аудита или отправку сообщения нельзя надёжно привязать к peek. Это побочный эффект — изменение чего-то помимо вычисляемого результата. Для обязательного действия нужна явно выбранная операция и понятное правило её выполнения.
4. Можно ли повторно использовать один Stream?
Нет: один Stream предназначен для одной обработки. После завершающей операции нельзя снова попросить тот же объект посчитать или собрать элементы. Реализация может обнаружить повторное использование и выбросить IllegalStateException, исключение о недопустимом текущем состоянии. Разветвление одного Stream на две якобы независимые цепочки тоже не разрешено.
Если из одного списка нужны количество и сумма, можно дважды вызвать list.stream() и построить два новых вычисления. Это отличается от сохранения одного Stream в переменную и двух запусков через неё. Если нужен один проход, результатом может быть объект статистики с несколькими показателями; для чисел есть специальные числовые потоки и готовые операции статистики.
Возможность снова открыть источник зависит от задачи. Повторное обращение к небольшому списку и повторное чтение большого файла имеют разную цену. Сначала решите, что нужно посчитать и можно ли читать заново, затем выбирайте один или несколько проходов.
Преобразование и сборка результата
5. Чем map отличается от flatMap?
map заменяет каждый входной элемент одним результатом. Например, заказ — его номером. Если результатом стал целый список или Stream, он остаётся одним вложенным значением. flatMap нужен, когда из каждого элемента получают внутренний Stream, а затем хотят пропустить его элементы в одну общую последовательность.
В примере groups — список двух списков строк. Нужно получить три отдельные технологии. List.of создаёт список с указанными значениями. Ссылка на метод List::stream означает «вызови stream у очередного списка». collect завершает цепочку, а Collectors.toList() передаёт ему готовое правило сборки элементов в список.
List<List<String>> groups = List.of(
List.of("Java", "SQL"),
List.of("HTTP")
);
List<String> result = groups.stream()
.flatMap(List::stream)
.collect(Collectors.toList());
System.out.println(result); // [Java, SQL, HTTP]groups.stream()даёт в обработку две группы: [Java, SQL] и [HTTP].- Для первой группы List::stream открывает обработку двух строк, для второй — одной.
flatMapпередаёт дальше сами строки по очереди, убирая один уровень вложенности.collectсобирает их в результат. Печатается[Java, SQL, HTTP].
С map(List::stream) дальше передавались бы два Stream, а не три строки: изменился бы тип результата. Выбор определяется именно формой данных. У Optional методы с такими же именами тоже помогают с вложенностью, но там внутри не последовательность элементов, а максимум одно значение.
6. Можно ли без последствий переставлять filter и map?
Только если сохраняется смысл обработки. filter проверяет условие и оставляет элементы с результатом true. map вычисляет новое значение для каждого дошедшего элемента. Перестановка меняет то, что проверяется: исходный объект или результат преобразования. Она также может привести к выполнению операции, которую прежде не делали для отброшенных элементов.
Например, вход содержит текстовые коды, среди которых бывают не числа. Integer.parseInt превращает подходящий текст в целое, а на неподходящем выбрасывает исключение. Если вызвать его до проверки допустимого формата, более поздний filter уже не предотвратит ошибку: до него выполнение не дойдёт. Нужна проверка, соответствующая формату, который вы действительно принимаете.
Другой пример — цена после скидки. «Оставить товары дороже 100 до скидки» и «дороже 100 после скидки» могут дать разные наборы. Ранний отбор иногда уменьшает вычисления, но ускорять можно только после проверки, что результат остался тем же. Для каждого этапа назовите его вход, выход и условие ошибки.
7. Какие условия нужны корректному reduce?
reduce объединяет несколько значений в одно, например складывает баллы. Начальное значение должно быть нейтральным: объединение с ним не меняет число. Для сложения это 0, для умножения — 1. Операция должна быть ассоциативной: если объединять части в разных группировках, ответ должен сохраняться. Это позволяет отдельно обработать части данных и затем объединить результаты.
Складываем 3, 5 и 8, а по условию отчёта затем прибавляем разовый бонус 10. Integer::sum — ссылка на метод сложения двух целых. Бонус не является нейтральным началом, поэтому записан отдельным действием после суммы.
int total = List.of(3, 5, 8).stream()
.reduce(0, Integer::sum);
int withBonus = total + 10;
System.out.println(withBonus); // 26Последовательное сложение начинается с 0: после числа 3 получается 3, после 5 — 8, после 8 — 16. Затем прибавляется один бонус 10, и печатается 26. При корректном разбиении можно, например, сложить 3 + 5 отдельно, получить 8 и затем объединить с последней восьмёркой: сумма останется 16.
Запись reduce(10, Integer::sum) может случайно давать желаемый итог при последовательной обработке, но нарушает условие нейтральности для сложения. При параллельной обработке начальное значение может участвовать в нескольких частях, и бонус повторится. Вычитание тоже не подходит как ассоциативное объединение: (8 − 5) − 3 и 8 − (5 − 3) различаются. Конкретный неправильный результат ошибочного параллельного reduce не стоит заучивать: способ разбиения не обещан.
8. Почему изменяемый результат собирают через collect?
Чтобы получить список, обычно удобнее постепенно добавлять элементы в контейнер, а не каждый раз создавать новое значение результата. collect как раз описывает такую сборку. Коллектор — правило из нескольких действий: создать пустой контейнер, добавить очередной элемент, а при обработке по частям объединить контейнеры.
У правильного обычного коллектора для разных частей могут создаваться отдельные списки. Пока часть обрабатывается, её накопитель не меняют одновременно несколько потоков; затем частичные результаты объединяются. Поэтому использование ArrayList внутри такого коллектора само по себе не является ошибкой. Это отличается от одного общего ArrayList, в который все потоки пытаются писать одновременно.
Передача одного изменяемого списка как начального значения reduce смешивает эти модели: начальное значение уже не просто нейтральное значение, а общий объект, который меняют. Для списка используйте подходящий готовый коллектор. Если нужен именно изменяемый ArrayList, Collectors.toCollection(ArrayList::new) явно передаёт фабрику его создания; ::new означает ссылку на конструктор, а не заранее созданный общий список.
9. Равнозначны ли toList() и collect(toList())?
Нет, их обещания различаются. Stream.toList() возвращает список, в котором нельзя добавлять, удалять или заменять элементы. Метод доступен с Java 16. Вызов collect(Collectors.toList()) использует отдельное правило сборки, но это правило не обещает конкретный класс и не гарантирует, что результат можно изменять.
То, что в конкретной версии после второго варианта работает add, не становится гарантией для всех реализаций. Если добавление обязательно, запросите изменяемый ArrayList явно: collect(Collectors.toCollection(ArrayList::new)). Аналогично нельзя предполагать без проверки, что результат безопасен для изменений из нескольких потоков или может быть сохранён стандартным механизмом сериализации.
Запрет менять список касается его состава, а не всех данных внутри. Если элементы — изменяемые заказы, другой код может изменить статус заказа, и это будет видно через уже собранный список. Коллекция хранит ссылки на те же объекты. Для независимых данных нужно отдельно решать, требуется ли копирование самих заказов.
10. Что произойдёт, если toMap встретит одинаковый ключ?
Collectors.toMap строит карту, где каждому ключу соответствует одно значение. Вариант с двумя аргументами задаёт только получение ключа и значения. Если равный ключ встретится повторно, возникнет IllegalStateException: правило разрешения конфликта не задано. Повтор нужно осмыслить: это ошибка, сумма значений или необходимость хранить целую группу?
В списке ниже java встречается дважды. По условию считаем число появлений. Function.identity() возвращает входное значение без изменений: сама метка становится ключом. Лямбда tag -> 1 назначает каждому появлению начальное количество 1. Третий аргумент Integer::sum говорит, как складывать значения при совпадении ключа.
List<String> tags = List.of("java", "sql", "java");
Map<String, Integer> counts = tags.stream().collect(
Collectors.toMap(Function.identity(), tag -> 1, Integer::sum)
);
System.out.println(counts.get("java")); // 2Первое java создаёт пару java → 1. Затем sql создаёт свою пару sql → 1. Второе java находит существующий ключ; заданное правило складывает прежнюю 1 и новую 1. Поэтому counts.get("java") возвращает 2. Без третьего аргумента такое повторение остановило бы сборку исключением.
Правило «оставлять вторую запись» тоже можно задать, но сначала нужно определить, какой порядок значим и что означает «последняя». Иначе незаметно потеряется одна из записей. Если важны конкретный класс карты или порядок её обхода, их нужно задать предусмотренной фабрикой, а не предполагать по умолчанию.
11. Когда нужен groupingBy, а когда partitioningBy?
groupingBy собирает группы под вычисленными ключами. Например, из заявок со статусами NEW, DONE, NEW получится группа NEW с двумя заявками и DONE с одной. Функцию, определяющую ключ группы, называют классификатором. По умолчанию внутри каждой группы находится список её элементов.
partitioningBy делит по условию на две части: под ключом true — выполнившие условие, под false — остальные. Например, оплаченные и неоплаченные заказы. Результат содержит обе части, даже если в одной нет элементов. Если нужны произвольные статусы, а не ответ «да/нет», используйте groupingBy.
Вместо списка можно хранить количество или сумму. Для этого передают вложенный, или downstream, коллектор — правило обработки элементов внутри каждой группы. Например, counting() считает их, поэтому итогом будут числа, а не списки. Заранее решите, что нужно при пустом входе, отсутствующей группе или null вместо ключа. Нельзя считать, что groupingBy разрешает всё то же, что выбранная карта: правила самой операции группировки нужно учитывать отдельно.
Порядок и параллельность
12. Как работают distinct и sorted?
distinct убирает совпадающие элементы по правилу equals — смыслового равенства, заданного их классом. sorted расставляет элементы по порядку. Порядок может быть естественным, как у чисел, или заданным Comparator — отдельным правилом, которое сравнивает пару элементов и возвращает знак результата.
Ноль при сравнении означает одинаковое место по этому правилу порядка, но не обязательно равенство по equals. Две заявки с разными номерами могут иметь одинаковую сумму. Сортировка по сумме не превращает их в одну заявку, а distinct не обязан удалять одну из них. Если нужно убирать повторы именно по номеру, нужно выразить это правило явно, а не надеяться на сортировку.
Обеим операциям может понадобиться хранить сведения об уже просмотренных данных. Для сортировки обычного конечного потока нужно получить данные, которые предстоит упорядочить. Это требует времени и памяти. Запрос «первые три подходящих в исходном списке» отличается от «три первых после сортировки»: сначала определите нужный смысл слова «первые».
13. Чем findFirst отличается от findAny?
findFirst выбирает первый элемент в порядке встречи, если у потока задан такой порядок. Английское название этого свойства — encounter order. Например, список задаёт порядок своих позиций, а sorted устанавливает порядок сортировки. findAny допускает любой элемент: он подходит, если нужен просто один найденный, независимо от места.
В последовательном запуске оба метода часто дают одинаковый ответ, но обещания от этого не становятся одинаковыми. При параллельной обработке отказ от требования «первый» может упростить поиск. Если исходный источник не задаёт порядка, нельзя приписать findFirst привычный порядок списка, которого здесь нет.
unordered() снимает требование сохранять порядок, а не обязательно перемешивает данные случайным образом. Поиск одного результата также не гарантирует завершение любой цепочки. Например, бесконечный источник перед sorted не даст сортировке получить весь вход. До findFirst дело может не дойти: анализировать нужно предыдущие этапы тоже.
14. Почему parallelStream не гарантирует ускорения?
parallelStream() разрешает выполнять части обработки нескольким потокам выполнения. Для этого данные нужно разделить, обработать части и объединить результат. На разделение и согласование тоже тратится время. Если элементов мало или действие очень дешёвое, эти дополнительные расходы могут оказаться больше полученной пользы.
Не все этапы хорошо делятся. Требование точного порядка, сортировка или доступ к общему изменяемому объекту могут заставлять части ждать друг друга. Если действие ждёт сеть или базу данных, важны также ограничения внешнего сервиса и число одновременных запросов. Одного переключателя parallel для управления этими условиями недостаточно.
Сначала проверьте, независимы ли вычисления и правильно ли объединяются результаты, затем измерьте время на подходящих для задачи данных. Универсальной границы «после тысячи элементов всегда быстрее» нет. Stream также не заменяет отдельную организацию задач с заданными сроками ожидания и лимитами запросов: это дополнительные требования, которых сам вызов parallelStream не описывает.
15. Почему опасно менять внешний список из forEach?
Внешний список — объект, созданный за пределами цепочки и доступный её действиям. Если внутри forEach добавлять в один общий ArrayList, последовательный вариант может выглядеть рабочим. После включения параллельности несколько потоков способны менять один список одновременно. Его обычные операции такого согласованного доступа не обеспечивают: возможны потерянные элементы и повреждение состояния.
Отсутствие исключения в одном запуске не доказывает правильность. Когда нужен список результатов, используйте collect с подходящим коллектором: он описывает создание и объединение накопителей. Во время обработки также не следует менять обычную коллекцию, которая служит самим источником Stream. Это мешает обходу даже в последовательной цепочке.
Если задача именно во внешнем действии, например отправке сообщений, отдельно определите порядок, реакцию на ошибку и допустимое число одновременных отправок. Параллельный forEach не обещает порядок списка. forEachOrdered сохраняет порядок встречи, но не решает за вас остальные правила работы внешнего сервиса. Безопасность отдельной операции и правильность всей задачи нужно проверять раздельно.
Optional и отсутствие результата
16. Чем of отличается от ofNullable и как работают map и flatMap?
Optional<T> — объект с двумя вариантами состояния: внутри есть одно ненулевое значение типа T либо значения нет. Пустой Optional существует как объект; это не null-ссылка. Optional.of(value) требует значение и выбрасывает исключение на null. Optional.ofNullable(value) принимает оба случая: из null делает пустой Optional, из ненулевого значения — заполненный.
map выполняет преобразование только для имеющегося значения. Например, из найденного профиля получает его имя. Если результатом преобразования стал null, возвращается пустой Optional. Когда значения изначально нет, функция не вызывается. Это позволяет последовательно описать работу с необязательным результатом.
flatMap нужен, если действие само уже возвращает Optional. Например, поиск профиля вернул Optional<Profile>, а получение подтверждённого адреса из Profile возвращает Optional<String>. Через map возник бы лишний уровень Optional внутри Optional; flatMap сразу передаёт полученный внутренний результат. Но действие для flatMap должно вернуть именно Optional: null вместо него вызовет NullPointerException. Пустой результат и отсутствие самого объекта Optional — разные вещи.
17. Чем orElse отличается от orElseGet?
orElse получает уже вычисленный запасной результат: Java сначала вычисляет аргументы и только затем вызывает метод. orElseGet получает правило вычисления и запускает его только при пустом Optional. Второе поведение называют ленивым, потому что вычисление откладывается до необходимости. Это важно, если запасной вариант требует запроса к базе или другого заметного действия.
В примере готовое имя Mira уже есть. Supplier<String> — интерфейс поставщика: действие без входных параметров, возвращающее строку. Лямбда () -> { ... } задаёт это действие, пустые скобки показывают отсутствие входа. AtomicInteger здесь служит счётчиком вызовов: начинается с нуля, увеличивается через incrementAndGet и читается через get. Его возможности согласованной работы в нескольких потоках в этом примере не используются.
AtomicInteger calls = new AtomicInteger();
Supplier<String> makeName = () -> {
calls.incrementAndGet();
return "guest";
};
String first = Optional.of("Mira").orElse(makeName.get());
String second = Optional.of("Mira").orElseGet(makeName);
System.out.println(first + " " + second); // Mira Mira
System.out.println(calls.get()); // 1- Создание makeName сохраняет правило: увеличить счётчик и вернуть guest. Само тело пока не вызывается.
- Для orElse сначала выполняется аргумент
makeName.get(). Счётчик становится 1, получается guest. Затем orElse видит имеющуюся Mira и возвращает её, хотя запасное имя уже вычислено. - В orElseGet передают сам makeName. Поскольку Mira есть, правило не вызывается и счётчик больше не увеличивается.
- Первый вывод —
Mira Mira, второй —1.
Для готовой недорогой строки orElse("guest") подходит: лишней работы по её получению нет. Выбирайте метод по тому, нужно ли откладывать вычисление запасного результата, а не заменяйте одно имя другим механически.
18. Решает ли Optional все проблемы null?
Нет. Он помогает явно показать в возвращаемом типе метода: результат может отсутствовать. Например, поиск одного клиента может не найти ничего. Для поиска нескольких клиентов часто достаточно пустого списка — он уже выражает ноль найденных элементов. Обёртывание каждого поля и аргумента в Optional без нужного смысла усложняет обращение с данными.
На пустом Optional вызов get() выбрасывает NoSuchElementException, исключение об отсутствии элемента. Если значение обязательно, можно явно выразить это требование через orElseThrow, при необходимости указав своё исключение. Если отсутствие допустимо, выбирают запасной результат или действие для найденного значения. Саму переменную Optional тоже нельзя без последствий сделать null: вызов метода через такую ссылку останется ошибкой.
Есть отдельное условие у Stream. Если findFirst или findAny выбрал null-элемент, он не превращается в пустой Optional: возникнет NullPointerException. Пустой поток и поток с отсутствующей ссылкой внутри — разные входы. Поэтому заранее решите, разрешены ли null-элементы и следует ли их отбирать до поиска.
Ресурсы и итоговая проверка
19. Нужно ли закрывать Stream?
Stream из списка обычно не удерживает внешнего ресурса, который нужно специально освобождать. Но Files.lines открывает чтение файла. Такой ресурс нужно закрыть после использования, даже если обработка завершилась ошибкой. Завершающая операция вроде collect сама по себе не заменяет close.
try-with-resources — запись try с ресурсом в круглых скобках: после выхода из блока он автоматически закрывается. В примере считаем, что path уже указывает на существующий текстовый файл, а окружающий метод обрабатывает или объявляет возможную ошибку ввода-вывода. StandardCharsets.UTF_8 задаёт кодировку, то есть правило превращения байтов файла в символы.
try (Stream<String> lines = Files.lines(path, StandardCharsets.UTF_8)) {
List<String> codes = lines
.filter(line -> !line.isBlank())
.collect(Collectors.toList());
System.out.println(codes.size());
}Files.lines открывает чтение строк. Затем filter проверяет каждую строку: isBlank возвращает true для пустой строки или строки только из пробельных символов, а знак ! меняет ответ на противоположный. Поэтому дальше проходят непустые строки с содержимым. Collect собирает их в список codes, а size сообщает число строк. Если в файле были A1, пустая строка, строка из пробелов и B2, результатом будет 2. Текст непустых строк этот фильтр не обрезает.
После печати и выхода из блока файловый Stream закрывается. Если вместо списка вернуть из метода ещё не прочитанный Stream, закрывать его до использования нельзя: вызывающему коду уже нечего будет читать. Нужно определить, кто владеет открытым ресурсом и кто завершит его работу. Это требование остаётся и у короткой цепочки.
| Изменение | Вопрос к решению |
|---|---|
| Добавили parallel() | Ассоциативно ли объединение, есть ли общие изменяемые данные и требование порядка? |
| Заменили collect на count | Не зависели ли нужные действия от выполнения промежуточных операций? |
| Встретили повтор ключа | Повтор — ошибка, группа, сумма или выбор конкретной записи? |
| Получили Optional.empty() | Это допустимое отсутствие или нарушение обязательного условия задачи? |
Как потренироваться
Подготовьте небольшой список заявок с повторяющимися категориями и отсутствующими необязательными данными. Сначала словами определите нужный итог: какие заявки оставить, что вычислить из каждой и куда собрать результат. Затем назовите тип элементов после каждого этапа: заявки, строки категорий, числа или группы.
Проверьте пустой список, один элемент и повтор ключа. Объясните, какие действия выполнятся, а какие могут не понадобиться. Только после этого рассмотрите parallel и обоснуйте независимость частей. Если ответ непонятен, вернитесь к конкретному шагу: форме данных, моменту выполнения, правилу объединения или смыслу отсутствующего результата.
