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

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

Технічно — можна, але категорично не рекомендується.

Мовні версії: English Russian Ukrainian

🟢 Junior Level

Технічно — можна, але категорично не рекомендується.

Java вимагає, щоб змінні в лямбдах були final або effectively final:

// НЕ СКОМПІЛЮЄТЬСЯ
int sum = 0;
list.stream().forEach(n -> sum += n); // помилка: sum має бути final

// МОЖНА з AtomicInteger
AtomicInteger sum = new AtomicInteger(0);
list.stream().forEach(n -> sum.addAndGet(n)); // компілюється, але погано

Правильний підхід: Використовуйте reduce() або collect():

int sum = list.stream().mapToInt(Integer::intValue).sum();

🟡 Middle Level

Обмеження “Effectively Final”

Лямбда захоплює значення змінної (копіює в приховане поле), а не посилання на стек. Якщо змінна змінюється в основному потоці, копія всередині лямбди застаріває.

Навіщо це обмеження: лямбда може виконатися пізніше, коли метод вже завершився і його стек-фрейм знищено. Копіювання гарантує, що дані будуть живі.

Чому це антипатерн

1. Порушення функціональної чистоти Стріми спроектовані в парадигмі функціонального програмування. Операції мають бути чистими функціями.

2. Крах паралелізму У паралельних стрімах зміна спільної змінної призводить до:

  • Race Conditions: Кілька потоків читають і пишуть одночасно
  • Visibility Issues: Зміни в L1-кеші одного потоку не видні іншому
  • Синхронізація: Atomic або synchronized вбивають сенс паралелізму

Приклади

// ПОГАНО — Side Effect
List<Integer> result = new ArrayList<>();
stream.filter(x -> x > 10).forEach(result::add);

// ДОБРЕ — Reduction
List<Integer> result = stream.filter(x -> x > 10).collect(Collectors.toList());

🔴 Senior Level

Contention на Atomic

Якщо 100 потоків паралельного стріма інкрементують один AtomicInteger, вони конфліктують на шині пам’яті (CAS loop) — стрім може стати в ~10 разів повільнішим за звичайний цикл for (за даними JMH на 8-ядерному CPU при 1M елементів і AtomicInteger contention). Ваша цифра залежить від JVM і навантаження.

GC Pressure

Створення мутабельних “обгорток” для обходу final обмежень створює зайве сміття в купі.

Edge Cases

  • AtomicInteger як лічильник: Результат непередбачуваний у паралельному стрімі (індекси будуть врозтіч)
  • Збирання в Map через forEach: Ризик ConcurrentModificationException. Якщо ключі унікальні — toMap(). Якщо ключі можуть повторюватися — groupingBy() (інакше toMap кине IllegalStateException: Duplicate key).

Діагностика

Static Analysis: SonarQube правило “Stream operations should be side-effect free” знаходить такі місця.

Rule of Thumb: Якщо потрібно змінити зовнішню змінну всередині стріма — ви обрали неправильну terminal операцію. Подивіться на reduce() або collect().


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

Обов’язково знати:

  • Змінні в лямбдах мають бути final або effectively final — лямбда захоплює копію значення, а не посилання
  • AtomicInteger компілюється, але в паралельному стрімі створює contention (CAS loop на шині пам’яті) — стрім може бути в ~10 разів повільнішим
  • Зміна зовнішніх змінних порушує парадигму функціонального програмування, на якій побудовані стріми
  • У паралельних стрімах мутація спільних змінних веде до race conditions, visibility issues, втрати даних
  • Правильний підхід: reduce(), collect(), sum() — вони інкапсулюють акумуляцію
  • Для Map-збирання з повторюваними ключами використовуйте groupingBy(), а не toMap() (інакше IllegalStateException: Duplicate key)
  • SonarQube знаходить такі місця правилом «Stream operations should be side-effect free»

Часті уточнюючі запитання:

  • Чому лямбди вимагають effectively final? — Лямбда може виконатися пізніше, коли стек-фрейм методу знищено; копія гарантує живучість даних
  • Чим forEach(result::add) відрізняється від collect(toList())?forEach мутує зовнішній ArrayList (side effect), collect — чиста операція редукції
  • Чому 100 потоків з AtomicInteger повільніші за цикл for? — CAS loop на одній кеш-лінії викликає cache thrashing — потоки перечитують одну й ту саму лінію пам’яті
  • Яка terminal-операція замінює мутацію списку?filter(...).collect(Collectors.toList())

Червоні прапорці (НЕ говорити):

  • «AtomicInteger — безпечний спосіб мутації в паралельному стрімі» — він потокобезпечний, але contention вбиває продуктивність
  • «Можна змінювати змінну, якщо обгорнути її в клас» — це обхід компілятора, але проблема thread-safety залишається
  • «У звичайному стрімі мутація безпечна» — вона компілюється, але порушує функціональну парадигму і блокує паралелізм
  • «forEach — нормальний спосіб зібрати результат» — це антипатерн, використовуйте collect()

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

  • [[15. Що таке побічні ефекти (side effects) в Stream]]
  • [[16. Чому слід уникати побічних ефектів в Stream]]
  • [[17. Що робить операція reduce()]]
  • [[18. В чому різниця між reduce() та collect()]]
  • [[12. Які потенційні проблеми можуть бути з паралельними стрімами]]