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. Thread-safety: избегаем общих изменяемых данных

В параллельном мире главная угроза — гонки данных (race conditions). Если несколько потоков меняют одну переменную, результат может быть неожиданным.

  • Избегайте изменяемых общих переменных. Даже выражение вроде counter++ не атомарно.
  • Используйте thread-safe коллекции и атомарные операции. Например, 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
Много маленьких задач Последовательный стрим
Задачи с побочными эффектами Только thread-safe коллекции!

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

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

7. Типичные ошибки при параллельном программировании

Ошибка №1: Параллелизация маленьких задач. Часто новички параллелят всё подряд, даже когда работа занимает микросекунды. Итог — медленнее из‑за накладных расходов.

Ошибка №2: Побочные эффекты в стримах. В parallelStream нельзя изменять внешние переменные или коллекции — получите гонки данных и непредсказуемые баги.

Ошибка №3: Игнорирование исключений. Если не обработать ошибки из Future.get() или ForkJoinTask, вы не узнаете, почему задача не выполнилась.

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

Ошибка №5: Использование не thread-safe коллекций. Писать из нескольких потоков в обычный ArrayList — прямой путь к ошибкам.

Ошибка №6: «Глотание» InterruptedException. Если поток прервали — уважайте это и корректно завершайте работу.

Ошибка №7: Слишком сложная логика синхронизации. Избыточные synchronized-блоки ведут к deadlock/livelock. Предпочитайте высокоуровневые абстракции.

1
Задача
JAVA 25 SELF, 54 уровень, 4 лекция
Недоступна
Эксперимент хаотической магии с дублированием артефактов 🧪
Эксперимент хаотической магии с дублированием артефактов 🧪
1
Задача
JAVA 25 SELF, 54 уровень, 4 лекция
Недоступна
Анализ древних свитков в цифровой библиотеке 📜
Анализ древних свитков в цифровой библиотеке 📜
1
Опрос
Параллелизм и ForkJoin, 54 уровень, 4 лекция
Недоступен
Параллелизм и ForkJoin
Параллелизм и ForkJoin
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ