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

Коли починається виконання операцій в Stream?

Виконання починається строго в момент виклику terminal-операції. До цього стрим — інертна структура даних.

Мовні версії: English Russian Ukrainian

🟢 Junior Level

Виконання починається строго в момент виклику terminal-операції. До цього стрим — інертна структура даних.

Terminal-операції (запускають виконання): collect, forEach, count, reduce, findFirst, anyMatch, min, max, sum, toArray

Intermediate-операції (НЕ запускають): filter, map, flatMap, sorted, limit, skip, peek

// Нічого не відбувається
Stream<String> stream = list.stream()
    .filter(s -> s.length() > 3)
    .map(String::toUpperCase);

// Тільки тут починається робота
List<String> result = stream.collect(Collectors.toList());

🟡 Middle Level

Метод AbstractPipeline.evaluate()

При виклику terminal-операції внутрішній метод evaluate() (клас AbstractPipeline — основа конвеєра Stream API) виконує наступне:

  1. Перевіряється прапорець linkedOrConsumed → якщо true, викидається IllegalStateException (стрим вже був використаний — стрими одноразові)
  2. Проміжні стадії «схлопуються» в ланцюжок Sink — об’єктів-обробників, кожен з яких приймає елемент і передає наступному
  3. Визначається: послідовне чи паралельне виконання
  4. Викликається spliterator.forEachRemaining(sink) — запускає цикл обходу даних

Тригери виконання

Всі terminal-операції запускають процес. Але не всі методи виглядають як terminal:

  • count(), sum(), average() — terminal
  • toArray() — terminal
  • min(), max() — terminal

Ресурси та onClose

Стрими на I/O (Files.lines()) володіють ресурсами. Обробники закриття (onClose()) спрацюють тільки при явному close().

Правило: Завжди використовуйте try-with-resources для стримів з I/O:

try (Stream<String> lines = Files.lines(path)) {
    lines.filter(...).collect(...);
}

🔴 Senior Level

Short-circuiting execution (коротке замикання)

Terminal-операції типу anyMatch або findFirst можуть завершити виконання достроково — конвеєр перестає запитувати елементи, щойно результат стає очевидним. Аналог break у циклі for. На великих даних із селективною умовою це економить процесорний час, оскільки не всі елементи будуть оброблені.

JIT Warming

JIT (Just-In-Time компілятор JVM) не компілює лямбди в машинний код при їх оголошенні — тільки після того, як terminal-операція викличе їх достатню кількість разів («прогрів»). Це означає: перші кілька сотень викликів працюють у режимі інтерпретатора, що в 5–50 разів повільніше скомпільованого коду. Враховуйте це при бенчмарках — використовуйте JMH з proper warmup-фазами.

Edge Cases

Empty Streams: Виконання проходить усі стадії evaluate, але forEachRemaining не здійснює ітерацій.

Peek Side Effects: Якщо peek для логування, а terminal не викликана (через if-else) — логи не з’являться. Часта причина “фантомних багів”.

Діагностика

Trace IDs: У розподіленій трасировці (Sleuth/Zipkin) контекст (Span) пробрасується в момент terminal-операції — там зосереджена вся нагрузка на CPU.

Stack Traces: При помилці стек-трейс вказує на terminal-операцію як точку входу — це збиває з пантелику новачків. Реальна помилка знаходиться в одній з intermediate-лямбд, але в стек-трейсі вона буде глибоко всередині AbstractPipeline.evaluate().

Коли НЕ використовувати terminal-операції як тригер

  • Коли потрібен контроль над моментом виконання. Усі terminal-операції запускають конвеєр негайно. Якщо ви хочете відкласти виконання до певної умови — не викликайте terminal-операцію заздалегідь.
  • Коли потрібно виконати стрим двічі. Стрим одноразовий (linkedOrConsumed прапорець). Якщо вам потрібно двічі обробити одні й ті самі дані — викличте collect(toList()) для матеріалізації даних, потім працюйте з колекцією.
  • Коли джерело — зовнішній ресурс без try-with-resources. Files.lines() відкриває файловий дескриптор. Без явного close() (через try-with-resources) ресурс залишиться відкритим, що призведе до витоку файлових дескрипторів.

🎯 Шпаргалка для інтерв’ю

Обов’язково знати:

  • Terminal-операції (collect, forEach, count, findFirst, anyMatch та ін.) — єдині тригери виконання
  • До terminal-операції стрим — інертна структура даних, нічого не відбувається
  • Метод AbstractPipeline.evaluate() перевіряє прапорець linkedOrConsumed і будує ланцюжок Sink
  • Short-circuit terminal-операції (anyMatch, findFirst) можуть завершити конвеєр достроково
  • Стрими одноразові — повторне використання викине IllegalStateException
  • I/O стрими (Files.lines) потрібно закривати через try-with-resources
  • JIT не компілює лямбди до «прогріву» — перші виклики працюють в інтерпретаторі
  • peek() без terminal-операції — логи не з’являться, часте джерело «фантомних багів»

Часті уточнюючі запитання:

  • Які методи є terminal? — collect, forEach, count, reduce, findFirst, findAny, anyMatch, allMatch, noneMatch, min, max, sum, toArray.
  • Чому стрими одноразові? — Прапорець linkedOrConsumed всередині AbstractPipeline запобігає повторному використанню — це архітектурне обмеження.
  • Що відбувається при виклику terminal-операції? — evaluate() будує ланцюжок Sink-обробників і запускає spliterator.forEachRemaining(sink).
  • Чому стек-трейс при помилці збиває з пантелику? — Він вказує на terminal-операцію як точку входу, а реальна помилка — глибоко всередині intermediate-лямбди.

Червоні прапорці (НЕ говорити):

  • «Стрим можна використовувати повторно» — неправда, одноразовий
  • «Intermediate операції запускаються одразу» — неправда, тільки від terminal-операції
  • «peek() гарантує виконання побічних ефектів» — неправда, тільки за наявності terminal
  • «Files.lines() сам закриє файл» — неправда, потрібен try-with-resources

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

  • [[21. Що таке lazy evaluation в Stream]]
  • [[24. Як працює коротке замикання (short-circuiting) в Stream]]
  • [[25. Що таке операції anyMatch(), allMatch(), noneMatch()]]
  • [[26. Що роблять операції findFirst() та findAny()]]