Параллелизм — это тема, которая пугает многих разработчиков. Не секрет, что это сложная тема с многочисленными ловушками. В этой статье я постараюсь представить некоторые из них.

  1. Отсутствие синхронизации при чтении
  2. Не обращая внимания на пулы потоков
  3. Использование ThreadLocal внутри пула потоков
  4. Небрежное использование фасада Executors
  5. Игнорирование идиомы try-finally при использовании блокировок

1. Отсутствие синхронизации при чтении

Важно помнить, что ключевое слово synchronized связано не только с атомарностью, но и с видимостью. Многие люди сосредотачиваются исключительно на первом, забывая о втором. Без надлежащей синхронизации нет гарантии, что изменения, сделанные в одном потоке, будут видны в другом потоке. Чтобы изменение было видимым, нам нужна так называемая связь происходит до. Если вам интересно, какие условия должны быть соблюдены, чтобы отношения произошли, прежде чем иметь место, вы можете взглянуть на Спецификацию языка Java.

2. Не обращать внимания на пулы потоков

Иногда нам нужно запустить какое-то действие в фоновом потоке. В Java есть множество способов добиться этого. К сожалению, не будучи достаточно осторожным, вы можете сделать то, чего не хотите.

2.1. Аннотация Spring @Async с TaskExecutor по умолчанию

Предположим, мы пишем серверное приложение с использованием Spring Boot и хотим выполнить какую-то операцию в фоновом потоке. Использование аннотации @Async кажется вполне рациональным выбором. К сожалению, по умолчанию каждый раз при вызове метода создается новый поток. Этот подход имеет два недостатка. Во-первых, большое количество потоков может быстро истощать ресурсы. Во-вторых, создание объекта потока — довольно затратная операция. В официальной документации мы можем прочитать, что

Объекты потока используют значительный объем памяти, и в крупномасштабном приложении выделение и освобождение многих объектов потока создает значительные накладные расходы на управление памятью.

и что использование пулов потоков является предпочтительным подходом.

Не забудьте указать своего исполнителя при использовании аннотации @Async.

2.2 ForkJoinPool.commonPool() и операции блокировки

ForkJoinPool.commonPool() не следует использовать для блокировки операций ввода-вывода. Этот пул был оптимизирован для задач с интенсивным использованием ЦП. Вы можете подумать, что в конце концов не используете commonPool, но некоторые популярные классы, такие как CompletableFuture и Stream, используют этот пул внизу.

Метод ioIntensiveOperation() будет выполнен commonPool ⛔️

Чтобы этого избежать, не забудьте передать объект типа Executor вторым параметром ✅

Другой пример непреднамеренного использования commonPool — это использование параллельных потоков. fetchRolesForUser() — это метод, требующий сетевого вызова, а это значит, что потенциально это может занять довольно много времени ⛔️

Выполнение такого рода логики может довольно быстро насытить commonPool, потому что он обычно невелик (количество доступных процессоров минус один). К сожалению, нет простого способа передать собственный объект Executor при использовании параллельных потоков.

Не используйте commonPool() для блокировки операций.

3. Использование ThreadLocal внутри пула потоков

Есть две причины, по которым вы не должны использовать ThreadLocal в пуле потоков:

  • возможная утечка памяти
  • задачи, запущенные в пуле потоков, могут видеть неверные данные

Задачи, отправленные в пул потоков, обычно недолговечны, в отличие от потоков пула потоков. Это означает, что локальное значение потока, установленное задачей, которая была завершена давно, может все еще существовать в памяти, потому что поток, выполняющий эту задачу, все еще существует. Это приводит к утечке памяти.

Когда вы отправляете задачу в пул потоков, вы никогда не знаете, какой поток ее выполнит. ExecutorService реализация может поддерживаться одним или несколькими потоками. Поэтому нет смысла читать значения из объекта ThreadLocal, потому что там могут быть данные, установленные совсем другим потоком.

Избегайте использования объекта ThreadLocal в коде, предназначенном для выполнения в пуле потоков.

4. Небрежное использование фасада Executors

4.1. Executors.newCachedThreadPool()

Если вы посмотрите на реализацию метода Executors.newCachedThreadPool(), вы увидите этот код

Хорошо то, что если нет работы, то пул не будет потреблять никаких ресурсов, потому что через 60 секунд простаивающие потоки будут завершены.

Что должно привлечь ваше внимание, так это установка максимального количества потоков на Integer.MAX_VALUE. На практике это означает, что количество потоков не ограничено. Потоки требуют значительного объема памяти, поэтому такой подход может быстро насытить ресурсы приложения, если сервер находится под большой нагрузкой.

4.2. Executors.newFixedThreadPool()

Использование пула потоков фиксированного размера обычно является лучшим выбором, чем Executors.newCachedThreadPool(). К сожалению, здесь тоже есть один риск. Если вы посмотрите на фактическую реализацию, вы увидите этот код.

В этой реализации используется неограниченный LinkedBlockingQueue, поэтому даже если вы никогда не сможете создать больше потоков, чем максимальный размер пула, очередь может расти бесконечно. Лучший подход — создать ExecutorService с помощью конструктора ThreadPoolExecutor, передав как максимальное количество потоков, так и ограниченную очередь, например, new LinkedBlockingQueue<>(1_000).

Предпочтительнее использовать ThreadPoolExecutor конструктор, чем Executors фабричные методы.

5. Игнорирование идиомы try-finally при использовании блокировок

Правильная структура вызова операции, охраняемой блокировкой, выглядит так.

Отсутствие блока try-finally может привести к тому, что замок никогда не будет разблокирован.

Помещать lock.lock(); внутрь блока try тоже неправильно. В ситуации, когда метод lock.lock() вызвал исключение, мы все равно попытаемся unlock lock, даже если оно никогда не было получено. Кроме того, исходное исключение теряется.

Резюме 🏁

Написание правильного параллельного кода — очень сложная тема. Есть много ловушек, в которые легко попасть, не будучи достаточно осторожным. В этой статье я выбрал только те, с которыми сталкивался чаще всего.