🌊 Раздел 8 · Вопрос #2

В чём разница между intermediate и terminal операциями

Все операции в Stream API строго разделяются на две фундаментальные категории:


🟢 Junior Level

Краткий ответ для собеседования (30 секунд)

Все операции в Stream API строго разделяются на две фундаментальные категории:

  1. Промежуточные (Intermediate): Всегда возвращают новый объект Stream<T> и являются ленивыми (Lazy). Они не выполняют реальных вычислений и не обходят элементы источника, а лишь декларируют следующий шаг конвейера. В цепочке их может быть сколько угодно ($0, 1, 2 \dots N$).
  2. Терминальные (Terminal): Возвращают конкретный результат, отличный от Stream (коллекцию, примитив, Optional, или void), и являются жадными (Eager). Вызов терминальной операции запускает реальное прохождение данных через весь конвейер, после чего стрим считается потребленным (закрытым) и повторно использован быть не может. В цепочке может быть ровно одна терминальная операция.

Сравнительная таблица

Критерий Intermediate (Промежуточные) Terminal (Терминальные)
Возвращаемый тип Всегда Stream<T> (или примитивный IntStream и т.д.) Результат не-Stream (List, Map, long, boolean, void)
Время выполнения Ленивые (Lazy): откладываются до терминального вызова Жадные (Eager): немедленно запускают обход элементов
Количество в цепочке Любое количество ($0, 1, 2 \dots N$) Строго одна в самом конце цепочки
Жизненный цикл Конструируют граф стадий конвейера Потребляют («сжигают») стрим навсегда
Примеры методов filter(), map(), flatMap(), sorted(), distinct(), limit() collect(), toList(), forEach(), count(), reduce(), findFirst()

Базовый пример на Java 21: Доказательство ленивости

import java.util.List;

public class StreamLifecycleDemo {
    public static void main(String[] args) {
        List<String> names = List.of("alex", "anna", "boris");

        System.out.println("--- Шаг 1: Конструируем промежуточные операции ---");
        var stream = names.stream()
            .filter(name -> {
                System.out.println("Фильтрация элемента: " + name);
                return name.startsWith("a");
            })
            .map(name -> {
                System.out.println("Трансформация элемента: " + name);
                return name.toUpperCase();
            });

        // На этом этапе в консоли АБСОЛЮТНО ПУСТО! Ни один элемент не был обработан!
        System.out.println("--- Шаг 2: Вызываем терминальную операцию toList() ---");
        List<String> result = stream.toList();

        System.out.println("--- Результат: " + result + " ---");
    }
}

Вывод программы наглядно показывает, что вычисления начинаются только на шаге 2:

--- Шаг 1: Конструируем промежуточные операции ---
--- Шаг 2: Вызываем терминальную операцию toList() ---
Фильтрация элемента: alex
Трансформация элемента: alex
Фильтрация элемента: anna
Трансформация элемента: anna
Фильтрация элемента: boris
--- Результат: [ALEX, ANNA] ---

🟡 Middle Level

Внутренняя классификация операций Stream API

                                  ┌─── Stateless (filter, map, flatMap, peek)
            ┌─── Intermediate ────┤
            │   (Ленивые)         └─── Stateful (sorted, distinct, limit, skip)
Stream API ─┤
            │                     ┌─── Eager (collect, reduce, forEach, toList, count)
            └─── Terminal ────────┤
                (Запускающие)     └─── Short-circuit (findFirst, findAny, anyMatch, allMatch)

1. Промежуточные: Stateless vs Stateful

  • Stateless (Без сохранения состояния): Обрабатывают каждый элемент независимо друг от друга в потоковом режиме («на лету»). Элемент сразу передается на следующую стадию конвейера (filter, map, flatMap). Прекрасно масштабируются в параллельных стримах.
  • Stateful (С сохранением состояния): Для обработки текущего элемента требуют информации о других элементах:
    • distinct() — хранит хэш-таблицу уже встреченных элементов.
    • limit(n) и skip(n) — поддерживают внутренний счетчик.
    • sorted() — барьер конвейера (Pipeline Barrier): не может передать ни одного элемента downstream, пока не накопит абсолютно все $N$ элементов источника во временный массив и не отсортирует их.

2. Терминальные: Eager vs Short-circuit

  • Eager (Полные): Обязаны обойти весь источник данных до последнего элемента (collect(), reduce(), count()).
  • Short-circuit (Короткое замыкание): Способны досрочно завершить обход, как только получен необходимый результат:
    • findFirst(), findAny() — останавливаются при нахождении первого подходящего элемента.
    • anyMatch(Predicate) — немедленно возвращает true, как только предикат вернул true.
    • Критическая роль: Только операции с коротким замыканием способны корректно завершить обработку бесконечных стримов (Stream.generate() или Stream.iterate()).

🔴 Senior Level

Архитектура конвейера: AbstractPipeline и цепочка Sink

Внутри JDK реализация стримов построена на двусвязном списке стадий конвейера (java.util.stream.ReferencePipeline наследует AbstractPipeline):

  • Вызовы filter(), map() конструируют связанные узлы конвейера (previousStage, nextStage), сохраняя флаги характеристик (Stream Flags: SIZED, DISTINCT, ORDERED).
  • В момент вызова терминальной операции запускается метод AbstractPipeline.evaluate():
    1. Метод wrapSink() обходит конвейер в обратном порядке (от терминальной стадии к источнику) и строит единую цепочку функциональных интерфейсов Sink<T>.
    2. Интерфейс Sink<T> содержит 4 ключевых метода:
      • begin(long size) — подготовка к приему данных (выделение точной памяти, если размер известен).
      • accept(T value) — передача одного элемента следующей стадии конвейера.
      • cancellationRequested() — механизм короткого замыкания (Short-Circuiting): опрашивается источником перед передачей каждого следующего элемента.
      • end() — завершение работы конвейера.
    3. Pipeline Fusion: Все промежуточные stateless-стадии объединяются в один проход: элемент передается через Sink.filter.accept(), который внутри вызывает Sink.map.accept(). JIT-компилятор HotSpot C2 агрессивно инлайнит эти вызовы, превращая весь стрим в монолитный плоский машинный цикл.

Архитектурная ловушка: Утечка ленивого стрима через границы транзакций (Leaking Stream)

Одна из самых распространенных ошибок в enterprise-приложениях на Spring Boot / JPA:

// ❌ ГРУБЕЙШИЙ АНТИПАТТЕРН: Стрим возвращается наружу транзакционного сервиса
@Service
public class UserService {

    @Transactional(readOnly = true)
    public Stream<UserEntity> getActiveUsers() {
        // Запрос к БД возвращает Stream (через JPA / Spring Data)
        return userRepository.streamAllByStatus(Status.ACTIVE)
                             .filter(UserEntity::isEmailVerified); // Intermediate операция!
    }
}

// В Web-контроллере:
@RestController
public class UserController {
    @Autowired private UserService userService;

    @GetMapping("/users")
    public List<UserDto> getUsers() {
        Stream<UserEntity> userStream = userService.getActiveUsers();
        
        // 💥 ВЗРЫВ В RUNTIME: LazyInitializationException / JDBC Connection Closed!
        // Метод getActiveUsers() завершился -> транзакция закрыта -> JDBC Connection закрыто!
        // Терминальная операция toList() пытается вычитать данные из закрытого соединения!
        return userStream.map(UserDto::from).toList();
    }
}

Идиоматическое решение:

Стрим должен быть полностью закрыт терминальной операцией внутри того слоя, который открыл транзакцию или I/O-ресурс:

@Transactional(readOnly = true)
public List<UserDto> getActiveUsersDto() {
    try (Stream<UserEntity> stream = userRepository.streamAllByStatus(Status.ACTIVE)) {
        return stream.filter(UserEntity::isEmailVerified)
                     .map(UserDto::from)
                     .toList(); // Терминальная операция вызвана ДО закрытия транзакции!
    }
}

🎯 Шпаргалка для интервью

Вопросы для быстрой проверки

  1. Что произойдет, если в стриме вызвать несколько промежуточных операций, но не вызвать терминальную? Ответ: Не произойдет ничего. Ни один элемент источника не будет вычитан, и ни одна лямбда не выполнится, так как промежуточные операции ленивы (lazy) и только формируют конвейер стадий.
  2. Почему операция sorted() называется барьером конвейера (Pipeline Barrier)? Ответ: sorted() является stateful-операцией. Невозможно определить, какой элемент должен быть первым, пока не прочитаны все элементы источника. Поэтому sorted() полностью останавливает потоковую передачу данных downstream, накапливает весь поток в буфер памяти, выполняет сортировку и только затем передает элементы дальше.
  3. Как механизм Sink реализует досрочную остановку стрима (Short-Circuiting) в таких операциях, как findFirst() или limit()? Ответ: Через метод Sink.cancellationRequested(). Источник данных (или предшествующая стадия) перед передачей очередного элемента проверяет этот флаг. Если терминальная операция нашла нужный элемент, флаг возвращает true, и генерация элементов немедленно прекращается.
  4. Можно ли использовать один и тот же объект Stream повторно после вызова терминальной операции? Ответ: Нет, нельзя. Стрим является одноразовым объектом. При попытке повторного вызова любой операции на уже закрытом стриме будет выброшено IllegalStateException: stream has already been operated upon or closed.

Типичные ошибки и Red Flags

  • ❌ Утверждение: “Intermediate операция создает промежуточный список ArrayList в памяти”: Стрим использует Pipeline Fusion и передает по одному элементу сквозь цепочку Sink без промежуточных буферов (за исключением stateful операций вроде sorted).
  • ❌ Считать forEach() промежуточной операцией: forEach() возвращает void и является терминальной операцией, сжигающей стрим.
  • ❌ Возврат незакрытого Stream<T> из @Transactional методов репозиториев/сервисов: Приводит к падениям из-за закрытия соединений к БД до вызова терминальной операции.
  • ❌ Попытка применить жадную операцию (sorted(), count()) к бесконечному стриму: Приводит к зависанию потока или падению по OutOfMemoryError.

Связанные темы