Коли починається виконання операцій в Stream?
Виконання починається строго в момент виклику terminal-операції. До цього стрим — інертна структура даних.
🟢 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) виконує наступне:
- Перевіряється прапорець
linkedOrConsumed→ якщоtrue, викидаєтьсяIllegalStateException(стрим вже був використаний — стрими одноразові) - Проміжні стадії «схлопуються» в ланцюжок
Sink— об’єктів-обробників, кожен з яких приймає елемент і передає наступному - Визначається: послідовне чи паралельне виконання
- Викликається
spliterator.forEachRemaining(sink)— запускає цикл обходу даних
Тригери виконання
Всі terminal-операції запускають процес. Але не всі методи виглядають як terminal:
count(),sum(),average()— terminaltoArray()— terminalmin(),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()]]