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

Що таке lazy evaluation в Stream

Реальне зчитування та перетворення елементів починається тільки тоді, коли викликається термінальна (terminal) операція (collect(), toList(), forEach(), count(), findFirst()). Я...


🟢 Junior Level

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

Lazy Evaluation (Ліниві обчислення / Відкладене виконання) — це стратегія обчислень у Stream API, за якої виклик проміжних (intermediate) операцій (filter, map, peek, sorted) не виконує реальної обробки даних у момент оголошення, а лише конструює план майбутнього конвеєра обробки.

Реальне зчитування та перетворення елементів починається тільки тоді, коли викликається термінальна (terminal) операція (collect(), toList(), forEach(), count(), findFirst()). Якщо термінальна операція не викликана, жоден рядок коду всередині проміжних лямбд ніколи не виконається.


Наочний приклад на Java 21

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

public class LazyEvaluationDemo {

    public static void main(String[] args) {
        List<String> words = List.of("яблуко", "апельсин", "ананас", "банан");

        // 1. Проміжні операції — тут НЕ відбувається жодної фільтрації та трансформації:
        Stream<String> plan = words.stream()
            .filter(w -> {
                System.out.println("[Filter] " + w);
                return w.startsWith("а");
            })
            .map(w -> {
                System.out.println("[Map] " + w);
                return w.toUpperCase();
            });

        System.out.println(">>> Проміжний конвеєр оголошено, термінальна операція НЕ викликалася <<<");

        // 2. Тільки при виклику термінальної операції запускається обробка:
        System.out.println(">>> Викликаємо toList() <<<");
        List<String> result = plan.toList();
        System.out.println("Результат: " + result);
    }
}

Виведення програми:

>>> Проміжний конвеєр оголошено, термінальна операція НЕ викликалася <<<
>>> Викликаємо toList() <<<
[Filter] яблуко
[Filter] апельсин
[Map] апельсин
[Filter] ананас
[Map] ананас
[Filter] банан
Результат: [АПЕЛЬСИН, АНАНАС]

🟡 Middle Level

Вертикальне (Vertical) виконання та Loop Fusion

На відміну від традиційних ланцюжків методів у колекціях (де кожен крок створює новий проміжний список), Stream API реалізує вертикальний прохід (Vertical Execution / Loop Fusion):

Горизонтальне виконання (Колекції):
[Вихідний масив 1M] ──► Filter ──► [Проміжний List 1M] ──► Map ──► [Підсумковий List]
                                     ▲
                                Пам'ять $O(N)$

Вертикальне виконання (Stream API — Loop Fusion):
Елемент 1 ──► [Filter: OK]   ──► [Map] ──► [Collect]
Елемент 2 ──► [Filter: FAIL] ──► (відкинутий одразу)
Елемент 3 ──► [Filter: OK]   ──► [Map] ──► [Collect]

Переваги моделі Loop Fusion:

  1. Нульові витрати пам’яті ($O(1)$ allocations): Не створюються проміжні структури даних між викликами filter та map.
  2. Кеш-локальність CPU: Один елемент одразу проходить крізь усі операції, поки його дані знаходяться у швидких регістрах та кеші L1/L2 процесора.
  3. Можливість раннього виходу (Short-circuiting): Якщо викликати .findFirst(), обробка зупиниться на першому відповідному елементі, а решта 999 999 елементів навіть не будуть прочитані.

🔴 Senior Level

Нескінченні стріми (Infinite Streams) завдяки лінивості

Лінивість дозволяє створювати нескінченні потоки даних через Stream.generate() та Stream.iterate():

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

public class InfiniteLazyStreamDemo {

    public static void main(String[] args) {
        // Нескінченний потік генерації UUID
        List<UUID> zeroPrefixTokens = Stream.generate(UUID::randomUUID)
            .filter(uuid -> uuid.toString().startsWith("00"))
            .limit(3) // Термінальний шорт-циркуїт: перериває нескінченний стрім!
            .toList();

        System.out.println("Згенеровано 3 токени: " + zeroPrefixTokens);
    }
}

Чому це працює:

  • Stream.generate() не генерує нескінченну кількість елементів у пам’ять.
  • Метод limit(3) запитує у сплітератора елементи по одному («pull-модель»). Щойно отримано 3 відфільтровані елементи, конвеєр закривається.

Senior Warning: Якщо на нескінченний стрім повісити проміжну операцію зі станом без обмеження (наприклад, stream.filter(...).sorted()), програма увійде в нескінченний цикл і вичерпає пам’ять (OutOfMemoryError), оскільки sorted() зобов’язаний спочатку прочитати ВСІ елементи потоку для сортування!


Архітектурні пастки лінивості в Enterprise (Spring Boot)

1. Витік за межі транзакції (@Transactional)

Якщо репозиторій повертає лінивий Stream<Entity> у контролер:

  • Транзакція Spring завершується при виході з методу сервісу, і сесія Hibernate закривається.
  • Коли контролер викликає .toList(), лінивий стрім починає запитувати дані з БД через закриту сесію, породжуючи фатальний виняток: org.hibernate.LazyInitializationException: could not initialize proxy - no Session.

2. Зміщення точки виникнення винятків (Deferred Exceptions)

Якщо в лямбді map(u -> 100 / u.getDivisor()) закладена помилка ділення на нуль, виняток ArithmeticException виникне не в місці оголошення стріма, а там, де буде викликана термінальна операція (можливо, через десятки методів у зовсім іншому потоці).


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

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

«Lazy Evaluation — стратегія, за якої проміжні операції не виконують обчислень у момент оголошення, а лише будують ланцюжок Sink-обробників. Реальна обробка ініціюється виключно термінальною операцією. Завдяки лінивості Stream API застосовує оптимізацію Loop Fusion (вертикальний прохід одного елемента крізь увесь пайплайн), що виключає алокації проміжних колекцій і забезпечує роботу з нескінченними стрімами у зв’язці з операціями короткого замикання (limit, findFirst).»


Порівняння моделей обчислення

Характеристика Eager Evaluation (Колекції) Lazy Evaluation (Stream API)
Момент старту Негайно при виклику методу При виклику термінальної операції
Проміжна пам’ять Створює колекції на кожному кроці ($O(N)$) Без проміжних колекцій ($O(1)$)
Порядок обходу Горизонтальний (усі елементи за кроками) Вертикальний (один елемент крізь усі кроки)
Нескінченні структури Не підтримуються (викличе OOM) Підтримуються через Stream.iterate/generate
Short-Circuiting Неможливо без ручних циклів Вбудовано (limit, findAny, anyMatch)

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

1. Що таке Loop Fusion (злиття циклів) і як воно пов’язане з лінивістю?

  • Loop Fusion — це механізм оптимізації, за якого ланцюжок проміжних операцій (filter().map().peek()) об’єднується в єдиний цикл обходу. Замість трьох окремих проходів по мільйону елементів зі створенням проміжних списків, рантайм бере один елемент і одразу «проштовхує» його через фільтр і мапінг у термінальний акумулятор, зберігаючи дані в кеші CPU.

2. Що станеться, якщо викинути виняток у map(), але забути викликати термінальну операцію?

  • Нічого не станеться. Код лямбди всередині map() просто ніколи не буде викликаний. Стрім залишиться неактивним об’єктом метаданих у купі й буде зібраний збирачем сміття (GC) без будь-яких помилок.

3. Чому операція sorted() не може бути повністю лінивою?

  • sorted() є операцією зі станом (stateful). Щоб дізнатися, який елемент є найменшим і повинен першим піти далі по конвеєру, алгоритм сортування зобов’язаний спочатку повністю вичитати та буферизувати в пам’ять абсолютно всі елементи джерела. Тому sorted() діє як бар’єр синхронізації.

4. У чому небезпека повернення Stream з методу Spring Data JPA репозиторію?

  • JPA Stream тримає відкритим ResultSet бази даних і з’єднання Connection з пулу HikariCP. Якщо клієнтський код відкладе виклик термінальної операції або забуде огорнути стрім у try-with-resources, з’єднання з БД залишиться заблокованим, що швидко призведе до вичерпання пулу з’єднань (Connection Pool Exhaustion).

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

  • 🚩 Очікування виконання проміжних операцій без виклику термінальної операції (код «мовчки не працює»).
  • 🚩 Повернення лінивого Stream із сервісу з @Transactional у вебконтролер.
  • 🚩 Спроба застосувати sorted() на нескінченному стрімі Stream.generate().
  • 🚩 Розміщення побічних ефектів у проміжних операціях у розрахунку на їхнє гарантоване послідовне виконання.

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