← назад к разделу

Частое возражение: «сервисы сейчас 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++ в синглтоне теряет данные.
  • Пулы потоков и типичные баги — как устроен пул и как он исчерпывается.
  • Виртуальные потоки — фундаментальное решение проблемы блокировки.