Вопросы по Stream API на собеседовании Java
Stream API спрашивают почти всегда, и главный вопрос звучит одинаково: почему цепочка ничего не сделала без терминальной операции. Дальше проверяют коллекторы и понимание, что параллельный стрим не бесплатный.
Из чего состоит тема
Так тема разложена в тренажёре: движок ведёт прогресс по каждой подтеме отдельно и возвращает те, где вы ошибаетесь.
- Коллекторы и группировка20
- Операции стрима18
- Стримы: продвинутое17
- Лямбды и функц-интерфейсы13
- Optional и null12
Разборы подтем
Конспект по каждой: что это, как отвечать вслух, на чём валятся, плюс вопросы для самопроверки.
- Коллекторы и группировка в Java20 вопросов
- Лямбды и функциональные интерфейсы13 вопросов
- Optional и работа с null12 вопросов
- Операции Stream API18 вопросов
- Stream API: продвинутые приёмы17 вопросов
Примеры вопросов с разбором
- Что делает collect(Collectors.toList())?A)Собирает элементы стрима в ListB)Складывает элементы в неизменяемый массив фиксированного размера, равного числу элементов стримаC)Подсчитывает количество элементов в стриме и возвращает его в виде целого числа типа longD)Соединяет все элементы в одну строку через запятую, вызывая у каждого метод toString
показать ответ и разбор
+A)Собирает элементы стрима в List// разбор: collect — универсальная терминальная операция сведения (reduction) в контейнер, а Collectors.toList() — готовый коллектор, материализующий элементы в List. Есть много готовых: toSet, toMap, joining, counting, groupingBy, partitioningBy, summingInt и др. (Отдельно Stream.toList() с Java 16 сразу даёт неизменяемый список.) collect работает и с параллельными стримами (у коллектора есть supplier/accumulator/combiner). Это основной способ «выгрузить» результат стрима в структуру данных.
- Что такое функциональный интерфейс?A)Интерфейс ровно с одним абстрактным методом (SAM)B)Интерфейс, все методы которого объявлены как default и имеют готовую реализацию по умолчаниюC)Интерфейс, помеченный аннотацией @FunctionalInterface, даже если абстрактных методов в нём несколькоD)Специальный класс из пакета java.util.function, экземпляр которого не получится создать напрямую через new
показать ответ и разбор
+A)Интерфейс ровно с одним абстрактным методом (SAM)// разбор: Функциональный интерфейс — интерфейс с РОВНО ОДНИМ абстрактным методом (Single Abstract Method). Именно под него компилятор подставляет лямбду или ссылку на метод. default- и static-методы, а также методы Object (equals/hashCode) не считаются — их может быть сколько угодно. Аннотация @FunctionalInterface необязательна, но заставляет компилятор проверить это условие. Примеры: Runnable, Comparator, Predicate, Function.
- Зачем нужен Optional<T>?A)Явно выразить возможное отсутствие значения вместо возврата nullB)Чтобы избавить программу от null: после введения Optional NullPointerException больше не возникаетC)Чтобы автоматически кэшировать результат метода и не вычислять его повторно при следующих вызовахD)Чтобы сделать поле потокобезопасным — Optional синхронизирует доступ к обёрнутому значению
показать ответ и разбор
+A)Явно выразить возможное отсутствие значения вместо возврата null// разбор: Optional<T> — контейнер, который либо содержит значение, либо пуст. Его цель — сделать ОТСУТСТВИЕ значения явным в сигнатуре метода (Optional<User> findUser(...)), чтобы вызывающий не забыл обработать «не найдено», как это бывает с молчаливым null. Он поощряет безопасные операции (map/filter/orElse) вместо ручных проверок на null. Но это не «серебряная пуля»: сам Optional может быть использован неправильно (Optional.get без проверки, Optional-поля), и NPE так не исчезает магически.
- Выполнится ли цепочка stream.filter(...).map(...) без терминальной операции?A)Нет — промежуточные операции ленивы, без терминальной ничего не запуститсяB)Да, filter и map выполняются немедленно в момент их вызова, независимо от наличия терминальной операцииC)Да, но только filter; map откладывается до вызова терминальной операции сбора результатаD)Нет, потому что такая цепочка не скомпилируется без завершающего вызова collect в конце
показать ответ и разбор
+A)Нет — промежуточные операции ленивы, без терминальной ничего не запустится// разбор: Промежуточные операции (filter, map, sorted, distinct, limit) ЛЕНИВЫ: они лишь строят конвейер и возвращают новый Stream, ничего не вычисляя. Реальная обработка запускается ТЕРМИНАЛЬНОЙ операцией (collect, forEach, reduce, count, findFirst и т.п.), которая «протягивает» элементы через весь конвейер — обычно за один проход и поэлементно. Без терминальной операции стрим бесполезен: лямбды в map/filter даже не вызовутся. Это и позволяет оптимизации (слияние операций, короткое замыкание).
- Что делает flatMap в стриме?A)Разворачивает каждый элемент в стрим и склеивает их в один плоский стримB)Фильтрует стрим, оставляя только элементы, которые сами являются коллекциями или массивамиC)Сортирует вложенные коллекции по возрастанию и объединяет их в один отсортированный стримD)Преобразует стрим в плоскую строку, соединяя строковые представления всех элементов подряд
показать ответ и разбор
+A)Разворачивает каждый элемент в стрим и склеивает их в один плоский стрим// разбор: flatMap(f) применяет к каждому элементу функцию, возвращающую СТРИМ, и «сплющивает» все получившиеся под-стримы в один. Это операция «один-ко-многим»: List<List<Order>> → все Order одним стримом (flatMap(List::stream)); строки → все слова (flatMap(s -> Arrays.stream(s.split(" ")))). map даёт Stream<Stream<...>> — неудобную вложенность, а flatMap её убирает. Есть и примитивные flatMapToInt и др. Ключевой инструмент для работы с вложенными структурами.
- Что вернёт Collectors.groupingBy(Employee::getDept)?A)List<Dept> — просто список всех встретившихся отделов без сотрудников, по одному разу каждыйB)Map<Dept, List<Employee>> — элементы, сгруппированные по ключуC)Map<Employee, Dept> — отображение каждого сотрудника на его отдел в виде пар ключ-значениеD)Set<Employee> — множество сотрудников с удалёнными дубликатами по признаку одинакового отдела
показать ответ и разбор
+B)Map<Dept, List<Employee>> — элементы, сгруппированные по ключу// разбор: groupingBy(classifier) собирает стрим в Map, где КЛЮЧ — результат классификатора (отдел), а ЗНАЧЕНИЕ по умолчанию — List элементов этой группы. Это SQL-подобный GROUP BY. Мощь — во втором аргументе (downstream-коллектор): groupingBy(dept, counting()) даёт число на отдел, groupingBy(dept, mapping(Employee::getName, toList())) — имена, groupingBy(dept, averagingInt(...)) — среднее. Так одной строкой считают агрегаты по группам.
- Что означает, что лямбда захватывает «effectively final» переменную?A)Лямбда может менять захваченную локальную переменную, и её новое значение видно снаружи методаB)Локальную переменную можно использовать в лямбде, только если её не переприсваиваютC)Лямбда захватывает копию переменной, поэтому переменную обязательно нужно объявлять с ключевым словом finalD)Захватить можно и изменяемую локальную переменную, а effectively final относится только к полям класса
показать ответ и разбор
+B)Локальную переменную можно использовать в лямбде, только если её не переприсваивают// разбор: Лямбда (и анонимный класс) может использовать локальную переменную окружающего метода, только если та ФАКТИЧЕСКИ финальна: после инициализации ей не присваивают новое значение. Явно писать final не нужно — достаточно не переприсваивать. Причина: лямбда может пережить метод, а локальная переменная живёт в стеке — захватывается её значение (снимок), и разрешать изменения было бы неоднозначно. Для «изменяемого» состояния используют поле объекта или обёртку (массив/AtomicInteger).
- Чем orElse(x) отличается от orElseGet(supplier)?A)OrElse ленив и вызывает выражение лишь при пустом Optional, а orElseGet вычисляет значение заранееB)orElse вычисляет x всегда (даже если значение есть); orElseGet — лениво, только если пустоC)Они полностью идентичны; orElseGet добавили лишь для более короткой записи того же самого поведенияD)OrElse возвращает Optional, а orElseGet — уже развёрнутое значение внутри обёртки без распаковки
показать ответ и разбор
+B)orElse вычисляет x всегда (даже если значение есть); orElseGet — лениво, только если пусто// разбор: Тонкое, но важное отличие. orElse(x): аргумент x — ОБЫЧНОЕ выражение, вычисляемое ВСЕГДА, ещё до проверки, даже если Optional непустой. Если x — дорогой вызов (новый объект, обращение к БД), это лишняя работа/побочный эффект. orElseGet(supplier): supplier вызывается ЛЕНИВО — только когда Optional пуст. Правило: константа/дешёвое значение — orElse; дорогое/с побочным эффектом — orElseGet. Частая ошибка производительности — orElse(expensiveCall()).
- Можно ли повторно использовать один и тот же объект Stream после терминальной операции?A)Да, стрим переиспользуется автоматически: после терминальной операции он сбрасывается в началоB)Нет — стрим одноразовый; повторный вызов бросит IllegalStateExceptionC)Да, но нужно вызвать метод reset() перед повторным использованием, иначе результат будет пустымD)Да, стрим можно обходить сколько угодно раз, как обычную коллекцию List или Set в цикле
показать ответ и разбор
+B)Нет — стрим одноразовый; повторный вызов бросит IllegalStateException// разбор: Stream — ОДНОРАЗОВЫЙ конвейер, а не хранилище: после выполнения терминальной операции он считается «потраченным», и любая новая операция на нём бросает IllegalStateException: stream has already been operated upon or closed. Если данные нужно обойти дважды — создают новый стрим из источника (например, list.stream() ещё раз). Поэтому стрим не хранят в поле для многократного обхода — хранят коллекцию/Supplier<Stream>. Это отличает стрим от коллекции (которую можно перебирать многократно).
это 9 из 80
Ещё 71 вопросов по теме — в тренажёре, с движком повторения
Прочитать разбор и ответить самому — разные навыки. В Сеньорчике вопросы идут сессиями, а движок возвращает подтемы, где вы ошибаетесь, пока они не начнут отскакивать. Бесплатно, лимит по энергии.
Частые вопросы
Чем промежуточные операции отличаются от терминальных?
Промежуточные только строят конвейер и ничего не вычисляют, работа начинается на терминальной операции. Отсюда и типичная ошибка с потоком, у которого нет завершающего вызова.
Когда параллельный стрим вредит?
На маленьких коллекциях, при работе с общим изменяемым состоянием и внутри и без того загруженного пула. Накладные расходы на разбиение часто съедают весь выигрыш.