Частое возражение: «сервисы сейчас stateless, многопоточка — теория, максимум навесить @Async». Это заблуждение. Stateless — про горизонтальное масштабирование (нет сессии между запросами, можно поднять 10 копий), и оно никак не отменяет параллелизм внутри процесса. Разберём, где многопоточность кусает в обычном Spring-приложении каждый день и почему модель памяти с гонками здесь не теория.
Верхняя сцена: потоки запросов Tomcat зовут один и тот же бин, поле у него общее — две одновременные прибавки кладут в память одну и ту же единицу, третья доводит счётчик до двух вместо трёх. Нижняя сцена: те же задачи в пуле на два потока, оба заняты ожиданием чужого сервиса, очередь заполнена, и новые задачи отклоняются, хотя процессор ничем не занят.
Бины — синглтоны, а поля — общие
Главная ловушка Spring: бины по умолчанию синглтоны. Один экземпляр @Service обслуживает все запросы, а Tomcat гонит их на пуле потоков — значит, изменяемое поле бина видят десятки потоков одновременно. Сколько именно, задаёт одно число: server.tomcat.threads.max, по умолчанию 200. Это и есть потолок одновременно обрабатываемых запросов: 201-й ждёт в очереди приёма, даже если процессор свободен. Знать его полезно с двух сторон — это и мера параллелизма, из-за которой гонки вообще случаются, и первое число, которое смотрят, когда сервис «медленный, а нагрузки нет».
живой пример
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicLong;
public class SharedBean { // в Spring — синглтон-@Service
private int count; // так не надо
private final AtomicLong safe = new AtomicLong(); // так надо
void onOrder() { count++; safe.incrementAndGet(); }
public static void main(String[] args) {
var bean = new SharedBean();
try (var pool = Executors.newFixedThreadPool(8)) {
for (int t = 0; t < 8; t++) pool.submit(() -> { for (int i = 0; i < 100_000; i++) bean.onOrder(); });
}
System.out.println("прибавили 800000: count = " + bean.count + ", safe = " + bean.safe.get());
}
}
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
Прибавок восемьсот тысяч: safe покажет ровно столько, count — меньше, и каждый раз по-новому. Лечится не блокировкой на каждый чих, а правильным примитивом — атомарной переменной.
Правило: у синглтон-бина не должно быть изменяемого состояния, кроме потокобезопасного (атомики, потокобезопасные коллекции). Если состояние есть — это первый кандидат в баг под нагрузкой.
Когда состояние всё-таки нужно, у Spring есть два обходных пути, и оба меняют не код, а время жизни бина. @Scope("prototype") даёт новый экземпляр на каждое обращение к контейнеру: годится объекту, который копит состояние и при этом нуждается во внедрённых зависимостях (сборщик отчёта, парсер с буфером). @RequestScope даёт экземпляр на HTTP-запрос: туда кладут контекст запроса — арендатора, идентификатор трассировки, текущего пользователя, — и внутри одного запроса он общий, а между запросами свой.
Оба варианта не бесплатны: prototype контейнер не уничтожает (@PreDestroy у него не вызовется), а request-бин внедряется в синглтон только через прокси и вне веб-потока падает с No thread-bound request found — в том же @Async, о котором ниже. Подробности обоих скоупов — в статье про DI и жизненный цикл.
Кэши и счётчики в памяти
Даже в «stateless» сервисе полно общего изменяемого состояния: кэш справочника, rate-limiter, дедуп по идемпотентности, метрики. Всё это живёт в синглтоне и читается-пишется из многих потоков. Обычный HashMap здесь недопустим — он ломается в многопоточном коде (порча структуры, в старых версиях — вечный цикл на ресайзе):
private final Map<String, Rate> limits = new ConcurrentHashMap<>(); // а не HashMap
Дедуп по ключу идемпотентности живёт на той же карте, и здесь важно не только «не HashMap», а атомарная операция: два одинаковых запроса, пришедших одновременно, сначала оба не найдут ключ, а потом оба запишут. putIfAbsent делает проверку и запись одним шагом, и первым оказывается ровно один:
private final Map<String, Instant> seen = new ConcurrentHashMap<>();
boolean firstTime(String idempotencyKey) {
return seen.putIfAbsent(idempotencyKey, Instant.now()) == null;
}
@Async: свой пул, а не общий
@Async уводит выполнение метода в другой поток. Какой именно — зависит от того, что найдёт Spring, и вариантов два.
В приложении на Spring Boot есть готовый бин applicationTaskExecutor: пул на восемь потоков с неограниченной очередью. Потоки при этом не плодятся, зато под нагрузкой растёт очередь — задачи копятся в памяти, задержка ползёт вверх, а при перезапуске очередь пропадает вместе с несделанной работой.
Второй вариант хуже, и встречается он вне Boot или при конфигурации по старинке: подходящего бина нет, и @Async достаётся SimpleAsyncTaskExecutor, который пулом не является вовсе — он заводит новый поток на каждый вызов. Под нагрузкой это ровно то, от чего пулы и придумали: тысячи потоков и машина, занятая переключением контекста.
Всегда задавайте свой пул с понятными границами:
@Bean("reportExecutor")
public Executor reportExecutor() {
var ex = new ThreadPoolTaskExecutor();
ex.setCorePoolSize(4);
ex.setMaxPoolSize(8);
ex.setQueueCapacity(100); // очередь с потолком
ex.setThreadNamePrefix("report-");
ex.initialize();
return ex;
}
@Async("reportExecutor")
public CompletableFuture<Report> build(long id) { ... }
Имя потока в префиксе — не косметика: когда сервис зависнет, вы будете читать thread dump, и report-3 сразу скажет, где встали потоки.
Ошибки в @Async-методе теряются, и это вторая причина всегда возвращать CompletableFuture. У метода с void исключение не всплывает никуда: вызывающий уже ушёл, обработчика нет, в логах тишина — задача просто не сделана. Ловят такие ошибки общим обработчиком:
@Configuration
@EnableAsync
public class AsyncConfig implements AsyncConfigurer {
@Override
public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
return (ex, method, params) -> log.error("async {} упал", method.getName(), ex);
}
}
У метода, возвращающего CompletableFuture, ошибка ложится в результат: вызывающий увидит её при join() или в exceptionally, и она не пропадёт даже без обработчика. Отсюда и совет предпочитать CompletableFuture вместо void: дело не в удобстве, а в том, что иначе сбой невидим.
И ещё одна вещь, общая с @Transactional: @Async работает через прокси. Вызов соседнего метода того же бина (this.sendAsync()) идёт мимо прокси и выполняется синхронно, прямо в текущем потоке, без единого предупреждения. Асинхронный метод выносят в отдельный бин — или зовут его через самого себя, полученного извне.
Есть и пересечение с виртуальными потоками, о которых ниже: при spring.threads.virtual.enabled=true умолчание меняется — Boot отдаёт @Async виртуальные потоки, и разговор про «восемь потоков с очередью» перестаёт действовать. Свой пул, объявленный явно, остаётся вашим при любом флаге.
Контекст не переезжает в @Async
Отдельно от транзакции (о ней ниже) в другой поток не попадает ещё три вещи, и обнаруживается это в проде. traceId из MDC: логи фоновой задачи перестают склеиваться с логами запроса, и в поиске по идентификатору их не найти. SecurityContextHolder: getAuthentication() возвращает null, и проверка прав внутри асинхронного метода падает или, хуже, пропускает. LocaleContextHolder и RequestContextHolder: язык и сам запрос там тоже недоступны, а обращение к бину со скоупом request даёт No thread-bound request found.
Природа одна — всё это ThreadLocal, — и лечение одно: перенести контекст в момент передачи задачи, один раз, декоратором:
ex.setTaskDecorator(runnable -> {
Map<String, String> mdc = MDC.getCopyOfContextMap();
SecurityContext security = SecurityContextHolder.getContext();
return () -> {
if (mdc != null) MDC.setContextMap(mdc);
SecurityContextHolder.setContext(security);
try {
runnable.run();
} finally {
MDC.clear();
SecurityContextHolder.clearContext(); // поток вернётся в пул чистым
}
};
});
Для контекста безопасности есть и готовая обёртка, DelegatingSecurityContextAsyncTaskExecutor, а если в проекте включена трассировка Micrometer, она ставит свой декоратор сама и traceId доезжает без вашего кода. Чистка в finally обязательна в любом варианте: поток переиспользуется, и оставленное значение достанется чужой задаче.
@Scheduled: один поток на все расписания
Вторая по частоте беда после исчерпания пула, и она в умолчании. Планировщик Spring по умолчанию работает в одном потоке: сколько бы методов с @Scheduled вы ни объявили, выполняются они по очереди. Одна задача, которая подвисла на внешнем вызове, останавливает все остальные расписания — отчёты не собираются, метрики не публикуются, очередь не разгребается, и в логах при этом ни одной ошибки.
spring:
task:
scheduling:
pool:
size: 5
thread-name-prefix: scheduled-
Пять потоков вместо одного снимают взаимное влияние, но не отменяют главного правила: у задачи по расписанию, которая ходит по сети, обязан быть таймаут, иначе она рано или поздно повиснет навсегда и займёт свой поток до перезапуска. Что ещё важно знать про расписания — несколько копий приложения, общий замок и остановка — в статье про Scheduled и Async.
Исчерпание пула — самый частый прод-инцидент
Вот сценарий, который кладёт сервисы чаще всего. Есть пул на 8 потоков. Внутри задачи — блокирующий вызов (HTTP к другому сервису, который затупил). Все 8 потоков заняты ожиданием, очередь заполнилась, новые задачи отклоняются — приложение «висит», хотя процессор простаивает.
То же на голом пуле: два потока, очередь на две, «внешний вызов» на 300 мс.
живой пример
import java.util.concurrent.*;
public class PoolExhaustion {
public static void main(String[] args) throws InterruptedException {
var pool = new ThreadPoolExecutor(2, 2, 0, TimeUnit.SECONDS, new ArrayBlockingQueue<>(2));
int rejected = 0;
for (int i = 0; i < 8; i++) {
try { pool.submit(() -> { Thread.sleep(300); return null; }); } // «внешний вызов»
catch (RejectedExecutionException e) { rejected++; }
}
pool.shutdown();
pool.awaitTermination(5, TimeUnit.SECONDS); // ждём, пока доработают принятые
System.out.println("из 8 задач отклонено " + rejected);
}
}
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
Две в работе, две в очереди, остальные четыре — RejectedExecutionException, при незанятом процессоре.
Это не лечится «добавить потоков» — это лечится пониманием, что блокировка в ограниченном пуле — это заём против всех остальных задач. Отсюда и таймауты на внешние вызовы, и отдельные пулы под разные виды нагрузки, и — как более фундаментальное решение — виртуальные потоки, где блокировка перестаёт занимать дорогой ресурс.
@Transactional не переходит в другой поток
Тонкая, но болезненная грабля. Транзакция Spring привязана к потоку (через ThreadLocal). Как только вы уходите в @Async или свой пул — вы в другом потоке, и транзакции там уже нет: executor.submit(() -> repo.save(entity)) внутри @Transactional-метода сохранит запись мимо транзакции. Механика простая: контекст лежит в ThreadLocal, а он у каждого потока свой.
живой пример
public class TxContext {
static final ThreadLocal<String> TX = new ThreadLocal<>();
public static void main(String[] args) throws Exception {
TX.set("tx-42"); // «открыли транзакцию»
var other = new Thread(() -> System.out.println("соседний поток: " + TX.get()));
other.start();
other.join();
System.out.println("свой поток: " + TX.get());
}
}
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
В своём потоке значение есть, в соседнем — null: так же там пусто вместо транзакции.
Транзакция накрывает только тот поток, в котором открыта: сохранение, отданное в пул, идёт мимо неё, и общий откат его уже не отменит.
Рядом стоит похожая по симптомам, но другая по природе беда: @Transactional, вызванный внутри того же бина (self-invocation), не срабатывает — потому что такой вызов идёт мимо прокси, а не из-за потоков.
А вот ленивая загрузка Hibernate в асинхронном потоке (LazyInitializationException) ломается именно из-за потоков, только причина точнее: сессия закрывается вместе с транзакцией, и к моменту, когда фоновая задача дотягивается до ленивого поля, доставать его уже неоткуда. В HTTP-потоке картина другая: у Boot по умолчанию включён open-in-view, который держит сессию открытой до конца ответа, — поэтому в контроллере то же самое обращение проходит, а в асинхронной задаче падает. Правило: транзакция живёт в одном потоке; если работа уходит в другой поток — заводите транзакцию там явно. Подробнее про сами транзакции — в статье про @Transactional.
Глубже: виртуальные потоки в Spring Bootрасширенное
С Spring Boot 3.2+ и Java 21 виртуальные потоки включаются одним флагом:
spring.threads.virtual.enabled=true
Теперь каждый HTTP-запрос обслуживает свой виртуальный поток, и блокирующий вызов не занимает дорогой платформенный поток — пропускная способность на IO-bound нагрузке растёт без реактивщины. Ровно поэтому первым делом после включения упираются в пул соединений: у HikariCP по умолчанию десять соединений, и это число не меняется от того, что потоков стало сколько угодно. Тысяча виртуальных потоков, каждому из которых нужна база, выстроится в очередь к десяти соединениям и получит SQLTransientConnectionException: connection is not available по истечении connectionTimeout. Потолок просто переехал: раньше им был пул потоков Tomcat, теперь пул соединений, и его надо считать осознанно (spring.datasource.hikari.maximum-pool-size), помня, что база тоже не бесконечная.
Но важно: виртуальные потоки не отменяют всё выше. Синглтоны так же общие, гонки так же возможны, @Transactional так же привязан к потоку. Более того, больше параллелизма — больше конкуренции за общее состояние, а synchronized вокруг долгой операции на Java 21–23 «пришпиливает» (pinning) виртуальный поток к несущему; в Java 24 это убрали (JEP 491). Виртуальные потоки убирают проблему дорогой блокировки — но не проблему разделяемых данных.
Коротко
- Бины Spring — синглтоны: изменяемое поле — общее для всех потоков-запросов, отсюда гонки. Держите состояние потокобезопасным (атомики, ConcurrentHashMap) или не держите вовсе.
@Async— всегда со своим пулом с границами и именем потока; дефолтный executor опасен.- Исчерпание пула блокирующими вызовами — самый частый прод-затык: таймауты, отдельные пулы, виртуальные потоки.
@Transactionalпривязан к потоку: уход в@Async/пул выносит работу из транзакции, оттуда жеLazyInitializationException. Отдельно от них — self-invocation: там причина в обходе прокси.- Виртуальные потоки (Spring Boot 3.2+) убирают дорогую блокировку, но не отменяют разделяемое состояние, гонки и привязку транзакции к потоку.
- Число одновременных запросов задаёт
server.tomcat.threads.max(по умолчанию 200); состояние, которое всё же нужно, держат в@Scope("prototype")или@RequestScope, а не в поле синглтона. - В
@Async-поток не переезжают MDC, контекст безопасности и локаль: переносят декоратором задач с чисткой вfinally; ошибка изvoid-метода теряется, нуженAsyncUncaughtExceptionHandlerили возвратCompletableFuture. - Планировщик по умолчанию однопоточный: одна залипшая задача останавливает все расписания, лечится
spring.task.scheduling.pool.sizeи таймаутами. - После включения виртуальных потоков узким местом становится пул соединений (HikariCP по умолчанию 10), а не потоки.
Что почитать дальше
- Гонки и модель памяти — почему
count++в синглтоне теряет данные. - Пулы потоков и типичные баги — как устроен пул и как он исчерпывается.
- Виртуальные потоки — фундаментальное решение проблемы блокировки.