🌊 Розділ 8 · Питання #20

Чи можна повторно використовувати Stream

Щойно на стрімі викликана будь-яка термінальна операція (наприклад, collect(), forEach(), count(), reduce()), стрім вважається повністю вичерпаним (consumed) і закривається. Буд...


🟢 Junior Level

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

Ні, повторно використовувати Stream категорично не можна. У Java об’єкт Stream являє собою одноразовий обчислювальний конвеєр.

Щойно на стрімі викликана будь-яка термінальна операція (наприклад, collect(), forEach(), count(), reduce()), стрім вважається повністю вичерпаним (consumed) і закривається. Будь-яка спроба викликати термінальну операцію повторно на тому самому об’єкті стріма негайно викине виняток: IllegalStateException: stream has already been operated upon or closed.

Щоб обробити ті самі дані наново, необхідно створити новий екземпляр стріма з вихідної колекції чи джерела.


Приклад поведінки на Java 21

import java.util.List;
import java.util.stream.Stream;

public class ReusingStreamDemo {

    public static void main(String[] args) {
        List<String> cities = List.of("Київ", "Львів", "Одеса");

        Stream<String> cityStream = cities.stream();

        // 1-й запуск термінальної операції — виконається штатно
        cityStream.forEach(System.out::println);

        // 2-й запуск термінальної операції на ТОМУ САМОМУ об'єкті:
        try {
            long count = cityStream.count(); // ❌ Викине IllegalStateException
        } catch (IllegalStateException e) {
            System.err.println("Помилка: " + e.getMessage());
            // stream has already been operated upon or closed
        }

        // ✅ ПРАВИЛЬНО: створювати новий стрім для кожного проходу
        long validCount = cities.stream().count();
        System.out.println("Кількість міст: " + validCount);
    }
}

🟡 Middle Level

Внутрішня будова: прапорець linkedOrConsumed в AbstractPipeline

У вихідному коді OpenJDK (java.util.stream.AbstractPipeline) кожен вузол конвеєра стріма зберігає внутрішній стан готовності:

// Спрощений фрагмент реалізації в AbstractPipeline.java:
private boolean linkedOrConsumed;

final <R> R evaluate(TerminalOp<E_OUT, R> terminalOp) {
    if (linkedOrConsumed) {
        throw new IllegalStateException("stream has already been operated upon or closed");
    }
    linkedOrConsumed = true; // Стрім незворотно позначається як спожитий
Locale
    return isParallel()
           ? terminalOp.evaluateParallel(this, sourceSpliterator(terminalOp.getOpFlags()))
           : terminalOp.evaluateSequential(this, sourceSpliterator(terminalOp.getOpFlags()));
}

Чому архітектор Java (Brian Goetz) зробив стріми одноразовими

  1. Потоки з небуферизованих зовнішніх джерел: Джерелом стріма може бути сокет (SocketChannel), генератор випадкових чисел або нескінченний лог, які фізично неможливо «перемотати назад» без копіювання.
  2. Нульова витрата пам’яті (Low Memory Footprint): Якби стрім дозволяв повторні проходи, рантайм був би зобов’язаний кешувати в оперативній пам’яті (heap) усі коли-небудь згенеровані та трансформовані елементи. Це перетворило б легкий конвеєр на важку колекцію і призвело б до OutOfMemoryError на великих обсягах.
  3. Безпека паралелізму (parallelStream): Одноразовість захищає від стану гонки та повторного входу робочих потоків ForkJoinPool у конвеєр, що вже завершився.

Патерн Supplier<Stream<T>> для повторного створення

Якщо алгоритм вимагає багаторазової генерації стріма з однаковими фільтрами та перетвореннями, використовується функціональний інтерфейс Supplier:

import java.util.List;
import java.util.function.Supplier;
import java.util.stream.Stream;

public class StreamSupplierPatternDemo {

    public static void main(String[] args) {
        List<String> data = List.of("apple", "banana", "avocado", "apricot");

        // Фабрика стрімів: обчислює конвеєр щоразу заново
        Supplier<Stream<String>> streamSupplier = () -> data.stream()
            .filter(s -> s.startsWith("a"));

        // Перший прохід
        streamSupplier.get().forEach(System.out::println);

        // Другий прохід — новий об'єкт Stream, помилки немає!
        long count = streamSupplier.get().count();
        System.out.println("Починаються на 'a': " + count);
    }
}

🔴 Senior Level

Архітектурний антипатерн: Stream<T> в аргументах публічного методу

У дизайні бібліотек та інтерфейсів прийом Stream<T> як вхідного параметра публічного методу вважається грубою проєктною помилкою:

// ❌ АНТИПАТЕРН: Прийом Stream у публічному API
public void processOrders(Stream<Order> orderStream) {
    long total = orderStream.count(); // Стрім вичерпано!
    // orderStream.forEach(this::save); // ВПАДЕ В РАНТАЙМІ!
}

// ✅ ПРАВИЛЬНИЙ ДИЗАЙН:
// 1. Для колекцій у пам'яті:
public void processOrders(Collection<Order> orders) { /* ... */ }

// 2. Для лінивих/важких генерацій:
public void processOrders(Supplier<Stream<Order>> orderStreamSupplier) { /* ... */ }

Витоки ресурсів (Resource Leaks) у стрімах з AutoCloseable

Стріми, що працюють із дескрипторами введення-виведення операційної системи (Files.lines(), Files.list(), Files.walk()), відкривають реальні файлові дескриптори ОС і реалізують інтерфейс AutoCloseable.

При роботі з ними обов’язкова конструкція try-with-resources:

import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.stream.Stream;

public class StreamResourceLeakDemo {

    public static void readSafely(Path path) throws IOException {
        // Обов'язкове закриття дескриптора ОС через try-with-resources:
        try (Stream<String> lines = Files.lines(path)) {
            lines.filter(line -> line.contains("ERROR"))
                 .forEach(System.out::println);
        } // lines.close() гарантовано викликається при виході
    }
}

Якщо такий стрім не закрити, файловий дескриптор в ОС залишиться заблокованим аж до спрацьовування фіналізатора/клінера GC, що у високонавантажених системах призводить до IOException: Too many open files.


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

30-секундна відповідь

«Стріми в Java суворо одноразові. При виклику термінальної операції внутрішній прапорець linkedOrConsumed в AbstractPipeline перемикається в true, і будь-яка повторна спроба запуску кидає IllegalStateException. Стрім — це не сховище даних, а процес їх обробки; повторне використання вимагало б кешування всіх елементів у купі, що вбило б головну перевагу Stream API — мінімальну витрату пам’яті. Для повторного обходу потрібно створювати новий стрім або використовувати патерн Supplier<Stream<T>>. Стріми, що читають файли (Files.lines()), вимагають закриття через try-with-resources.»


Порівняння Stream<T> та Iterable<T>

Характеристика Iterable<T> (Колекції) Stream<T> (Конвеєр)
Кількість проходів Необмежено (кожен .iterator() новий) Суворо один раз
Зберігання даних Зберігає елементи в пам’яті структури Не зберігає дані (тільки обчислення)
Обчислення Негайні (Eager) Ліниві (Lazy)
Підтримка паралелізму Вручну через потоки Вбудована через parallel()
Керування ресурсами Пам’ять у купі Може тримати дескриптори ОС (AutoCloseable)

Топ-4 каверзних запитання на інтерв’ю

1. Що відбувається під капотом при повторній спробі викликати термінальну операцію на об’єкті Stream?

  • Метод evaluate() в AbstractPipeline перевіряє логічне поле linkedOrConsumed. Якщо воно вже дорівнює true, викидається IllegalStateException("stream has already been operated upon or closed"). Повторне виконання конвеєра фізично блокується рантаймом.

2. Чому в Java Stream API принципово не передбачили метод reset() для скидання стріма на початок?

  • Тому що джерелом стріма може бути не лише масив чи список у пам’яті, але й односпрямований мережевий сокет, генератор випадкових чисел або потік введення (InputStream), який неможливо перемотати назад. Крім того, підтримка перемотування вимагала б повного буферизування всього потоку в пам’яті, що зламало б потокову модель обчислень.

3. У чому небезпека використання патерну Supplier<Stream<T>> при роботі із зовнішніми ресурсами?

  • Якщо лямбда Supplier викликає Files.lines(path) або запит до БД через Hibernate Stream, кожен виклик supplier.get() відкриватиме новий файловий дескриптор або з’єднання з БД. Якщо ці стріми не закривати явно через try-with-resources, виникне витік ресурсів (Connection Leak / File Descriptor Leak).

4. Чому Stream не успадковує інтерфейс Iterable, хоча має метод iterator()?

  • Iterable передбачає можливість багаторазового створення ітераторів (new iterator()) для багаторазового проходу за даними (наприклад, у циклі for-each). Stream одноразовий: його метод stream.iterator() можна викликати лише один раз, після чого стрім позначається як вичерпаний. Успадкування Iterable порушило б принцип підстановки Барбари Лісков (LSP).

Типові помилки та Red Flags

  • 🚩 Спроба зберегти стрім у полі класу для повторного використання в різних методах.
  • 🚩 Прийом Stream<T> в аргументах публічних сервісних методів.
  • 🚩 Незакриття стрімів введення-виведення (Files.lines(), Files.walk()) через try-with-resources.
  • 🚩 Спроба клонування стріма або пошук неіснуючого методу reset().

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