Коли починається виконання операцій в Stream
Виконання операцій у Stream починається суворо в момент виклику термінальної (terminal) операції (collect(), toList(), forEach(), count(), findFirst(), reduce()).
🟢 Junior Level
Коротка відповідь для співбесіди (30 секунд)
Виконання операцій у Stream починається суворо в момент виклику термінальної (terminal) операції (collect(), toList(), forEach(), count(), findFirst(), reduce()).
Доти, доки термінальна операція не викликана, проміжні операції (filter(), map(), peek(), sorted()) лише конструюють опис конвеєра обробки (дерево етапів). Самі дані не зчитуються з джерела, не фільтруються і не трансформуються. Якщо термінальна операція взагалі не буде викликана, код усередині проміжних лямбд ніколи не виконається.
Базовий приклад на Java 21
import java.util.List;
import java.util.stream.Stream;
public class ExecutionStartDemo {
public static void main(String[] args) {
List<String> items = List.of("стіл", "стілець", "шафа");
// 1. Проміжні операції: код усередині лямбди зараз НЕ виконується!
Stream<String> stream = items.stream()
.filter(item -> {
System.out.println("[Filter] Перевірка елемента: " + item);
return item.startsWith("с");
})
.map(String::toUpperCase);
System.out.println("Конвеєр сконфігуровано, термінальна операція НЕ викликалася.");
// 2. Виклик термінальної операції — САМЕ ЗАРАЗ починається реальна обробка:
System.out.println("Викликаємо термінальну операцію toList()...");
List<String> result = stream.toList();
System.out.println("Результат: " + result);
}
}
Виведення програми:
Конвеєр сконфігуровано, термінальна операція НЕ викликалася.
Викликаємо термінальну операцію toList()...
[Filter] Перевірка елемента: стіл
[Filter] Перевірка елемента: стілець
[Filter] Перевірка елемента: шафа
Результат: [СТІЛ, СТІЛЕЦЬ]
🟡 Middle Level
Класифікація: Intermediate vs Terminal
| Тип операції | Приклади методів | Що повертає | Чи запускає обробку? |
|---|---|---|---|
| Intermediate (Ліниві) | filter, map, flatMap, distinct, sorted, peek, limit, skip |
Stream<T> / IntStream |
❌ Ні (лише додає вузол у пайплайн) |
| Terminal (Ініціюючі) | collect, toList, forEach, reduce, count, findFirst, anyMatch |
Значення (List, Optional, long, void) |
✅ Так (тригер усього обчислення) |
Механіка запуску: Складання ланцюжка Sink під капотом
Коли викликається термінальна операція (наприклад, collect(Collectors.toList())), у глибинах AbstractPipeline відбувається таке:
- Перевірка одноразовості: Перевіряється прапорець
linkedOrConsumed. Якщо він ужеtrue, викидаєтьсяIllegalStateException. Потім прапорець виставляється вtrue. - Складання ланцюжка
Sinkу зворотному порядку: Починаючи з термінального споживача, рантайм викликає методopWrapSink()назад ланцюжком вузлів до початку стріма, формуючи вкладену матрьошку з об’єктівSink:[Source Spliterator] │ ▼ Sink(filter) ──► перевіряє предикат; якщо true ──► │ ▼ Sink(map) ──► перетворює елемент ──► │ ▼ Sink(terminal collect) ──► додає у підсумковий ArrayList - Запуск протягування:
sourceSpliterator.forEachRemaining(firstSink)починає послідовно виштовхувати елементи джерела в першийSink.
🔴 Senior Level
Байт-код та інлайнінг: invokedynamic і LambdaMetafactory
При компіляції виклику проміжної операції:
- Лямбда-вираз компілюється в байт-код інструкцію
invokedynamic(CallSite). - При першому проході JVM через
LambdaMetafactoryгенерує легковаговий анонімний внутрішній клас, що реалізує цільовий функціональний інтерфейс (наприклад,Predicate). - Тіло самої лямбди компілюється в синтетичний статичний метод поточного класу (
lambda$main$0). - Сам виклик згенерованого методу відбувається лише тоді, коли метод
accept()відповідногоSinkвикликається сплітератором.
Анатомія стек-трейсів винятків (Stream Exception Call Stack)
Якщо помилка (наприклад, NullPointerException або ділення на нуль) трапляється глибоко всередині проміжного map(), у стек-трейсі точка виклику завжди вказує на термінальну операцію:
Exception in thread "main" java.lang.NullPointerException
at com.example.Demo.lambda$main$0(Demo.java:18) <-- Помилка в лямбді всередині map()
at java.base/java.util.stream.ReferencePipeline$3$1.accept(...)
at java.base/java.util.Spliterators$ArraySpliterator.forEachRemaining(...)
at java.base/java.util.stream.AbstractPipeline.copyInto(...)
at java.base/java.util.stream.AbstractPipeline.wrapAndCopyInto(...)
at java.base/java.util.stream.ReduceOps$ReduceOp.evaluateSequential(...)
at java.base/java.util.stream.AbstractPipeline.evaluate(...)
at java.base/java.util.stream.ReferencePipeline.collect(...) <-- ТОЧКА СТАРТУ ОБЧИСЛЕННЯ
at com.example.Demo.main(Demo.java:24)
Саме виклик термінальної операції розкручує цикл forEachRemaining() та виклики Sink.accept().
JIT C2-Оптимізація: Схлопування в один плаский цикл
Хоча архітектура Stream API з вкладеними Sink виглядає великоваговою (велика кількість поліморфних викликів інтерфейсів):
- Після прогріву (Tiered Compilation / C2 JIT) HotSpot компілятор повністю інлайнить (inlines) ланцюжок викликів
Sink.accept(). - Завдяки Escape Analysis проміжні об’єкти
Sinkта замикання усуваються через скалярне заміщення (Scalar Replacement). - У машинному коді конвеєр стріма перетворюється на один плаский асемблерний цикл за масивом, практично не поступаючись за швидкістю ручному циклу
for.
🎯 Шпаргалка для інтерв’ю
30-секундна відповідь
«Виконання операцій у стрімі починається виключно в момент виклику термінальної операції (
collect,toList,forEach,count). Проміжні операції ліниві й лише будують зворотний ланцюжок обробниківSink. При виклику термінальної операції методAbstractPipeline.evaluate()збирає ланцюжокSink, перевіряє прапорець одноразовості та передає головнийSinkуSpliterator.forEachRemaining(). Після цього елементи по одному протягуються крізь увесь конвеєр (Loop Fusion). При прогріві JIT-компілятор інлайнить ланцюжок викликівSinkу єдиний плаский цикл.»
Життєвий цикл стріма за кроками
1. collection.stream() ──► Створюється ReferencePipeline.Head з вихідним Spliterator
2. .filter(...) ──► Додається вузол StatelessOp (робота НЕ почалася)
3. .map(...) ──► Додається вузол StatelessOp (робота НЕ почалася)
4. .toList() ──► ВИКЛИК ТЕРМІНАЛЬНОЇ ОПЕРАЦІЇ:
a) Перевірка linkedOrConsumed == false
b) Складання ланцюжка Sink від кінця до початку
c) Запуск Spliterator.forEachRemaining(firstSink)
d) Завершення, закриття конвеєра, повернення результату
Топ-4 каверзних запитання на інтерв’ю
1. Як AbstractPipeline збирає ланцюжок Sink у зворотному напрямку?
- Термінальна операція надає початковий
Sink(накопичувач результату). МетодwrapSink()проходить за зв’язним списком вузлів стріма від хвоста до голови: кожен вузол огортає переданий йому downstream-Sinkу свій власнийSink(наприклад,filterогортаєcollect, аmapогортаєfilter). У результаті виходить ланцюжок, де перший викликaccept()відповідає першій проміжній операції.
2. Що станеться, якщо у стрімі оголошено filter та map із виведенням у консоль, але термінальна операція не викликана?
- Код успішно скомпільовується і виконається за мікросекунди, але в консоль не буде виведено абсолютно нічого. Без термінальної операції ланцюжок
Sinkне збирається іSpliteratorне опитується.
3. Чому в стек-трейсі помилка з map() падає в рядку виклику collect()?
- Лямбда всередині
map()виконується не в момент виклику методу.map(), а в момент проходу елементів через конвеєр, який ініціюється методомcollect(). Термінальний метод запускає ітератор джерела даних, тому він знаходиться в корені стек-трейса виконання.
4. Чи може термінальна операція взагалі не запускати обхід елементів?
- Так, у випадку шорт-циркуїту на порожньому джерелі або при оптимізаціях рантайму Java 9+: виклик
.count()на стрімі з колекціїSIZEDбез фільтрації бере розмір зі сплітератораsize()безпосередньо, не читаючи жодного елемента.
Типові помилки та Red Flags
- 🚩 Забування викликати термінальну операцію (написання
stream.filter(...).map(...)без завершення). - 🚩 Припущення, що
.stream()запускає фоновий потік обробки. - 🚩 Спроба викликати термінальну операцію двічі на одному об’єкті стріма.
- 🚩 Очікування, що проміжні операції виконуються суворо «за шарами» для всіх елементів одразу.