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

Що таке побічні ефекти (side effects) в Stream

Приклади побічних ефектів:


🟢 Junior Level

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

Побічний ефект (Side Effect) — це будь-яка дія функції всередині стріма, яка, окрім обчислення значення, що повертається, змінює зовнішній стан програми або взаємодіє із зовнішнім оточенням.

Приклади побічних ефектів:

  • Запис елементів у зовнішню колекцію (list.add()).
  • Мутація стану оброблюваних об’єктів (entity.setStatus(...)).
  • Виведення в консоль (System.out.println) або запис у лог (log.info).
  • Мережеві виклики (HTTP, gRPC) або запити до бази даних.

Стріми спроєктовані в парадигмі функціонального програмування для роботи з чистими функціями (Pure Functions). Побічні ефекти порушують детермінізм, руйнують паралелізм і можуть призводити до важковловимих помилок багатопотоковості.


Приклади: Чистий стрім проти побічних ефектів на Java 21

import java.util.ArrayList;
import java.util.List;

public class SideEffectsBasicsDemo {

    public static void main(String[] args) {
        List<String> names = List.of("іван", "анна", "сергій");

        // ❌ ПОГАНО: Використання стріма заради побічного ефекту (мутація зовнішнього списку)
        List<String> badList = new ArrayList<>();
        names.stream()
            .map(String::toUpperCase)
            .forEach(badList::add); // Побічний ефект!

        // ✅ ДОБРЕ: Чистий функціональний пайплайн без побічних ефектів
        List<String> goodList = names.stream()
            .map(String::toUpperCase)
            .toList();

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

🟡 Middle Level

3 стовпи функціональної чистоти Stream API

Специфікація java.util.stream висуває до лямбда-виразів три фундаментальні вимоги:

  1. Non-interference (Невтручання): функції конвеєра не повинні модифікувати джерело даних стріма під час його виконання (інакше викидається ConcurrentModificationException).
  2. Statelessness (Відсутність стану): поведінка функції над елементом не повинна залежати від стану, накопиченого при обробці попередніх елементів потоку.
  3. Side-effect-freedom (Відсутність побічних ефектів): функції не повинні мутувати спільні ресурси без явної потокобезпечної синхронізації.
Pure Pipeline:
[ Source ] ──► filter(pure) ──► map(pure) ──► collect(accumulator) ──► [ Result ]
                                                       ▲
                                            Мутація ізольована
                                            всередині колектора!

Чому побічні ефекти ламають parallelStream()

У паралельному стрімі дані розбиваються на чанки й обробляються кількома ядрами CPU одночасно:

  • Гонки даних (Race Conditions): одночасний виклик list.add() або map.put() руйнує внутрішні структури даних колекцій.
  • Недетермінований порядок: якщо побічний ефект записує дані в зовнішній лог або відправляє повідомлення в чергу (Kafka/RabbitMQ), послідовність повідомлень буде повністю хаотичною.
  • Проблеми видимості (Visibility): зміни змінної на одному ядрі CPU можуть бути не видні іншому ядру без бар’єрів пам’яті (volatile, synchronized).

🔴 Senior Level

Пропуск побічних ефектів при оптимізаціях JVM (Stream Fusion & Pruning)

Починаючи з Java 9, реалізація Stream API активно застосовує оптимізації на основі метаданих сплітератора (SIZED, SUBSIZED).

Якщо стрім знає свій точний розмір, а термінальна операція не вимагає обчислення проміжних значень (наприклад, .count()), рантайм викидає проміжні операції без їхнього виконання:

import java.util.List;

public class StreamPruningTrapDemo {

    public static void main(String[] args) {
        List<String> items = List.of("A", "B", "C");

        // ❌ НЕБЕЗПЕЧНО: Побічний ефект всередині peek()
        long count = items.stream()
            .peek(item -> System.out.println("Оброблено: " + item))
            .count();

        System.out.println("Усього: " + count);
        // У Java 9+ виведення "Оброблено: ..." НЕ З'ЯВИТЬСЯ ЗОВСІМ!
        // Метод count() взяв items.size() безпосередньо зі сплітератора,
        // а проміжний peek() був повністю відкинутий рантаймом.
    }
}

Senior Insight: Покладатися на виконання побічних ефектів усередині проміжних операцій (map, filter, peek) категорично заборонено специфікацією. Рантайм не дає жодних гарантій, що вони будуть викликані для кожного елемента.


Bottleneck синхронізації: Логування у Highload (peek(log::info))

Поширена помилка під час налагодження — додавання логування у стрім:

items.parallelStream()
    .peek(item -> log.info("Processing: {}", item.getId()))
    .map(this::heavyCpuCalculation)
    .toList();

Апаратний колапс:

  • Апендери більшості логерів (Logback, Log4j2) використовують блокування (synchronized або ReentrantLock) для гарантії цілісності рядків у файлі або консолі.
  • 16 потоків ForkJoinPool шикуються в одну чергу за монітором апендера.
  • Замість паралельного обчислення на 16 ядрах потоки безперервно перемикають контекст (Context Switches), і продуктивність сервісу падає у 20–50 разів.

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

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

«Побічний ефект у Stream — це будь-яка дія всередині лямбди, що змінює стан за межами локального контексту функції (мутація списків, запис у базу, логування). Стріми спроєктовані для чистих функцій. У паралельних стрімах побічні ефекти призводять до гонок даних і хаосу в порядку виконання. Ба більше, починаючи з Java 9, рантайм стріма має право повністю пропускати проміжні операції з побічними ефектами (наприклад, peek()) при оптимізації викликів на кшталт .count(). Єдине легітимне місце для усвідомленого сайд-ефекту — термінальна операція forEach().»


Матриця допустимості побічних ефектів

Місце в пайплайні Допустимість побічних ефектів Наслідки
filter(), map(), flatMap() 🔴 Категорично заборонено Недетермінізм, поломка паралелізму, гонки даних
peek() 🟡 Лише тимчасове налагодження У Java 9+ може бути пропущений оптимізатором (count())
forEach() 🟢 Дозволено Термінальна операція; порядок у parallel не гарантований
forEachOrdered() 🟢 Дозволено Термінальна операція; зберігає вихідний порядок елементів
collect() 🟢 Ідеально Мутація ізольована всередині спеціалізованого акумулятора

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

1. Чому виклик .peek() із побічним ефектом (наприклад, записом у БД) може не виконатися у Java 9+?

  • Починаючи з Java 9, якщо термінальна операція не потребує елементів для обчислення результату (наприклад, stream.count()), а джерело стріма має характеристику SIZED, рантайм бере розмір безпосередньо з spliterator.getExactSizeIfKnown(). Проміжні stateless-операції (map, filter, peek) оптимізатор просто відсікає (Stream Pruning), і код усередині peek() не викликається зовсім.

2. У чому різниця між «Non-interference» та «Side-effect-freedom» у специфікації Stream API?

  • Non-interference вимагає, щоб стрім не модифікував своє власне джерело даних (колекцію, з якої створено стрім) під час обходу. Порушення призводить до ConcurrentModificationException.
  • Side-effect-freedom вимагає, щоб операції не змінювали жодного зовнішнього спільного стану взагалі (зовнішні списки, синглтони, бази даних).

3. Чому логування через peek(log::info) всередині parallelStream() знищує масштабованість?

  • Бібліотеки логування синхронізують запис у вихідний потік/файл через локи. Усі потоки ForkJoinPool блокуються на одному спільному моніторі апендера, перетворюючи паралельну обробку на послідовну з величезними накладними витратами на перемикання контексту процесора.

4. У яких рідкісних випадках сайд-ефект у Stream API вважається правильним проєктним рішенням?

  • Лише як фінальний крок пайплайна в термінальній операції forEach() / forEachOrdered(), коли метою всього стріма є виконання зовнішньої дії (наприклад, відправка готових DTO в чергу повідомлень або друк звіту). При цьому для складання структур даних усе одно слід віддавати перевагу collect().

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

  • 🚩 Запис елементів у колекцію через map(x -> { list.add(x); return x; }).
  • 🚩 Надія на те, що peek() обов’язково виконається для всіх елементів перед count().
  • 🚩 Використання побічних ефектів із залежністю від порядку виконання в parallelStream().
  • 🚩 Модифікація вихідної колекції під час стріма (items.stream().forEach(items::remove)).

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