JavaRush /Курси /JAVA 25 SELF /Найкращі практики паралельного програмування

Найкращі практики паралельного програмування

JAVA 25 SELF
Рівень 54 , Лекція 4
Відкрита

1. Не все, що можна паралелізувати, потрібно паралелізувати

У Java є багато способів паралелізувати задачі. Але «паралелізм = завжди швидше» — це як думати, що якщо додати у суп ще солі, він стане смачнішим: до певного моменту — так, а далі — краще не експериментувати.

ExecutorService чудово підходить, коли у вас є чіткі задачі, які потрібно запускати та контролювати: обробка запитів, асинхронне завантаження даних, незалежні обчислення. Ви самі вирішуєте, скільки потоків буде у пулі, і керуєте життєвим циклом задач.

parallelStream — швидкий спосіб паралелізувати обробку колекцій, коли операції незалежні та без побічних ефектів. Ефективний для «важких» колекцій (десятки тисяч елементів і більше).

ForkJoinPool — вибір для задач, що добре діляться на підзадачі (divide & conquer): сортування, пошук, агрегація великих масивів. Використовується усередині parallelStream, але ним можна керувати й напряму.

Не використовуйте паралелізм «про всяк випадок». Якщо задача маленька, накладні витрати на планування, перемикання контексту та синхронізацію можуть з’їсти весь виграш.

Приклад: коли паралелізм не потрібен

List<Integer> smallList = List.of(1, 2, 3, 4, 5);
int sum = smallList.parallelStream()
    .mapToInt(x -> x)
    .sum(); // Паралелізувати заради 5 чисел — оверкіл!

2. Потокобезпечність: уникаємо спільних змінюваних даних

У паралельному світі головна загроза — гонки даних (race conditions). Якщо кілька потоків змінюють одну змінну, результат може бути неочікуваним.

  • Уникайте змінюваних спільних змінних. Навіть вираз на кшталт counter++ не є атомарним.
  • Використовуйте потокобезпечні колекції та атомарні операції. Наприклад, ConcurrentHashMap, CopyOnWriteArrayList, AtomicInteger, AtomicLong.
  • Без побічних ефектів у паралельних стрімах. Не змінюйте зовнішні структури в межах parallelStream.

Приклад поганого коду

List<Integer> numbers = Arrays.asList(1,2,3,4,5);
List<Integer> result = new ArrayList<>();
numbers.parallelStream().forEach(n -> result.add(n * 2)); // НЕБЕЗПЕЧНО!

Тут result.add() не є потокобезпечним. Підсумок — втрачені елементи або винятки.

Як правильно?

List<Integer> result = numbers.parallelStream()
    .map(n -> n * 2)
    .collect(Collectors.toList());

3. Продуктивність: більше потоків — не завжди краще

Дрібні задачі паралелізувати невигідно. Якщо робота триває мілісекунди, паралельний запуск часто лише сповільнить виконання через накладні витрати.

Вимірюйте продуктивність. Для швидких вимірів підійде System.nanoTime():

long start = System.nanoTime();
// ... ваш код ...
long end = System.nanoTime();
System.out.println("Час виконання: " + (end - start) + " нс");

Для серйозних мікробенчмарків використовуйте JMH (Java Microbenchmark Harness).

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

List<Integer> bigList = IntStream.range(0, 1_000_000)
    .boxed().collect(Collectors.toList());

long t1 = System.nanoTime();
long sum1 = bigList.stream().mapToLong(x -> x).sum();
long t2 = System.nanoTime();
long sum2 = bigList.parallelStream().mapToLong(x -> x).sum();
long t3 = System.nanoTime();

System.out.println("Послідовно: " + (t2 - t1) / 1_000_000 + " мс");
System.out.println("Паралельно:     " + (t3 - t2) / 1_000_000 + " мс");

Спробуйте на своєму комп’ютері — вигода відчутна на справді великих колекціях та важких операціях.

4. Обробка помилок: не ігноруйте винятки у потоках

Future та обробка винятків

Якщо ви запускали задачу через ExecutorService.submit(), винятки не буде передано автоматично — їх слід обробити через Future.get():

Future<Integer> future = executor.submit(() -> {
    if (Math.random() > 0.5) throw new RuntimeException("Ой!");
    return 42;
});
try {
    Integer result = future.get(); // може кинути ExecutionException
} catch (ExecutionException e) {
    System.err.println("Помилка в задачі: " + e.getCause());
}

ForkJoin та обробка винятків

У ForkJoinPool винятки «загортаються» усередині задачі. Під час виклику join()/get() вони спливуть:

ForkJoinPool pool = new ForkJoinPool();
RecursiveTask<Integer> task = new MyTask();
try {
    int result = pool.invoke(task);
} catch (Exception e) {
    System.err.println("Помилка у ForkJoin: " + e);
}

Не забувайте про обробку InterruptedException

Багато методів (наприклад, Future.get(), Thread.sleep()) можуть кинути InterruptedException. Не «ковтайте» його — реагуйте коректно: виставляйте прапорець переривання або завершуйте задачу.

5. Налагодження та тестування паралельного коду

Логування та налагодження

Паралельні баги підступні й часто виявляються нестабільно. Логуйте з указанням потоку: Thread.currentThread().getName(). Це допомагає зрозуміти, хто і коли виконує код.

У складних випадках використовуйте налагоджувач із підтримкою багатопоточності (наприклад, IntelliJ IDEA). Тимчасові Thread.sleep() іноді допомагають «зловити» рідкісну гонку даних.

Тестування багатопотокових сценаріїв

Виділяйте окремі тести на паралельні операції та використовуйте утиліти очікування умов, наприклад Awaitility. Запускайте такі тести багаторазово: деякі проблеми спливають лише на 100‑му або 1000‑му запуску.

6. Корисні нюанси та поради

Читабельність та підтримуваність: пишіть зрозумілий паралельний код

  • Документуйте. Коментуйте складні ділянки та вибір інструментів.
  • Використовуйте високорівневі абстракції. Надавайте перевагу ExecutorService, parallelStream, ForkJoinPool замість ручного керування потоками.
  • Уникайте «магії». Не ускладнюйте синхронізацію, якщо можна обійтися простіше.

Таблиця: коли який інструмент використовувати

Сценарій Рекомендований інструмент
Багато незалежних задач
ExecutorService
Обробка великої колекції parallelStream або ForkJoin
Задача «розділяй і володарюй»
ForkJoinPool + RecursiveTask
Проста асинхронна задача
CompletableFuture
Багато маленьких задач Послідовний стрім
Задачі з побічними ефектами Лише потокобезпечні колекції!

«Заповіді» паралельного програміста

  • Не робіть спільних змінюваних змінних — якщо не впевнені, що вони потокобезпечні.
  • Не паралелізуйте заради паралелізму: оцініть потенційну вигоду.
  • Не забувайте закривати пули: shutdown()/shutdownNow().
  • Не використовуйте parallelStream для операцій із побічними ефектами.
  • Не забувайте обробляти винятки з Future і ForkJoinTask.
  • Не «ковтайте» InterruptedException — коректно завершуйте задачу.

7. Типові помилки під час паралельного програмування

Помилка № 1: Паралелізація дрібних задач. Часто новачки паралелізують усе підряд, навіть коли робота триває мікросекунди. Підсумок — повільніше через накладні витрати.

Помилка № 2: Побічні ефекти у стрімах. У parallelStream не можна змінювати зовнішні змінні або колекції — отримаєте гонки даних і непередбачувані баги.

Помилка № 3: Ігнорування винятків. Якщо не обробити помилки з Future.get() або ForkJoinTask, ви не дізнаєтеся, чому задача не виконалася.

Помилка № 4: Забутий shutdown() у ExecutorService. Без явного завершення застосунок може «зависнути» під час виходу.

Помилка № 5: Використання непотокобезпечних колекцій. Писати з кількох потоків у звичайний ArrayList — прямий шлях до помилок.

Помилка № 6: «Ковтання» InterruptedException. Якщо потік перервали — поважайте це та коректно завершуйте роботу.

Помилка № 7: Надто складна логіка синхронізації. Надмірні synchronized-блоки призводять до deadlock/livelock. Надавайте перевагу високорівневим абстракціям.

1
Опитування
Паралелізм і ForkJoin, рівень 54, лекція 4
Недоступний
Паралелізм і ForkJoin
Паралелізм і ForkJoin
Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ