Мы уже разложили бюджет 300 мс на составляющие, посчитали по закону Литтла, что при 2000–5000 rps и синхронной проверке 100–200 мс в системе одновременно живёт около 1000 запросов, и разобрались, как HAProxy распределяет HTTP/2-streams между бэкендами пула — см. бюджет задержки и выбор транспорта и балансировка L4/L7 и HTTP/2. Эта статья открывает JVM-дополнение серии: тот же сквозной кейс, но с прицелом на конкретный рантайм, где выбор тред-модели решает, выдержит сервис 1000 in-flight запросов или уткнётся в память и очереди задолго до исчерпания CPU.
На JVM для одной и той же задачи — «принять запрос, выполнить блокирующую проверку 100–200 мс, отдать ответ» — есть минимум три архитектурных пути: классический platform-пул потоков, virtual threads (JDK 21+, Project Loom) и полностью реактивный стек. Они не эквивалентны ни по цене ресурсов, ни по сложности кода, ни по тому, как ведут себя под 1000 одновременных проверок. Разбор идёт по той же триаде, что и в предыдущих статьях серии: ✅ правильно / ❌ неправильно / ⚠️ неоптимально.
Рабочий стенд. Всё, о чём идёт речь в серии, собрано в запускаемый пример
performance/highload-lowlatency: HAProxy L7 + пул Go/Java-бэкендов + клиент-нагрузчик,docker compose up. Java-бэкенд стенда (services/java-backend) — Helidon 4 SE (Nima) WebServer на JDK 21: каждый запрос по умолчанию обрабатывается на отдельном virtual thread, без явной настройки пулов. Именно к этому коду отсылают примеры в разделе про virtual threads ниже.
В статье
- Platform-пул
- Virtual threads
- Reactive / WebFlux
- Бэкпрешер и ограничение параллелизма
- Короткий checklist
- Что дальше
Platform-пул
Классическая модель JVM-сервера (Tomcat, Jetty в блокирующем режиме) — поток-на-запрос, но поток здесь платформенный: тонкая обёртка над потоком ОС, со своим стеком (обычно 512 КБ – 1 МБ) и полноценным контекстным переключением через планировщик ядра. Пока запросов немного, это простая и предсказуемая модель: код пишется в блокирующем стиле, Thread.sleep() или блокирующий вызов к БД/HTTP-клиенту просто занимает поток до завершения.
Проблема начинается ровно на цифре из закона Литтла для нашего кейса. Если под 1000 in-flight держать пул платформенных потоков размером 1000, это ~1000 полноценных стеков ОС-уровня (сотни мегабайт памяти только на стеки) и заметные накладные расходы на переключение контекста планировщиком ОС при такой конкуренции за CPU. На практике пул такого размера — уже не «пул», а способ исчерпать память раньше, чем упереться в CPU.
- ❌ неправильно — ограниченный пул (
maxThreadsв пределах десятков-сотен), рассчитанный не отrps × latency, а «на глаз» или по дефолтам фреймворка. Как только число одновременных запросов превышает размер пула, лишние запросы встают в очередь на acceptor’е. Очередь не бесплатна: время ожидания в очереди прибавляется к latency каждого запроса, и при 1000 in-flight против пула в 200 потоков хвост latency улетает далеко за SLA 300 мс — сервис не падает, а просто перестаёт укладываться в бюджет. - ⚠️ допустимо — platform-пул на 1000+ потоков технически может продержаться, если памяти и CPU в достатке, а конкурентность именно такого порядка — постоянная, а не пиковая. Но это дорогое и хрупкое решение: любой всплеск (кратковременный рост rps, замедление зависимости) толкает конкурентность выше 1000, и деградация по памяти/переключениям контекста наступает резко, а не плавно.
// application.yml (Spring Boot, embedded Tomcat, platform threads)
// server:
// tomcat:
// threads:
// max: 200 // потолок конкурентности — НЕ 1000 in-flight
// min-spare: 20
// accept-count: 100 // сверх max — очередь ожидания, добавляет latency
// При 1000 одновременных запросов и max=200:
// ~800 запросов ждут в очереди acceptor'а (или отклоняются, если
// accept-count тоже исчерпан) — латентность каждого из них
// = время в очереди + 100-200мс самой проверки, легко > 300мс SLA.Virtual threads
JDK 21 сделал virtual threads (Project Loom) стабильной частью платформы, и это меняет расчёт из предыдущего раздела принципиально. Virtual thread — не поток ОС, а лёгкая сущность, управляемая самой JVM: тысячи и десятки тысяч virtual threads мультиплексируются на небольшой пул platform-потоков (carrier threads, по умолчанию — по числу ядер). Когда virtual thread блокируется на I/O или на Thread.sleep(), JVM «паркует» его и освобождает carrier thread для другого virtual thread — сам блокирующий вызов при этом остаётся написан как обычный блокирующий код, без единого Future, Mono или callback.
Практический вывод для нашего кейса: 1000 in-flight с блокирующей проверкой 100–200 мс каждая — это ровно тот сценарий, для которого virtual threads спроектированы. Модель «поток на запрос» ложится естественно и остаётся дешёвой: 1000 virtual threads — это не 1000 стеков ОС, а лёгкие объекты в куче, которых JVM может удерживать десятками тысяч без сравнимой цены. Именно так устроен Java-бэкенд рабочего стенда серии — Helidon 4 SE (Nima) обрабатывает каждый запрос на отдельном virtual thread по умолчанию, без какой-либо настройки пулов, и блокирующий Thread.sleep(100..200мс) внутри обработчика не занимает платформенный поток на всё это время.
// services/java-backend/src/main/java/tech/khorost/highload/Main.java
WebServer server = WebServer.builder()
.host(hostPort.host())
.port(hostPort.port())
.routing(routing -> routing
.post("/check", handler::handleCheck)
.get("/healthz", Main::handleHealthz))
.build()
.start();
// Никакой явной настройки пула потоков: Helidon 4 Nima по умолчанию
// обрабатывает каждый запрос на отдельном virtual thread.
private void handleCheck(ServerRequest req, ServerResponse res) throws IOException {
long current = inFlight.incrementAndGet();
try {
String body = readBody(req); // блокирующее чтение — ОК
long delayMs = SIM_MIN_DELAY_MS + ThreadLocalRandom.current().nextLong(SIM_MAX_JITTER_MS);
Thread.sleep(delayMs); // имитация проверки 100-200мс —
// блокирует virtual thread, НЕ carrier
res.header("Content-Type", "application/json");
res.send(buildJson(...));
} finally {
inFlight.decrementAndGet();
}
}Для Spring Boot путь ещё проще: spring.threads.virtual.enabled=true переключает embedded Tomcat/обработку запросов на virtual threads без изменения кода контроллеров — тот же блокирующий стиль, что и всегда, но без цены platform-пула.
// Никакого спец-кода: spring.threads.virtual.enabled=true сам переносит синхронный
// путь запроса на virtual threads. Обработчик остаётся блокирующим, как и был.
@PostMapping("/check")
public CheckResponse check(@RequestBody CheckRequest req) throws InterruptedException {
long delayMs = simulateCheckDelay();
Thread.sleep(delayMs); // блокирующий стиль, дёшево на virtual thread
return new CheckResponse(req.requestId(), delayMs);
}Что при этом не является переключателем синхронных контроллеров — bean applicationTaskExecutor с newVirtualThreadPerTaskExecutor(). Его иногда показывают как «альтернативу» свойству, но это неточно: этот AsyncTaskExecutor управляет асинхронной обработкой Spring MVC — возвратом Callable/WebAsyncTask из обработчика и @Async-методами. Обычный синхронный @RequestMapping-обработчик исполняется на потоке контейнера сервлетов (Tomcat) и переносится на virtual threads именно spring.threads.virtual.enabled=true, а не переопределением этого bean. Свойство — переключатель синхронного пути; applicationTaskExecutor — рычаг для async-обработки.
Важная оговорка, о которой легко забыть: virtual threads не отменяют блокировку синхронизированных секций (synchronized, до JDK 24 они «прикалывают» — pin — virtual thread к carrier thread на время удержания монитора) и не отменяют нужды в ограничении параллелизма к внешним зависимостям — об этом ниже, в разделе про бэкпрешер. Более глубокий разбор модели virtual threads в сравнении с корутинами Kotlin и reactive — в статье конкурентность на JVM: virtual threads, корутины и reactiveготовится, с 30 июля.
Reactive / WebFlux
Реактивный стек (Project Reactor, Spring WebFlux, Reactor Netty) решает ту же задачу — держать много одновременных I/O-bound операций — но другим способом: event loop на небольшом числе потоков, где ни один поток никогда не блокируется, а вся работа описывается как конвейер операторов (map, flatMap, zip) над Mono/Flux. Для чисто I/O-bound проверки (сетевой вызов к внешнему сервису, ожидание ответа) reactive эффективен по ресурсам почти так же, как virtual threads: и там, и там мы не держим тысячи дорогих platform-потоков ради тысячи ожидающих операций.
- ✅ по ресурсам — event loop не платит за конкурентность стеками потоков, реактивный клиент (
WebClientна Reactor Netty) умеет держать тысячи одновременных исходящих запросов на пуле из нескольких event-loop потоков. - ⚠️ по сложности — цена reactive не в ресурсах, а в когнитивной и инженерной нагрузке. Реактивность «заражает» кодовую базу: как только один шаг цепочки становится асинхронным, весь путь вызова до него должен быть реактивным (нельзя просто вызвать блокирующий метод из середины реактивного конвейера — это тихо уничтожает всю выгоду, блокируя event-loop поток). Отладка сложнее: трассировка стека в реактивном коде показывает внутренности Reactor, а не бизнес-логику; профилирование и логирование требуют реактивного контекста (
Context,Hooks.onEachOperatorдля распространения MDC); ошибка в одном оператора цепочки может уронить весь поток обработки способом, который не всегда очевиден по стектрейсу.
@PostMapping("/check")
public Mono<CheckResponse> check(@RequestBody CheckRequest req) {
return webClient.post()
.uri("/external-validate")
.bodyValue(req)
.retrieve()
.bodyToMono(ValidationResult.class)
.timeout(Duration.ofMillis(250)) // таймаут в пределах SLA
.map(result -> new CheckResponse(req.requestId(), result))
.doOnError(err -> log.warn("validate failed: {}", err.toString()));
// Ни один поток event-loop не блокируется на время сетевого ожидания —
// но ЛЮБОЙ блокирующий вызов внутри этой цепочки (JDBC, синхронный SDK)
// должен быть явно вынесен на boundedElastic(), иначе он застопорит
// весь event-loop поток и всех, кто на нём мультиплексируется.
}Практический вывод для нашего кейса: если проверка — синхронный блокирующий вызов (как в стенде — Thread.sleep, имитирующий CPU/IO-bound работу без готового неблокирующего клиента), virtual threads решают задачу тем же кодом, что и обычный blocking-сервис, без реактивной обвязки. Reactive оправдан, когда изначально есть полностью неблокирующий стек снизу доверху (реактивный драйвер БД, реактивный HTTP-клиент к зависимости) и/или когда конкурентность на порядки выше — десятки-сотни тысяч, где даже дешёвые virtual threads начинают давать заметный оверхед на диспетчеризацию. Для ~1000 in-flight эта грань, как правило, не достигается, и virtual threads проще при сравнимой эффективности по ресурсам. Подробное сравнение — в статье reactive на JVM: Project Reactor, WebFlux и когда он больше не нуженготовится, с 6 августа.
Бэкпрешер и ограничение параллелизма
Ни одна из тред-моделей не отменяет главного правила: даже когда сам поток обработки почти бесплатен (virtual thread) или вовсе не блокируется (reactive), зависимость, к которой идёт проверка, — не бесплатна и не бесконечна. Если 1000 запросов одновременно ударят во внешний сервис проверки, в БД-соединение или в другой ограниченный ресурс, дешёвая конкурентность на стороне JVM-сервиса просто переносит проблему перегрузки на шаг дальше — и там она может быть куда болезненнее, потому что зависимость не спроектирована держать 1000 одновременных вызовов.
Отсюда три обязательных элемента независимо от выбранной тред-модели:
- Ограничитель параллелизма к зависимости — bounded pool соединений или
Semaphore, явно посчитанный (не от общего числа in-flight запросов сервиса, а от того, сколько параллельных вызовов реально выдерживает конкретная зависимость). - Таймаут на саму проверку, отдельный от общего SLA и меньше его: если проверка не уложилась в разумное время, нет смысла ждать до последней миллисекунды бюджета — быстрее освободить слот и вернуть ошибку/деградацию.
- Fail-fast при перегрузке — если ограничитель исчерпан, новый запрос должен быстро получить отказ (или деградированный ответ), а не встать в неограниченную очередь. Неограниченная очередь на дешёвых virtual threads — это тот же самый рост latency, что и очередь platform-пула, просто задержанный: сама очередь бесплатной не бывает никогда.
private final Semaphore validationSlots = new Semaphore(200); // посчитано от capacity зависимости
private CheckResponse check(CheckRequest req) throws InterruptedException {
if (!validationSlots.tryAcquire(50, TimeUnit.MILLISECONDS)) {
// fail-fast: не встаём в очередь на неопределённое время,
// отдаём отказ/деградацию, пока бюджет SLA ещё не исчерпан
throw new ServiceOverloadedException("validation pool exhausted");
}
try {
return callValidationWithTimeout(req, Duration.ofMillis(220)); // < оставшийся бюджет SLA
} finally {
validationSlots.release();
}
}Virtual threads и bounded executor вокруг зависимости не противоречат друг другу: virtual threads снимают ограничение на дешевизну «потока на запрос» внутри сервиса, а Semaphore/bounded pool защищает конкретный внешний ресурс — это два независимых слоя контроля конкурентности, и оба нужны одновременно.
Короткий checklist
- Тред-модель сервиса рассчитана под реальную цифру in-flight (
rps × latencyиз закона Литтла), а не подобрана по дефолтам фреймворка? - Выбор между virtual threads и reactive обоснован характером проверки (блокирующая vs полностью неблокирующий стек) и порядком конкурентности, а не модой на реактивность?
- Если используется platform-пул — понятно, что рост очереди сверх его размера напрямую бьёт в SLA, и это осознанный компромисс, а не забытая настройка по умолчанию?
- Ограничен ли параллелизм к внешней зависимости проверки отдельно от общей конкурентности сервиса (bounded pool/
Semaphore, посчитанный от capacity зависимости)? - Есть ли таймаут на саму проверку, меньший общего бюджета SLA?
- Реализован ли fail-fast при исчерпании ограничителя — вместо неограниченной очереди, которая переносит проблему в latency, а не решает её?
Что дальше
Мы разобрали тред-модель JVM-сервиса под 1000 in-flight: почему platform-пул упирается в память и очереди, почему virtual threads естественно ложатся на блокирующий стиль этого кейса, когда reactive оправдан несмотря на сложность, и почему бэкпрешер к зависимости нужен при любой из моделей. Следующая статья серии переходит на клиентскую сторону JVM-сервиса — как JVM-клиент (в противовес Go-клиенту из предыдущей статьи) держит пул HTTP/2-соединений и параллельные вызовы под той же нагрузкой: JVM-клиент под нагрузкой.
См. также
- Бюджет задержки и выбор транспорта
- Балансировка L4/L7 и HTTP/2
- Конкурентность на JVM: virtual threads, корутины и reactiveготовится, с 30 июля
- Reactive на JVM: Project Reactor, WebFlux и когда он больше не нуженготовится, с 6 августа
Комментарии