🌊 Розділ 8 · Питання #22

Коли починається виконання операцій в 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 відбувається таке:

  1. Перевірка одноразовості: Перевіряється прапорець linkedOrConsumed. Якщо він уже true, викидається IllegalStateException. Потім прапорець виставляється в true.
  2. Складання ланцюжка Sink у зворотному порядку: Починаючи з термінального споживача, рантайм викликає метод opWrapSink() назад ланцюжком вузлів до початку стріма, формуючи вкладену матрьошку з об’єктів Sink:
    [Source Spliterator]
            │
            ▼
    Sink(filter) ──► перевіряє предикат; якщо true ──►
            │
            ▼
    Sink(map) ──► перетворює елемент ──►
            │
            ▼
    Sink(terminal collect) ──► додає у підсумковий ArrayList
    
  3. Запуск протягування: sourceSpliterator.forEachRemaining(firstSink) починає послідовно виштовхувати елементи джерела в перший Sink.

🔴 Senior Level

Байт-код та інлайнінг: invokedynamic і LambdaMetafactory

При компіляції виклику проміжної операції:

  1. Лямбда-вираз компілюється в байт-код інструкцію invokedynamic (CallSite).
  2. При першому проході JVM через LambdaMetafactory генерує легковаговий анонімний внутрішній клас, що реалізує цільовий функціональний інтерфейс (наприклад, Predicate).
  3. Тіло самої лямбди компілюється в синтетичний статичний метод поточного класу (lambda$main$0).
  4. Сам виклик згенерованого методу відбувається лише тоді, коли метод 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() запускає фоновий потік обробки.
  • 🚩 Спроба викликати термінальну операцію двічі на одному об’єкті стріма.
  • 🚩 Очікування, що проміжні операції виконуються суворо «за шарами» для всіх елементів одразу.

Пов’язані теми