🌊 Розділ 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.

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