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

Чи можна змінювати стан зовнішніх змінних в Stream операціях

Хоча компілятор дозволяє обходити це обмеження шляхом зміни полів об'єктів або елементів масивів (array[0]++, atomicCounter.incrementAndGet()), це вважається грубим антипатерном...


🟢 Junior Level

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

Прямо змінювати локальні змінні методу всередині Stream-операцій не можна. Java вимагає, щоб будь-які локальні змінні, захоплені лямбда-виразами, були оголошені як final або були effectively final (змінними, значення яких не перепризначається після первинної ініціалізації). Спроба змінити локальну змінну (наприклад, count++) призведе до помилки компіляції.

Хоча компілятор дозволяє обходити це обмеження шляхом зміни полів об’єктів або елементів масивів (array[0]++, atomicCounter.incrementAndGet()), це вважається грубим антипатерном. Мутація зовнішнього стану перетворює функціональний конвеєр на процедурний код із побічними ефектами, провокує гонки даних (race conditions) при паралельній обробці та порушує передбачуваність стріма. Для агрегації значень необхідно використовувати вбудовані операції редукції: mapToInt().sum(), reduce() або collect().


Приклади на Java 21

import java.util.List;
import java.util.concurrent.atomic.AtomicInteger;

public class ExternalVariablesStreamDemo {

    public static void main(String[] args) {
        List<Integer> numbers = List.of(1, 2, 3, 4, 5);

        // ❌ Помилка компіляції:
        // int sum = 0;
        // numbers.stream().forEach(n -> sum += n); // Variable must be final or effectively final

        // ❌ АНТИПАТЕРН: Обхід через масив або Atomic
        int[] fakeWrapper = new int[1];
        numbers.stream().forEach(n -> fakeWrapper[0] += n);

        AtomicInteger atomicCounter = new AtomicInteger();
        numbers.stream().forEach(atomicCounter::addAndGet);

        // ✅ ПРАВИЛЬНО: Функціональна редукція через Stream API
        int sum = numbers.stream()
            .mapToInt(Integer::intValue)
            .sum();

        System.out.println("Підсумкова сума: " + sum);
    }
}

🟡 Middle Level

Чому Java вимагає Effectively Final: стек проти купи

У Java локальні примітиви та посилання живуть у стек-фреймі (Stack Frame) конкретного потоку і знищуються одразу після виходу з методу. Лямбда-вираз — це екземпляр функціонального інтерфейсу, що створюється в купі (Heap) і може пережити метод, у якому був створений (наприклад, бувши збереженим у полі класу чи переданим в асинхронний потік).

Стек виклику методу:                Купа (Heap):
┌─────────────────────────┐        ┌─────────────────────────┐
│ int count = 10;         │ ───►   │ Lambda Instance         │
│ (знищиться при return)  │ (копія)│ final int capturedCount │
└─────────────────────────┘        └─────────────────────────┘
  1. Захоплення за значенням (Capture by Value): Компілятор Java копіює значення локальної змінної всередину згенерованого об’єкта лямбди.
  2. Заборона ілюзії зв’язування: Якби Java дозволяла змінювати змінну всередині лямбди, розробники очікували б, що оригінальна змінна на стеку також зміниться. Але стек може бути вже стертий! Щоб уникнути розсинхронізації між стеком і купою, мова примусово забороняє будь-яке перепризначення захоплених змінних.

Мутація полів об’єкта: чому компілятор мовчить

public class CounterHolder {
    private int counter = 0;

    public void process(List<String> items) {
        // Компілюється без помилок! Чому?
        items.stream().forEach(item -> this.counter++);
    }
}

Тут захоплюється не примітив counter, а посилання this! Посилання this є effectively final (воно не змінюється), а мутується лише пам’ять об’єкта за цим посиланням у купі. Компілятор дозволяє цей код, але з точки зору чистоти архітектури це створює прихований побічний ефект.


🔴 Senior Level

Апаратний рівень: Cache Line Bouncing при паралельній мутації

Якщо розробник вирішує обійти заборону компілятора за допомогою AtomicLong усередині parallelStream():

AtomicLong counter = new AtomicLong();
transactions.parallelStream().forEach(t -> counter.addAndGet(t.amount()));

Код стає потокобезпечним математично, але катастрофічно деградує на рівні CPU:

  1. Протокол когерентності кешів (MESI):
    • Змінна AtomicLong займає 8 байтів і розміщується всередині 64-байтного рядка кешу (Cache Line) процесора.
    • Коли Ядро 1 виконує операцію addAndGet (інструкція LOCK XADD або CAS), воно захоплює кеш-лінію в ексклюзивний статус (Modified) і відправляє сигнал інвалідації (Invalidate) всім іншим ядрам CPU.
  2. Ефект Cache Line Bouncing:
    • Усі інші ядра змушені скинути свої кеші L1/L2 і запросити оновлений рядок з L3 або оперативної пам’яті.
    • У результаті ядра процесора витрачають 95% часу не на обробку транзакцій, а на очікування синхронізації кешів по міжпроцесорній шині (QPI / UPI).

Порівняння продуктивності:

  • Паралельний стрім з AtomicLong: 150 мс (постійний Cache Bouncing).
  • Паралельний стрім із чистою редукцією mapToLong().sum(): 8 мс (кожен потік рахує суму в ізольованому регістрі CPU, а наприкінці викликається Long.sum у дереві злиття).

Порушення специфікації: Non-interference та Stateless Behaviors

Згідно зі специфікацією пакета java.util.stream:

«Поведінкові параметри (лямбди) стріма повинні бути non-interfering (не повинні модифікувати джерело даних) і stateless (їхній результат не повинен залежати від стану, який може змінюватися під час виконання пайплайна).»

Якщо проміжна операція (map, filter) читає або змінює зовнішній змінний стан:

  • JIT-компілятор і рантайм стріма мають право змінювати порядок викликів, об’єднувати операції (Loop Fusion) або зовсім пропускати їх при оптимізаціях короткого замикання (short-circuiting).
  • Поведінка коду стає недетермінованою і залежить від версії JDK, налаштувань GC та кількості ядер CPU.

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

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

«Прямо змінювати локальні змінні всередині лямбд не можна: компілятор Java вимагає, щоб вони були final або effectively final, оскільки лямбда захоплює їхнє значення зі стеку в купу (Capture by Value). Змінювати поля об’єктів або елементи масивів компілятор дозволяє, але це жорсткий антипатерн, що призводить до недетермінованої поведінки та руйнує багатопоточність. У паралельних стрімах мутація Atomic-змінних вбиває швидкість через Cache Line Bouncing, а мутація звичайних колекцій призводить до втрати даних. Завжди слід використовувати чисту редукцію: collect(), reduce() або sum().»


Аналіз спроб мутації зовнішнього стану

Спроба мутації Чи компілюється? У stream() У parallelStream() Архітектурний вердикт
int sum = 0; s.forEach(x -> sum += x) 🔴 Ні — — Помилка компіляції (effectively final)
int[] arr = new int[1]; s.forEach(x -> arr[0] += x) 🟢 Так Працює 🔴 Гонка даних «Брудний хак», заборонено
AtomicInteger a; s.forEach(a::addAndGet) 🟢 Так Працює 🟡 Сповільнення (CAS contention) Антипатерн, використовувати sum()
List<T> list; s.forEach(list::add) 🟢 Так Працює 🔴 Втрата даних, NPE, OOBE Катастрофа в багатопоточності
s.mapToInt(...).sum() / s.toList() 🟢 Так 🟢 Ідеально 🟢 Ідеально Стандартний чистий підхід

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

1. Чому Java забороняє змінювати локальні змінні всередині лямбди, але дозволяє змінювати поля того самого класу?

  • Локальні змінні живуть на стеку потоку. Лямбда створюється в купі (heap) і захоплює копію локальної змінної (Capture by Value). Дозвіл мутації призвів би до розсинхронізації між стеком та об’єктом лямбди.
  • Поля класу належать об’єкту в купі. Усередині лямбди захоплюється посилання this (яке є effectively final), а зміни відбуваються безпосередньо в пам’яті об’єкта в купі, доступній усім потокам.

2. У чому різниця між Capture by Value та Capture by Reference?

  • При Capture by Reference (як у C# через ключове слово ref або замикання в C++) компілятор пов’язує лямбду з вихідною адресою змінної (просуває її зі стеку в купу).
  • Java принципово не переміщує локальні змінні зі стеку в купу заради простоти моделі пам’яті та передбачуваності GC, реалізуючи лише Capture by Value (захоплення знімка значення на момент створення лямбди).

3. Чому використання AtomicLong.addAndGet() у паралельному стрімі працює повільніше, ніж в однопотоковому циклі?

  • Через конфлікти на шині кеш-пам’яті (Cache Line Bouncing). Усі ядра процесора намагаються атомарно оновити один і той самий 64-байтний рядок кешу через інструкції LOCK CMPXCHG, що викликає безперервну інвалідацію кешів L1/L2 усіх ядер. Однопотоковий цикл тримає значення в одному регістрі без між’ядерної синхронізації.

4. Що говорить специфікація Stream API про вимогу «Non-interference»?

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

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

  • 🚩 Загортання примітива в масив з одного елемента int[] arr = new int[1] заради обходу помилки компілятора effectively final.
  • 🚩 Запис елементів у колекцію через forEach(list::add) замість виклику stream.toList() або .collect(Collectors.toList()).
  • 🚩 Накопичення суми через зовнішній AtomicInteger у стрімі.
  • 🚩 Модифікація елементів вихідної колекції прямо під час її обходу в стрімі.

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