Вопрос 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()]]