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 вместо ручного управления потоками.
- Избегайте «магии». Не усложняйте синхронизацию, если можно обойтись проще.
Таблица: когда какой инструмент использовать
| Сценарий | Рекомендуемый инструмент |
|---|---|
| Много независимых задач | |
| Обработка большой коллекции | parallelStream или ForkJoin |
| Задача «разделяй и властвуй» | |
| Простая асинхронная задача | |
| Много маленьких задач | Последовательный стрим |
| Задачи с побочными эффектами | Только 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. Предпочитайте высокоуровневые абстракции.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ