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

Які потенційні проблеми можуть бути з паралельними стрімами?

Паралельні стріми — не завжди роблять код швидшим. Ось основні проблеми:

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

🟢 Junior Level

Паралельні стріми — не завжди роблять код швидшим. Ось основні проблеми:

1. Блокування спільного пулу потоків Всі паралельні стріми ділять один ForkJoinPool.commonPool(). Якщо один стрім робить довгий HTTP-запит — всі решта чекають.

2. Повільніше за звичайний цикл Для маленьких списків паралельний стрім буде повільнішим через витрати на розділення задач та об’єднання результатів. overhead на ForkJoinTask для списку з 100 елементів може скласти ~50мкс, тоді як простий цикл — ~1мкс. Паралелізм окупається від ~10,000 елементів (правило N × Q > 10,000).

3. Проблеми з потокобезпечністю

// КАТАСТРОФА — ArrayList не потокобезпечний
List<Integer> list = new ArrayList<>();
numbers.parallelStream().forEach(list::add);

Можете отримати ConcurrentModificationException або втрату даних.

4. Непередбачуваний порядок parallelStream().forEach() обробляє елементи у випадковому порядку.

🟡 Middle Level

Race Conditions

Лямбди в стрімах мають бути stateless (без стану):

// ПОГАНО — мутація зовнішньої змінної
AtomicInteger counter = new AtomicInteger();
numbers.parallelStream().forEach(n -> counter.incrementAndGet());

100 потоків, що конкурують за один AtomicInteger, створюють contention — стрім стане повільнішим за звичайний цикл.

Неефективні операції у паралелізмі

  • limit() і skip(): Потребують синхронізації між потоками
  • sorted(): Паралельне сортування ефективне лише на дуже великих обсягах
  • findFirst(): Змушує чекати результату від першого потоку (використовуйте findAny())

Втрата локальності даних

CPU любить послідовне читання (L1/L2 cache prefetching — CPU заздалегідь підвантажує сусідні байти у швидкий кеш). Коли потоки читають з різних ділянок пам’яті, CPU не може передбачити адресу — кеш промах, процесор чекає дані з RAM (100+ тактів).

ThreadLocal Dangers

В general case не використовуйте ThreadLocal всередині паралельного стріма — воркери ForkJoinPool перевикористовуються між задачами. Якщо контролюєте кастомний ForkJoinPool і очищуєте ThreadLocal в finally — допустимо.

🔴 Senior Level

Starvation Common Pool

Якщо компонент А запустив важкий стрім, компонент Б буде гальмувати. В Enterprise-додатках (Spring Boot) це критично.

Рішення: Ізоляція через кастомні ForkJoinPool:

ForkJoinPool isolatedPool = new ForkJoinPool(8);
isolatedPool.submit(() -> stream.parallel()...).get();

False Sharing

При оновленні сусідніх елементів в масиві примітивів потоки інвалідують кеш-лінії один одного в CPU — деградація продуктивності до рівня одного ядра і нижче.

Overhead breakdown

  1. Розділення даних (Splitting)
  2. Створення об’єктів задач (Task allocation)
  3. Перемикання контексту між ядрами
  4. Зливання результатів (Merging/Combining)

Діагностика

  • -Djava.util.concurrent.ForkJoinPool.common.parallelism=0: Тимчасово вимкніть паралелізм для тестів
  • Thread Dumps: Якщо додаток “завис” — перевірте commonPool. Якщо всі потоки в WAITING на мережевих викликах — знайдено проблему
  • JMH Benchmarking: Єдиний спосіб довести користь паралелізму

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

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

  • Паралельні стріми ділять один ForkJoinPool.commonPool() — один довгий запит блокує всі решта
  • overhead Fork/Join окупається від ~10 000 елементів (правило N x Q > 10 000)
  • parallelStream().forEach() обробляє елементи у випадковому порядку — для впорядкованого результату використовуйте forEachOrdered()
  • Зміна зовнішніх змінних у паралельному стрімі веде до ConcurrentModificationException та race conditions
  • limit(), skip(), findFirst() потребують синхронізації між потоками і неефективні
  • CPU cache locality втрачається при паралелізмі — потоки читають з різних ділянок пам’яті, кеш-промахи 100+ тактів
  • В Spring Boot спільний commonPool — критична проблема: компонент А гальмує компонент Б

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

  • Коли паралельний стрім повільніший за звичайний цикл? — На малих даних (< 10K елементів) та при мутації зовнішніх змінних
  • Як ізолювати навантаження паралельного стріма? — Запустити через кастомний ForkJoinPool: pool.submit(() -> stream.parallel()...).get()
  • Чим замінити findFirst() у паралельному режимі? — Використати findAny() — він не потребує синхронізації
  • Як діагностувати проблеми з commonPool? — Вимкнути паралелізм через системну властивість -Djava.util.concurrent.ForkJoinPool.common.parallelism=0

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

  • «Паралельний стрім — це завжди швидше» — overhead і contention можуть зробити його в рази повільнішим
  • «Можна використовувати ArrayList у паралельному forEach» — ArrayList не потокобезпечний, втрата даних гарантована
  • «ThreadLocal безпечний у паралельному стрімі» — воркери перевикористовуються між задачами, ThreadLocal «витече»
  • «Паралельний стрім сам вирішить, скільки потоків використовувати» — він використовує один shared commonPool

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

  • [[11. Як створити parallel stream]]
  • [[13. Що таке ForkJoinPool і як він пов’язаний з parallel streams]]
  • [[14. Чи можна змінювати стан зовнішніх змінних в Stream операціях]]
  • [[16. Чому слід уникати побічних ефектів в Stream]]
  • [[10. Коли використовувати parallel streams]]