Или: как спорили о реактивщине, а потом Java 21 всё запутала ещё сильнее
Реактивное программирование обещало нам масштабируемость и производительность. Оно их дало. Но вместе с этим подарило новый вид боли: stacktrace на 47 фреймов, половина из которых operator.onNext, а элементарные вещи написаны так, словно решаешь задачу на leetcode. Потом подвезли Virtual Threads, и половина интернета написала «реактивщина умерла», другая – «ничего не изменилось». Истина где-то посередине.
Попробуем с вами разобраться.
Я не назову себя экспертом в Java. И уж тем более не назову экспертом в нагрузочном тестировании. Даже не буду притворяться. Тесты проводились с ограниченным набором сценариев, где с перегибами, где без достижения saturation point. Это попытка «потыкать палкой и посмотреть, что будет», а не исследование с претензией на универсальные выводы.
Что здесь есть: рабочая методология, воспроизводимые конфигурации и честная интерпретация того, что получилось. Иной тьюнинг, тесты, условия, железо, дадут другие цифры.
Код и конфигурация Gatling-тестов на GitHub: https://github.com/herbert-garrison/spring-benchmark
Три модели, которые нам нужно понять
Прежде чем идти дальше, зафиксируем три игрока нашей истории.
N.B. Undertow как сервер опускаем – его поддержка выпилена начиная со Spring Framework 7 (Spring Boot 4). Он не Servlet 6.1 compatible
WebMVC + платформенные потоки (классика)
Сервер: Tomcat (или Jetty)
Модель: thread-per-request
I/O: блокирующий (JDBC, Spring RestTemplate/RestClient, etc.)
Код: обычная императивная Java
@GetMapping("/users/{id}") public User getUser(@PathVariable Long id) { // Поток блокируется здесь пока БД не ответит return userRepository.findById(id).orElseThrow(); }
Плюсы: просто, понятно, любой джун разберётся.
Минусы: начинает плохо масштабироваться при высокой конкурентности + медленных I/O операциях.
WebFlux + реактивные потоки (реактивщина)
Сервер: Netty
Модель: event loop + неблокирующий I/O
I/O: неблокирующий (R2DBC, Spring WebClient, экосистема местами дырявая – иногда приходится писать реактивную обвязку руками, потому что её либо нет, либо как reactor-kafka является discontinued)
Код: реактивный (Mono/Flux, операторы)
@GetMapping("/users/{id}") public Mono<User> getUser(@PathVariable Long id) { // Ни один поток не блокируется -- callback-цепочка return userRepository.findById(id) .switchIfEmpty(Mono.error(new NotFoundException())); }
Плюсы: отличная масштабируемость, нативный стриминг, меньше потоков.
Минусы: крутая кривая обучения, ад при дебаге, надо менять весь стек.
WebMVC + Virtual Threads (новый игрок, Java 21+)
Сервер: Tomcat (с виртуальными потоками)
Модель: thread-per-request, но «потоки» дешёвые
I/O: блокирующий синтаксически, неблокирующий фактически
Код: обычная императивная Java (без изменений!)
// Тот же код что и в WebMVC -- но теперь поток виртуальный @GetMapping("/users/{id}") public User getUser(@PathVariable Long id) { // JVM паркует виртуальный поток при I/O, освобождая carrier thread return userRepository.findById(id).orElseThrow(); }
Плюсы: простота WebMVC + масштабируемость реактивного подхода (почти).
Минусы: «почти» – важное слово. Не может быть всё настолько просто.
Virtual Threads – как это работает под капотом
Это ключевая часть для понимания, почему Virtual Threads не просто «дешёвые потоки».
Carrier threads и монтирование
Virtual Thread (VT) – это просто объект в куче JVM. Он не привязан к OS-потоку постоянно. Вместо этого он монтируется (mount) на реальный OS-поток (называемый carrier thread) только когда выполняет код.
Carrier threads (обычно = CPU cores): [Carrier-1]: [VT-1 код]░░░░[VT-2 код]░░[VT-1 callback]░░░░ [Carrier-2]: [VT-3 код]░░░░░░░░░[VT-4 код]░░░░░░░░░░░░░░░░ VT-1, VT-3: ждут I/O → демонтированы, не занимают carrier threads ░ = carrier thread свободен и берёт другие VT
Когда VT делает блокирующий I/O (например, читает из сокета), JVM:
Обнаруживает, что операция блокируется
Сохраняет стек VT в кучу
Демонтирует VT с carrier thread
Carrier thread берёт другой готовый VT
Когда I/O завершается – VT снова монтируется на свободный carrier thread
Это называется cooperative scheduling на уровне JVM. То есть JVM по-сути взяла на себя то, что делает OS с нативными потоками: выполнение части инструкций за отведённый квант времени, многозадачность и переключение контекста. Только вместо переключения процессора с одного потока на другой здесь переключение нескольких VT в рамка одного carrier thread. И вместо кванта времени факт блокировки VT.
Главная ловушка: synchronized и pinning
Вот где начинается интересное. Если внутри synchronized блока или метода происходит блокирующий I/O – VT не может демонтироваться. Он «пиннится» к carrier thread.
// ПЛОХО: VT пиннится к carrier thread на время I/O synchronized (lock) { var result = jdbcTemplate.query(...); // блокирующий I/O // Carrier thread заблокирован всё это время! } // Х��РОШО: используем ReentrantLock вместо synchronized private final ReentrantLock lock = new ReentrantLock(); lock.lock(); try { var result = jdbcTemplate.query(...); // VT демонтируется нормально } finally { lock.unlock(); }
Почему synchronized так работает? Потому что JVM реализует мониторы через OS-примитивы, привязанные к конкретному потоку. А VT при демонтировании «теряет» привязку.
Многие популярные библиотеки используют synchronized внутри. До Java 23 драйвер PostgreSQL (pgjdbc) был именно таким – там была куча synchronized. В Java 23+ ситуация улучшается, JEP 491 (Synchronize Virtual Threads without Pinning) решает эту проблему на уровне JVM. В значительной части случаев.
Проверить пиннинг можно так:
# Добавьте к JVM аргументам: -Djdk.tracePinnedThreads=full
В логах увидите:
Thread[#24,ForkJoinPool-1-worker-1,5,CarrierThreads] com.example.SomeLibrary.someMethod(SomeLibrary.java:123) <== pinned
Кратко: если вы на Java 21/22 – ReentrantLock ваш лучший друг. Если вы обновились до Java 24 – забудьте про пиннинг в synchronized как про страшный сон.
Connection pool: второй подводный камень
Virtual Threads дают вам возможность иметь тысячи одновременных «потоков». Но если ваш connection pool к PostgreSQL настроен на 10 соединений – 990 VT будут ждать соединение из пула, а не ответа от БД.
// application.properties // это ОБЯЗАТЕЛЬНО проверить при переходе на VT spring.datasource.hikari.maximum-pool-size=50 # дефолт 10 -- маловато
Тут возникает тонкость: при WebFlux + R2DBC пул тоже ограничен, но реактивный драйвер мультиплексирует запросы иначе. С VT + JDBC каждый ждущий VT держит «слот» в очереди пула.
То есть тут так или иначе требуется настраивать HikariCP.
ThreadLocal: третья ловушка
ThreadLocal – популярный способ хранить контекст (tenant ID, trace context, security context). С VT он работает, но важно понимать: каждый VT имеет свой ThreadLocal. Если у вас 100000 VT, это 100000 копий ваших ThreadLocal-данных.
Spring Security и MDC (Mapped Diagnostic Context) работают через ThreadLocal – с VT оно интегрируется нормально. Но если вы сами создаёте что-то тяжёлое в ThreadLocal – стоит это пересмотреть.
Впрочем со введениями из JEP 506 (Scoped Values) можно вздохнуть спокойно.
Это альтернатива ThreadLocal, созданная специально для миллионов виртуальных потоков. Они иммутабельны, автоматически очищаются после выхода из области видимости и позволяют эффективно пробрасывать контекст вниз по древу вызовов без копирования данных в каждый поток. Поэтому если вы обнови��ись до Java 25 – используйте их смело.
WebFlux изнутри
Project Reactor: Mono и Flux
Project Reactor за основу берёт подход Publisher-Subscriber, он же Observable-Observer. Работавшие с Angular и RxJS сейчас понимающе закивают.
Реактивное программирование предполагает не написание императивного кода, но построение декларативного пайплайна (похожего по виду на Streams API).
В Project Reactor реактивный пайплайн обязан возвращать что-то из этих типов:
Mono<T>– реактивный тип для 0 или 1 элемента.Flux<T>– реактивный тип для 0..N элементов.
Это не просто обёртки. Это описание вычисления, которое запустится только при подписке. Это важно: реактивный пайплайн ленив по-умолчанию.
// Это ничего не делает -- просто описывает пайплайн Mono<User> userMono = userRepository.findById(42L) .map(user -> enrichWithProfile(user)) .doOnNext(user -> log.info("Fetched: {}", user.getName())); // Выполнение начинается только при подписке userMono.subscribe(); // или Spring WebFlux подпишется сам при обращении по HTTP
Учитывая это, в Reactor нет стека вызовов в привычном понимании. Вместо этого существует цепочка операторов, которая выполняется через continuations.
Отсюда и происходят знаменитые портянки со stacktrace – вместо привычного стека вызовов мы видим операторный пайплайн.
Как WebFlux обрабатывает запрос
HTTP Request ↓ Netty (event loop, ~N worker threads где N = число ядер CPU) ↓ DispatcherHandler (аналог DispatcherServlet) ↓ RouterFunction / @RequestMapping ↓ Ваш Handler → возвращает Mono<ServerResponse>/Mono<ResponseEntity>/Mono<T>/Flux<T> ↓ Netty записывает ответ (неблокирующе) ↓ HTTP Response
Netty worker'ы никогда не блокируются. Если вам нужно запустить блокирующий код (скажем, интегрироваться с legacy синхронной библиотекой) – нужно явно переключиться на другой scheduler.
Таких schedulers есть несколько:
parallel– для быстрых неблокирующих CPU-bound операцийsingle– как однопоточный демон для обработки одной и той же задачи, не содержащей гонок, blocking I/O или долгих вычисленийboundedElastic– для долгих, тяжёлых и/или блокирующих операцийimmediate– сразу же выполнить задачу in-place вместо того, чтобы её запланироватьваш собственный (да, так тоже можно)
Для переключения scheduler есть две операции, влияющие на разные части пайплайна:
subscribeOn– влияет на источник данных (upstream)publishOn– влияет на все последующие операторы пайплайна (downstream)
Например:
return Mono.fromCallable(() -> legacyBlockingService.call()) .subscribeOn(Schedulers.boundedElastic()); // выполнится в отдельном пуле
Если забыть subscribeOn и заблокировать Netty worker – увидите классическую ошибку:
reactor.blockhound.BlockingOperationError: Blocking call! java.lang.Thread.sleep
BlockHound – ваш лучший друг при разработке на WebFlux. Подключите его в тестах. Он, конечно, использует bytecode instrumentation, поэтому иногда требует whitelist для некоторых библиотек – не пугайтесь false positive.
Тестирование
Сценарии
В качестве «мяса» мы рассмотрим несколько реальных сценариев и реализуем каждый:
Сценарий A: Multipart Upload → S3. Обычная задача по перегонке байт от клиента на файловое хранилище
Сценарий B: Multipart Upload → Шифрование → S3. Добавляем CPU-bound задачу с трансформацией данных, которая щекочет нервы как WebFlux, так и WebMVC + VT
Сценарий С: SSE/Long Polling. Server-Sent Events – классика для real-time уведомлений
Сценарий D: PostgreSQL + Kafka. Самый распространённый – получил запрос → сходил в БД → отправил в Kafka → ответил
Методология
Тесты проводились на следующей конфигурации:
Сервер: 4 vCPU, 4 ГБ RAM
JVM: Java 25,
-Xmx4g -Xms4g -XX:+UseZGCWebMVC: Tomcat, пул потоков 200 (дефолт)
WebMVC + VT: Tomcat, пул потоков 200 (дефолт) +
spring.threads.virtual.enabled=trueWebFlux: Netty, worker threads = 8 (4 * 2)
Инструмент: Gatling 3.10.3
БД: PostgreSQL 16, HikariCP pool size = 50
S3: MinIO
Kafka: одна нода, без репликации (не продовый, но воспроизводимый)
Результаты: Сценарий A – Multipart Upload → S3
Нагрузка: ступенчатый рамп до 30 RPS, файлы 1-25 МБ (микс)
Метрика | WebMVC | WebMVC + VT | WebFlux |
|---|---|---|---|
p50 (мсек) | 27619 | 14388* | 10053 |
p75 (мсек) | 46499 | 42968* | 20470 |
p95 (мсек) | 61736 | 95633* | 41319 |
p99 (мсек) | 83131 | 115528* | 83064 |
Max (мсек) | 115600 | 110000 | 122500 |
Throughput mean (RPS) | 17.6 | 16.6* | 18.1 |
Ошибки | 0% | 4.7% | 0.6% |
*только успешные запросы



File Upload – это сценарий, где WebFlux в своей стихии. Tomcat держит поток занятым всё время пока байты ползут от клиента до MinIO. WebFlux не выделяет поток на соединение вообще – Netty читает чанк, отдаёт в S3, читает следующий. Отсюда троекратная разница в медиане и стабильный heap без накопления в Old Generation.
VT здесь мало помогает – и это ожидаемо. Tomcat multipart resolver тянет данные синхронно, VT паркуется, но системный вызов чтения всё равно блокирует carrier thread. Получаем overhead планировщика без профита и 4.7% таймаутов как итог.
Если у вас загрузка бинарей – WebFlux это вам позволит сделать из коробки без накладных расходов (правда, если вам потребуется подсчитать размер получаемого бинаря, придётся опять изрядно поприседать). WebMVC же придётся однозначно тьюнить.
Результаты: Сценарий B – Multipart Upload → шифрование → S3
Нагрузка та же: ступенчатый рамп до 30 RPS, файлы 1-25 МБ
Метрика | WebMVC | WebMVC + VT | WebFlux |
|---|---|---|---|
p50 (мсек) | 21634 | 11403* | 11744* |
p75 (мсек) | 47752 | 29824* | 38677* |
p95 (мсек) | 55700 | 81299* | 87323* |
p99 (мсек) | 69522 | 108420* | 114098* |
Max (мсек) | 86267 | 126300 | 120000 |
Throughput mean (RPS) | 17.2 | 17.8* | 15.7* |
Ошибки | 0% | 1.6% | 8% |
*только успешные запросы



Добавление CPU-bound задачи меняет картину радикально – WebFlux из лидера становится аутсайдером по ошибкам. Причина прямая: у Netty 8 worker threads на 4 ядрах. Пока они заняты шифрованием – новые соединения висят в очереди, хвосты распухают, часть запросов вылетает по таймауту. WebMVC с пулом в 200 потоков распределяет CPU-bound работу честнее – каждый поток шифрует свой файл независимо, новые запросы не голодают.
VT где-то в середине: медиана лучше WebMVC, но p95 и p99 хуже. Всё как и предупреждали разработчики из Oracle: CPU-bound задача не даёт виртуальным потокам парковаться, в результате carrier threads насыщаются и преимущество от VT полностью исчезает.
Результаты: Сценарий C – SSE стриминг
500 соединений, hold 120 секунд, параллельно 50 RPS событий
Метрика | WebMVC | WebMVC + VT | WebFlux |
|---|---|---|---|
p50 connection (мсек) | 17 | 6 | 6 |
p99 connection (мсек) | 222 | 14 | 13 |
Max connection (мсек) | 1365 | 813 | 46 |
Ошибки | 0% | 0% | 0% |
Heap под соединения – платформенный поток ~1 МБ стека, VT в куче ~2-4 КБ, Netty channel фиксированный буфер


Казалось бы, это должна быть целиком и полностью вотчина WebFlux. Однако, результаты удивляют.
Интересно, что SseEmitter в WebMVC использует async servlet, отчего Tomcat держит соединения через NIO без блокирующего потока на каждое. Принципиальная разница появляется в max connection time: при установке 500-го соединения WebMVC уже скрипит (1365 мсек), WebFlux и WebMVC + VT не замечают (466 и 813 мсек соответственно).
Результаты: Сценарий D – PostgreSQL + Kafka (обычная бизнес-логика)
Три смешанных сценария одновременно: read до 350 RPS, create+read до 100 RPS, multi-I/O до 50 RPS. Пиковая суммарная нагрузка ~500 RPS
Метрика | WebMVC | WebMVC + VT | WebFlux |
|---|---|---|---|
p50 (мсек) | 3 | 2 | 3 |
p75 (мсек) | 4 | 3 | 8 |
p95 (мсек) | 6 | 5 | 19 |
p99 (мсек) | 8 | 7 | 20 |
Max (мсек) | 665 | 519 | 994 |
Throughput mean (RPS) | 395 | 395 | 395 |
Ошибки | 0% | 0% | 0% |
При типичной бизнес-нагрузке все три сценария справляются – 0 ошибок, throughput одинаковый. p50 у всех 2-3 мсек, это просто шум.
Разница только в хвостах: WebFlux p95/p99 в 3 раза хуже WebMVC (19-20 мсек против 6-8 мсек). Реактивный пайплайн добавляет overhead на каждый оператор – при простом findById -> save -> send это лишние аллокации и планировщик Reactor поверх каждого шага. При умеренной нагрузке это не критично, но в максимумах видно: 994 мсек у WebFlux против 665 у обычного WebMVC.
VT незначительно лучше WebMVC по всем метрикам – меньше thread contention на HikariCP при пиковых 500 RPS.
Для типичного CRUD-микросервиса все три варианта равнозначны по throughput. Выбор между ними – вопрос сложности кода, а не производительности. WebMVC + VT не хуже WebFlux, а писать и отлаживать его проще.
Когда что выбирать?
Новый проект или рефакторинг? │ ├─ Нужен стриминг больших файлов / бинарей? │ └─ WebFlux (backpressure, чанковый стриминг, стабильный heap) │ ├─ Нужны тысячи одновременных долгоживущих соединений (SSE, WebSocket)? │ ├─ WebMVC справляется через async servlet (SseEmitter) │ └─ WebFlux быстрее устанавливает соединения при высокой конкурентности │ ├─ Вся команда понимает реактивное программирование? │ ├─ НЕТ → WebMVC + VT (снизите bus factor и время онбординга) │ └─ ДА → можете взять WebFlux, если есть выгода │ ├─ Java 21+? │ ├─ ДА → WebMVC + VT вместо «голого» WebMVC – стоит попробовать, но учитывайте проблемы с пиннингом и ThreadLocal │ └─ НЕТ → WebFlux – если нужна масштабируемость, WebMVC – если нет │ ├─ Типичный CRUD / микросервис с БД и очередью? │ └─ WebMVC + VT (код проще и чище, производительность не хуже) │ └─ Ваши узкие места – CPU (криптография, сжатие, ML), а не I/O? └─ Ни WebFlux, ни VT особо не помогут – масштабируйте горизонтально
Итоги
Проведённые тесты не претендуют на то, чтобы доказать что-либо окончательно – они лишь не противоречат тому, что говорят люди с реальным продовым опытом.
Virtual Threads – это не убийца WebFlux. Это убийца боли от thread-per-request при высокой конкурентности. Они убирают основной аргумент «WebFlux нужен чтобы не кончались потоки». Но WebFlux решает не только эту проблему – стриминг, massive concurrency, backpressure остаются его территорией.
Virtual Threads – это для тех, кто ждёт (I/O), а не тех, кто считает (CPU). Не пытайтесь майнить крипту или рендерить видео в виртуальных потоках – вы просто парализуете планировщик JVM.
WebMVC + Virtual Threads – это разумный выбор для большинства микросервисов. В типичном CRUD + message broker при умеренной нагрузке разница с WebFlux несущественна, а код в разы проще.
Главный совет: измеряйте свои реальные сценарии. Цифры из этой статьи – лишь ориентир, а не вердикт. Реальная сеть, другое железо, профилировка JVM дадут другие числа. Gatling-конфигурация выше рабочая – берите и пробуйте.
