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

Частое возражение: «сервисы сейчас stateless, многопоточка — теория, максимум навесить @Async». Это заблуждение. Stateless — про горизонтальное масштабирование (нет сессии между запросами, можно поднять 10 копий), и оно никак не отменяет параллелизм внутри процесса. Разберём, где многопоточность кусает в обычном Spring-приложении каждый день и почему модель памяти с гонками здесь не теория.

http-1 http-2 http-3 поток на каждый запрос, бин один на всех@Serviceполе общее count = 0 count = 1 count = 2 три ++, count = 2одна прибавка потерялась @Async: пул на 2 потока, очередь на 2пул задач report-1: ждётreport-2: ждётблокирующий вызов задача 3задача 4очередь задача 5✗ новые — отклоняютсяприложение «висит»,процессор простаивает

Верхняя сцена: потоки запросов 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 repo.save commit или откат поток пула submit repo.save вне транзакции

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

Рядом стоит похожая по симптомам, но другая по природе беда: @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), а не потоки.

Что почитать дальше