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

Коли використовувати parallel streams

Використовувати parallelStream() доцільно лише за одночасного дотримання трьох обов'язкових умов:


🟢 Junior Level

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

Використовувати parallelStream() доцільно лише за одночасного дотримання трьох обов’язкових умов:

  1. Великий обсяг даних: у джерелі містяться десятки або сотні тисяч елементів.
  2. Важкі обчислення процесора (CPU-Bound): математика, криптографічне хешування, парсинг складних форматів, обробка графіки/аудіо.
  3. Повна незалежність елементів: функції всередині стріма чисті (stateless), не мають побічних ефектів (side-effects) і не мутують спільний стан.

Якщо хоча б одна умова не виконується — паралельні стріми застосовувати не слід. Для завдань введення-виведення (I/O: HTTP-запити, запити до БД, читання файлів) використовувати parallelStream() категорично заборонено, оскільки це паралізує спільний пул ForkJoinPool.commonPool() усієї JVM.


Базовий приклад на Java 21

import java.math.BigInteger;
import java.util.List;
import java.util.stream.LongStream;

public class WhenToUseParallelDemo {

    public static void main(String[] args) {
        // ✅ ДОБРЕ: CPU-навантаження, масив зі швидким поділом, чиста функція
        List<BigInteger> heavyPrimes = LongStream.rangeClosed(1_000_000, 1_100_000)
            .parallel()
            .mapToObj(BigInteger::valueOf)
            .filter(n -> n.isProbablePrime(50))
            .toList();

        System.out.println("Знайдено простих чисел: " + heavyPrimes.size());

        // ❌ ПОГАНО: Маленький список і примітивна операція
        List<String> words = List.of("apple", "banana", "cherry");
        List<String> upper = words.parallelStream() // Оверхед на fork/join у 10 разів довший за саму роботу!
            .map(String::toUpperCase)
            .toList();
    }
}

🟡 Middle Level

Оцінка доцільності: модель $N \times Q$

Згідно з емпіричною моделлю Браяна Гетца, паралелізм приносить прискорення, коли: \(N \times Q > 10\,000\) де:

  • $N$ — кількість елементів у джерелі;
  • $Q$ — кількість тактів процесора, що витрачається на обробку одного елемента.

Якщо операція над елементом дешева ($Q$ мале: додавання чисел, читання поля об’єкта), $N$ має бути не менше сотень тисяч елементів. Якщо операція важка ($Q$ велике: обчислення RSA, парсинг JSON), паралелізм може бути ефективним навіть при $N = 1\,000$.


Аналіз структури джерела та операцій

1. Структура джерела даних:

  • Чудово діляться ($O(1)$): int[], long[], ArrayList, IntStream.rangeClosed(). Вони знають точний розмір (SIZED), розташовані компактно в пам’яті та миттєво діляться за індексами рівно навпіл.
  • Посередньо діляться ($O(1)$ амортизовано): HashSet, TreeSet. Розмір відомий, але поділ за бакетами потребує додаткових накладних витрат.
  • Жахливо діляться ($O(N)$): LinkedList, Stream.iterate(). Для розділення зв’язного списку навпіл потрібно обійти $N/2$ посилань. Накладні витрати на поділ роблять паралельний стрім повільнішим за послідовний.

2. Вплив проміжних операцій:

  • Stateless-операції (filter, map, flatMap): чудово масштабуються, потоки працюють ізольовано.
  • Stateful-операції (sorted, distinct, limit, skip): створюють глобальні бар’єри синхронізації. Наприклад, операція sorted() у паралельному стрімі змушена зібрати всі елементи з усіх потоків в один величезний буфер, відсортувати його і лише потім передати далі.

🔴 Senior Level

Архітектурний вибір у сучасних сервісах

У серверних застосунках (Spring Boot, Quarkus) більша частина навантаження вже є конкурентною: вебсервер обробляє тисячі вхідних HTTP-запитів одночасно в різних потоках.

Incoming Web Requests (Tomcat Worker Threads)
Request 1  ───> Thread 1 ──┐
Request 2  ───> Thread 2 ──┼──> Запуск parallelStream() у кожному запиті
Request 3  ───> Thread 3 ──┘           │
                                       ▼
                       ForkJoinPool.commonPool() (Кількість ядер - 1)
                       [Worker 1] [Worker 2] [Worker 3] (Захлинаються)
                       ───> Thread Starvation, лавиноподібне зростання латентності!
  1. Серверний паралелізм: Якщо сервіс уже завантажений сотнями паралельних клієнтських запитів, увімкнення parallelStream() усередині обробки одного запиту призведе до вичерпання потоків пулу, конкуренції за кеші CPU (Cache Thrashing) та деградації загальної пропускної здатності.
  2. Пакетна обробка (Batch / ETL): Навпаки, у нічних фонових джобах (Spring Batch, CLI-утилітах), де один процес монопольно утилізує всі ядра сервера, parallelStream() є чудовим і простим рішенням для прискорення обчислень.

parallelStream() vs Virtual Threads (Java 21) vs CompletableFuture

Профіль завдання Найкращий інструмент Чому НЕ parallelStream()
CPU-Bound над великими даними (математика, хеші) 🟢 parallelStream() Ідеально завантажує доступні фізичні ядра без накладних витрат на контекст
I/O-Bound завдання (HTTP-виклики, SQL, черги) 🟢 Virtual Threads (Java 21+) parallelStream блокує платформені потоки commonPool
Асинхронні графи залежностей 🟢 CompletableFuture Підтримує неблокуючі ланцюжки thenCompose, таймаути та обробку помилок

Вплив апаратної архітектури: False Sharing та кеш-лінії

При паралельній обробці колекцій посилальних об’єктів (List<Object>):

  1. Cache Misses: Посилальні об’єкти розкидані по всій купі (heap fragmentation). Кожен потік при зверненні до об’єкта промахується повз L1/L2 кеш процесора і звертається до повільної оперативної пам’яті (DRAM).
  2. False Sharing: Якщо різні потоки записують результати в сусідні елементи одного масиву чи структури даних, розташовані в межах одного 64-байтного рядка кешу (Cache Line), ядра процесора змушені безперервно інвалідувати кеші один одного за протоколом когерентності (MESI), знижуючи швидкість обчислень у десятки разів.

Senior Insight: Для досягнення максимальної продуктивності в CPU-Bound завданнях паралельні стріми повинні використовувати примітивні масиви (long[], int[]) або примітивні стріми (LongStream, IntStream), що забезпечують послідовне пакування даних у пам’яті (Memory Locality).


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

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

«Використовувати parallelStream слід лише для CPU-інтенсивних обчислень над великими обсягами даних за умови виконання правила $N \times Q > 10\,000$ і використання ефективних структур даних (ArrayList, масиви, числові діапазони). Категорично заборонено використовувати parallelStream для I/O-операцій або всередині навантажених вебсерверів, оскільки він використовує спільний ForkJoinPool.commonPool() і створює конкуренцію за ресурси між клієнтськими запитами. Для I/O в сучасній Java 21 стандартом є Virtual Threads.»


Чек-лист: чи вмикати parallelStream?

Питання перевірки Відповідь ТАК Відповідь НІ
Операція виконує читання БД, мережі чи файлу? 🔴 СТОП! Лише Virtual Threads / CF 🟢 Продовжуємо перевірку
Обсяг даних $N \times Q > 10\,000$? 🟢 Продовжуємо перевірку 🔴 СТОП! Звичайний stream() швидший
Джерело — ArrayList або масив? 🟢 Продовжуємо перевірку 🟡 Обережно: LinkedList сповільнить код
У конвеєрі є sorted() чи distinct()? 🟡 Обережно: бар’єр синхронізації 🟢 Чудово (stateless операції)
Сервер уже навантажений тисячами HTTP-запитів? 🔴 СТОП! Пул потоків буде виснажений 🟢 Вмикаємо parallelStream()

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

1. Чому додавання .parallel() до обробки вхідного запиту у Spring Boot контролері часто сповільнює сервер?

  • Вебсервер (Tomcat) уже паралелить навантаження, виділяючи потік на кожен HTTP-запит. Якщо кожен запит почне захоплювати потоки зі спільного ForkJoinPool.commonPool(), виникне гостра конкуренція за процесорні ядра, постійні перемикання контексту та затримки в черзі пулу. У результаті латентність усіх запитів зростає, а пропускна здатність сервісу падає.

2. Як апаратні кеші CPU (L1/L2/L3) впливають на вибір між parallelStream() та звичайним циклом?

  • Послідовний цикл обходить дані суворо по порядку, що дозволяє апаратному блоку випереджальної вибірки процесора (Hardware Prefetcher) заздалегідь підвантажувати дані в L1/L2 кеші.
  • Паралельний стрім ділить пам’ять на чанки між ядрами. Якщо елементи — це посилальні об’єкти в купі, кожне ядро читає випадкові адреси пам’яті, викликаючи постійні Cache Misses. У таких випадках простий послідовний цикл за примітивним масивом може виявитися швидшим за паралельний стрім за списком об’єктів.

3. У яких випадках операція parallelStream().sorted() може вичерпати пам’ять (OOM)?

  • sorted() є операцією зі станом (stateful) і вимагає накопичення всіх елементів стріма перед їх упорядкуванням. У паралельному стрімі дані накопичуються в багатопотокові буфери, після чого виконується паралельне сортування злиттям (Parallel Merge Sort), що вимагає додаткової пам’яті під допоміжні масиви. При обробці величезних колекцій це подвоює споживання heap і може призвести до OutOfMemoryError.

4. Коли при $N = 10\,000$ паралельний стрім за ArrayList<Integer> програє послідовному циклу за int[]?

  • Майже завжди. В ArrayList<Integer> кожен елемент — це об’єкт-обгортка (24 байти в купі з покажчиком). Витрати на розіменування посилань, промахи кешу та авторозпакування (unboxing) багаторазово перевищують вигоду від паралелізму, а накладні витрати на координацію потоків ForkJoinPool роблять паралельний код у 2–5 разів повільнішим за векторизований послідовний цикл за неперервним масивом примітивів int[].

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

  • 🚩 Використання parallelStream() для виконання мережевих або дискових викликів (HTTP клієнт, JDBC).
  • 🚩 Вмикання .parallel() «про всяк випадок» без профілювання та перевірки через JMH-бенчмарки.
  • 🚩 Паралельна обробка LinkedList або нескінченних генераторів Stream.iterate().
  • 🚩 Використання операцій із залежністю від порядку (findFirst(), limit()) у паралельному стрімі без гострої потреби (замість них слід використовувати findAny()).

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