Частое возражение: «сервисы сейчас stateless, многопоточка — теория, максимум навесить @Async». Это заблуждение. Stateless — про горизонтальное масштабирование (нет сессии между запросами, можно поднять 10 копий), и оно никак не отменяет параллелизм внутри процесса. Разберём, где многопоточность кусает в обычном Spring-приложении каждый день — и почему знать модель памяти и гонки нужно, а не «просто навесить future».
Бины — синглтоны, а поля — общие
Главная ловушка Spring: бины по умолчанию синглтоны. Один экземпляр @Service обслуживает все запросы, а Tomcat гонит их на пуле потоков — значит, изменяемое поле бина видят десятки потоков одновременно.
@Service
public class OrderCounter {
private int count = 0; // ОДНО поле на все потоки-запросы
public void onOrder() {
count++; // гонка: ++ не атомарен
}
}
Под нагрузкой count++ теряет инкременты — это классическая гонка, и она проходит ревью незамеченной. Лечится не блокировкой на каждый чих, а правильным примитивом — атомарной переменной:
private final AtomicLong count = new AtomicLong();
public void onOrder() { count.incrementAndGet(); }
Правило: у синглтон-бина не должно быть изменяемого состояния, кроме потокобезопасного (атомики, потокобезопасные коллекции). Если состояние есть — это первый кандидат в баг под нагрузкой.
Кэши и счётчики в памяти
Даже в «stateless» сервисе полно общего изменяемого состояния: кэш справочника, rate-limiter, дедуп по идемпотентности, метрики. Всё это живёт в синглтоне и читается-пишется из многих потоков. Обычный HashMap здесь недопустим — он ломается в многопоточном коде (порча структуры, в старых версиях — вечный цикл на ресайзе):
private final Map<String, Rate> limits = new ConcurrentHashMap<>(); // а не HashMap
@Async: свой пул, а не общий
@Async уводит выполнение метода в другой поток. Но по умолчанию Spring берёт простой executor, который под нагрузкой плодит потоки без ограничения. Всегда задавайте свой пул с понятными границами:
@Bean("reportExecutor")
public Executor reportExecutor() {
var ex = new ThreadPoolTaskExecutor();
ex.setCorePoolSize(4);
ex.setMaxPoolSize(8);
ex.setQueueCapacity(100); // очередь, чтобы не расти бесконечно
ex.setThreadNamePrefix("report-"); // имя в thread dump — спасение при разборе
ex.initialize();
return ex;
}
@Async("reportExecutor")
public CompletableFuture<Report> build(long id) { ... }
Имя потока в префиксе — не косметика: когда сервис зависнет, вы будете читать thread dump, и report-3 сразу скажет, где встали потоки.
Исчерпание пула — самый частый прод-инцидент
Вот сценарий, который кладёт сервисы чаще всего. Есть пул на 8 потоков. Внутри задачи — блокирующий вызов (HTTP к другому сервису, который затупил). Все 8 потоков заняты ожиданием, очередь заполнилась, новые задачи отклоняются — приложение «висит», хотя процессор простаивает.
@Async("reportExecutor")
public void build(long id) {
externalApi.call(); // висит 30 секунд → поток занят 30 секунд
}
Это не лечится «добавить потоков» — это лечится пониманием, что блокировка в ограниченном пуле — это заём против всех остальных задач. Отсюда и таймауты на внешние вызовы, и отдельные пулы под разные виды нагрузки, и — как более фундаментальное решение — виртуальные потоки, где блокировка перестаёт занимать дорогой ресурс.
@Transactional не переходит в другой поток
Тонкая, но болезненная грабля. Транзакция Spring привязана к потоку (через ThreadLocal). Как только вы уходите в @Async или свой пул — вы в другом потоке, и транзакции там уже нет:
@Transactional
public void process(long id) {
executor.submit(() -> repo.save(entity)); // ← это уже ВНЕ транзакции
}
Тот же механизм ломает @Transactional, вызванный внутри того же бина (self-invocation), и ленивую загрузку Hibernate в асинхронном потоке (LazyInitializationException). Правило: транзакция живёт в одном потоке; если работа уходит в другой поток — заводите транзакцию там явно. Подробнее про сами транзакции — в статье про @Transactional.
Виртуальные потоки в Spring Boot
С Spring Boot 3.2+ и Java 21 виртуальные потоки включаются одним флагом:
spring.threads.virtual.enabled=true
Теперь каждый HTTP-запрос обслуживает свой виртуальный поток, и блокирующий вызов не занимает дорогой платформенный поток — throughput на IO-bound нагрузке растёт без реактивщины. Но важно: виртуальные потоки не отменяют всё выше. Синглтоны так же общие, гонки так же возможны, @Transactional так же привязан к потоку. Более того, больше параллелизма — больше конкуренции за общее состояние, а synchronized вокруг долгой операции «пришпиливает» (pinning) виртуальный поток к несущему. Виртуальные потоки убирают проблему дорогой блокировки — но не проблему разделяемых данных.
Где это применяется
Всё перечисленное — не экзотика, а ежедневная жизнь backend-сервиса: счётчик метрик из многих запросов, общий кэш, @Async-отправка письма, фоновая задача, которая пишет данные, читаемые HTTP-потоком. Понимание модели памяти, гонок и пулов — это разница между сервисом, который держит нагрузку, и тем, что теряет инкременты, виснет на исчерпанном пуле или молча пишет вне транзакции.
Коротко
- Бины Spring — синглтоны: изменяемое поле — общее для всех потоков-запросов, отсюда гонки. Держите состояние потокобезопасным (атомики, ConcurrentHashMap) или не держите вовсе.
@Async— всегда со своим пулом с границами и именем потока; дефолтный executor опасен.- Исчерпание пула блокирующими вызовами — самый частый прод-затык: таймауты, отдельные пулы, виртуальные потоки.
@Transactionalпривязан к потоку: уход в@Async/пул выносит работу из транзакции; та же природа у self-invocation иLazyInitializationException.- Виртуальные потоки (Spring Boot 3.2+) убирают дорогую блокировку, но не отменяют разделяемое состояние, гонки и привязку транзакции к потоку.
Что почитать дальше
- Гонки и модель памяти — почему
count++в синглтоне теряет данные. - Пулы потоков и типичные баги — как устроен пул и как он исчерпывается.
- Виртуальные потоки — фундаментальное решение проблемы блокировки.