Виртуальные потоки после JDK 24: что изменилось в Java
Главное
JEP 491 в JDK 24 устранил закрепление виртуальных потоков за потоками-носителями при работе с мониторами. Именно из-за этого механизма переход на виртуальные потоки в Java 21 был рискованным для блокирующего кода внутри synchronized. Однако закрепление всё ещё возможно при выполнении нативного кода, загрузке классов и локальных файловых операциях в Linux. Поэтому событие JDK Flight Recorder (JFR) jdk.VirtualThreadPinned по-прежнему должно входить в контрольный список перед внедрением.
Главный эксплуатационный риск сместился от нехватки свободных потоков-носителей к исчерпанию ресурсов ниже по цепочке вызовов. Когда виртуальные потоки снимают ограничение, заданное размером пула потоков servlet-контейнера, реальными границами параллелизма становятся пулы соединений, лимиты запросов, файловые дескрипторы и зависимые сервисы.
Кеширование через ThreadLocal при работе с виртуальными потоками незаметно перестаёт быть кешированием. В сопутствующем бенчмарке кеш на основе
ThreadLocal.withInitial()инициализировался 200 раз при использовании платформенных потоков и 443 267 раз при использовании виртуальных потоков на той же нагрузке. Разница в 2216 раз сначала проявилась как необъяснимый рост нагрузки на сборщик мусора.Scoped Values, окончательно утверждённые в JDK 25 в рамках JEP 506, подходят для замены ThreadLocal при хранении и передаче контекста запроса, которым управляет приложение. Они избавляют от неожиданностей при передаче InheritableThreadLocal и автоматически распространяются на дочерние задачи
StructuredTaskScope. При финализации API изменился один важный момент: вызовScopedValue.orElse(null)больше не допускается.Виртуальные потоки упрощают блокирующие сервисы, построенные по модели «запрос — ответ», но не заменяют механизм обратного давления реактивного стека. Spring MVC с виртуальными потоками можно считать удачным вариантом по умолчанию для сервисов с блокирующим вводом-выводом. Spring WebFlux остаётся правильным выбором для потоковой передачи данных, SSE, WebSocket и систем, чувствительных к обратному давлению.
Введение
Чтобы включить виртуальные потоки в сервисе на Spring Boot 3, достаточно изменить одну настройку. А что произойдёт через неделю, во многом зависит от количества синхронизированного кода в ваших зависимостях, от предположений о повторном использовании потоков в коде с ThreadLocal и от того, на какую модель рассчитан пул соединений: на десятки потоков ОС или на десятки тысяч виртуальных потоков.
Мы оценивали переход на виртуальные потоки для высоконагруженных сервисов с интенсивным вводом-выводом. В таких системах основная часть времени уходит не на вычисления, а на ожидание ответов от внешних API и выполнение запросов к базе данных. Первые наблюдения совпали с прогнозами JEP 444 Virtual Threads: конечные точки, ограниченные вводом-выводом, масштабировались лучше на том же объёме кучи, а число потоков, которое прежде приходилось тщательно подбирать, перестало быть проблемой.
Кроме того, проявились проблемы, на которые руководства по миграции почти не обращали внимания: кеширование незаметно переставало работать, контекст корректно передавался в среде разработки, но исчезал под нагрузкой, а узкие места (bottleneck) смещались к зависимым системам и ресурсам. Одно лишь обновление JDK эти проблемы решить не могло. В этой статье мы разберём эти наблюдения, их эксплуатационные последствия и возможные меры.
Все приведённые далее показатели и панели мониторинга взяты из общедоступного бенчмарка. Он воспроизводит те же сценарии отказа в контролируемой среде: одинаковые параметры JVM, зависимости и хост, а различия между профилями явно задокументированы.
В сценарии с ThreadLocal фактически меняется только модель потоков. В JDBC-сценарии предельный размер пула соединений изменён намеренно, чтобы показать перемещение узкого места. Благодаря этому любой читатель может воспроизвести описанные закономерности на своём оборудовании, не полагаясь на закрытые данные в продакшене. Исходный код, панели мониторинга и необработанные результаты доступны в репозитории GitHub.
На рисунке 1 показана топология бенчмарка. Два экземпляра приложения работают на одном хосте с одинаковыми параметрами JVM, зависимостями и серверными компонентами. Между профилями различаются только настройка модели потоков и размер пула соединений.
Такое построение сохраняет стабильность среды и делает различия между профилями явными. Поэтому изменения можно оценивать в контролируемом эксперименте, не смешивая их с шумом окружения.

Основной эффект был измерен на экземпляре c7i.2xlarge с 8 vCPU и 15 ГиБ оперативной памяти на Ubuntu 24.04 с Temurin 25.0.3. В течение 60 секунд 500 одновременных пользователей обращались к конечной точке, на которой воспроизводился описанный далее сценарий с неэффективным кешированием через ThreadLocal.
Метрика | Платформенные потоки | Виртуальные потоки | Изменение |
|---|---|---|---|
Пропускная способность, запросов/с | 3594 | 7426 | +107% |
Задержка p99, мс | 147 | 53 | −64% |
Доля ошибок, % | 0,00 | 0,00 | — |
Число инициализаций SimpleDateFormat в ThreadLocal | 200 | 443 267 | 2216× |
Эта конечная точка воспроизводит типичный сценарий обработки запроса, основное время в котором занимает ввод-вывод. В качестве внешней системы используется WireMock с искусственной задержкой ответа 300 мс, поэтому результаты нельзя считать общей оценкой пропускной способности виртуальных потоков. Кроме того, в конечную точку встроен инструментированный кеш на базе ThreadLocal, позволяющий наблюдать за выделением памяти. При этом запрос проходит через тот же механизм диспетчеризации servlet-контейнера, что и в реальном сервисе. На рисунке 2 показана телеметрия двух последовательных запусков за всё окно бенчмарка.

На верхнем графике пропускная способность достигает примерно 4 000 запросов в секунду для платформенных потоков и 10 000 для виртуальных. На втором графике задержка p99 под нагрузкой в обоих режимах достигает сотен миллисекунд, однако показатели виртуального режима (оранжевая линия) стабильно ниже, чем платформенного (зелёная линия), а p999 (синяя линия), как и ожидается при длительной нагрузке, выше обоих значений.
Третий график (занятый объём кучи) показывает побочный эффект. В платформенном режиме зелёная линия растёт медленно и плавно, а сборка мусора выполняется сравнительно редко. В виртуальном режиме жёлтая линия образует значительно более выраженную пилу. Память выделяется быстрее, поскольку каждый запрос заново создаёт кеш ThreadLocal. Тот же эффект количественно показан на рисунке 3. Нижний график подтверждает это по времени пауз сборщика мусора: во время нагрузочного теста в виртуальном режиме появляется группа пауз длительностью около 4,5 мс, тогда как в платформенном режиме дополнительная активность сборщика мусора почти отсутствует.
Результат оказался двояким. В виртуальном режиме приложение выделяет больше памяти, но всё равно выигрывает по пропускной способности и задержке. Даже при дополнительной работе паузы GC остаются короче 5 мс, поэтому сборщик мусора не становится новым узким местом. Однако при внедрении в продакшене следует ожидать роста активности GC пропорционально нагрузке на выделение памяти. Источник этой нагрузки показан на рисунке 3.
В JDK 24 устранили наиболее заметную проблему, с которой сталкивались первые пользователи виртуальных потоков при работе с мониторами. JDK 25 — первый релиз с долгосрочной поддержкой, включающий JEP 491 и финальную версию Scoped Values. Поэтому в 2026 году он становится естественной целью для команд, выбирающих между JDK 21 LTS и JDK 25 LTS. Далее мы разберём, где теперь сосредоточены риски, какие проблемы уже решены, а какие требуют осознанных архитектурных решений независимо от версии JDK.
Риски изменились, но не исчезли
В JDK 21 основным сценарием отказа была нехватка свободных потоков-носителей из-за блоков synchronized. JVM выполняет виртуальные потоки на общем пуле платформенных потоков, которые называют потоками-носителями. Пулом управляет планировщик ForkJoin. По умолчанию его уровень параллелизма, то есть количество потоков-носителей, одновременно выполняющих виртуальные потоки, соответствует числу доступных процессоров. В JDK 21 виртуальный поток, вошедший в синхронизированный блок, не мог отсоединиться от потока-носителя, даже если затем блокировался на операции ввода-вывода. Поток-носитель оставался занятым. Если достаточно много виртуальных потоков одновременно оказывались внутри синхронизированных блоков, свободных носителей не оставалось и планировщик больше не мог передавать остальные виртуальные потоки на выполнение потокам-носителям. В результате система останавливалась.
Это был не теоретический и не единичный сценарий. Команда JVM Ecosystem компании Netflix описала именно такой отказ в нескольких сервисах Spring Boot 3 на Java 21 со встроенным Tomcat. Мы наблюдали ту же картину во время собственной оценки JDK 21: всплески конкуренции за синхронизированные блоки в зависимостях для журналирования или сбора метрик лишали планировщик свободных потоков-носителей. Экземпляры переставали обрабатывать трафик, хотя JVM продолжала работать, а число сокетов в состоянии CLOSE_WAIT устойчиво росло.
Обычный дамп потоков показывал на вид простаивающую JVM без потоков, ожидающих захвата монитора и очевидных блокировок: стеки виртуальных потоков не попадают в вывод jstack. Только команда jcmd с параметрами Thread.dump_to_file -format=json показала тысячи созданных виртуальных потоков, которые планировщик не мог передать на выполнение. Все потоки-носители были закреплены внутри кода Tomcat, обрабатывающего соединения. Статья Netflix Java 21 Virtual Threads — Dude, Where’s My Lock? остаётся самым наглядным опубликованным разбором ситуации, когда закрепление приводит к взаимной блокировке, проявляющейся не ошибкой, а молчанием системы.
JEP 491 Synchronize Virtual Threads without Pinning, реализованный в JDK 24, изменил механизм учёта владельцев мониторов: теперь JVM использует идентификатор самого виртуального потока, а не его носителя. Виртуальный поток может отсоединиться от носителя внутри синхронизированного блока, вызвать Object.wait(), а после пробуждения снова захватить монитор, не закрепляя поток-носитель. JEP 491 устраняет именно тот механизм закрепления на мониторах, который лежал в основе описанных Netflix остановок. Для большинства сервисов это означает, что главное препятствие к внедрению снято и достаточно обновить JDK.
Оставшиеся случаи закрепления встречаются реже и в типичном прикладном коде с меньшей вероятностью приводят к каскадным остановкам.
Сценарий | JDK 21 | JDK 24 и новее, включая JDK 25 LTS |
|---|---|---|
Блокирующий ввод-вывод внутри синхронизированного кода | Закрепляет поток-носитель | Больше не закрепляет благодаря JEP 491 |
Object.wait() внутри синхронизированного кода | Закрепляет поток-носитель | Больше не закрепляет благодаря JEP 491 |
Кадр нативного метода JNI или FFM | Закрепляет поток-носитель | По-прежнему закрепляет |
Загрузка или инициализация класса | Закрепляет поток-носитель | По-прежнему закрепляет |
Файловый ввод-вывод на локальном диске в Linux | Закрепляет поток-носитель | По-прежнему закрепляет |
Диагностика | -Djdk.tracePinnedThreads | Событие JFR jdk.VirtualThreadPinned, порог по умолчанию в JDK 25 — 20 мс |
На практике важны два оставшихся случая. Закрепление при выполнении нативного кода встречается в приложениях, которые обращаются к нативным библиотекам, выполняющим обратные вызовы Java. Нативный код может хранить непосредственные указатели на стек, поэтому безопасно отсоединить виртуальный поток удаётся не всегда. Для большинства веб-сервисов это редкость, однако приложения с некоторыми криптографическими провайдерами или специализированными библиотеками ввода-вывода следует проверить с помощью JFR.
Более распространённый сюрприз связан с файлами: локальные файловые операции в Linux всё ещё закрепляют поток-носитель. В JDK нет готовой к продакшену интеграции с io_uring. В обсуждениях списков рассылки JDK практическими препятствиями назывались совместимость с версиями ядра и поддержка в средах выполнения контейнеров. Сторонние библиотеки на основе Project Panama, например экспериментальная JUring, демонстрируют примерно в четыре раза более высокую пропускную способность чтения за счёт прямой работы с io_uring, но не входят в JDK.
Чтобы выявить оставшиеся закрепления, настройте JFR на запись событий jdk.VirtualThreadPinned и прогоните нагрузочные тесты до внедрения в продакшене. Системное свойство -Djdk.tracePinnedThreads, доступное в JDK 21, было удалено в JDK 24.
ThreadLocal: два незаметных сценария отказа
Отслеживание закреплений — правильный первый шаг. Для выявления второй проблемы нужна ревизия кода, а не настройка наблюдаемости: она не вызывает исключений и предупреждений и не обязательно приводит к очевидному падению производительности, пока её не начнут искать целенаправленно.
Класс ThreadLocal появился в 1998 году и рассчитан на долгоживущие платформенные потоки из пула. Пул из двухсот потоков может работать часами или днями. В такой модели значение, один раз созданное для потока и затем повторно использованное тысячи раз, вполне уместно. Виртуальные потоки живут недолго и не используются повторно для разных запросов. Каждый новый виртуальный поток получает собственный экземпляр ThreadLocalMap.
Из этого следуют два разных сценария отказа: кеш незаметно перестаёт кешировать, а контекст InheritableThreadLocal в дочерних задачах оказывается равным null.
Кеш, который незаметно перестаёт кешировать
В продакшен-коде часто встречается ThreadLocal.withInitial(): с его помощью один раз для каждого потока лениво создают дорогостоящий и непотокобезопасный объект, а затем используют его в разных запросах. Канонический пример — SimpleDateFormat. Аналогичный подход встречается у объектов, связанных с JDBC, и некоторых средств сериализации. Его также применяют для JSON-сериализаторов, повторно используемых буферов байтов, экземпляров ObjectMapper и библиотечных кешей, рассчитанных на небольшое стабильное число долгоживущих потоков.
С виртуальными потоками выделение памяти происходит для каждой задачи, а после её завершения объект отбрасывается. С точки зрения корректности ThreadLocal продолжает работать и не выдаёт ошибок. Просто при каждом запросе значение вычисляется заново.
Во время нашей оценки виртуальных потоков проявились оба сценария. Промахи кеша сначала выглядели как рост активности GC и постоянно увеличивающаяся скорость выделения памяти в сервисах, где, кроме настройки потоков, ничего не менялось. Симптом настолько общий, что его легко принять за «повышенные накладные расходы виртуальных потоков».
Самый быстрый способ обнаружить причину — инструментировать сам ThreadLocal: создать подкласс и увеличивать счётчик Micrometer при каждом вызове initialValue(). Тогда частота промахов кеша становится наблюдаемой метрикой. В сопутствующем бенчмарке именно такая инструментализация встроена в контролируемый сценарий. Вот результаты 60-секундного запуска с 500 одновременными пользователями на конечной точке, использующей кеш:
# Платформенный режим, порт 8080 threadlocal_initializations_total{cache_name="SimpleDateFormat"} 200 # Виртуальный режим, порт 8081; та же нагрузка и параметры JVM threadlocal_initializations_total{cache_name="SimpleDateFormat"} 443267
В платформенном режиме значение ThreadLocal инициализировалось один раз для каждого потока из пула Tomcat, после чего сохранялось на всё время его жизни. Именно на такое поведение рассчитывал прикладной код. Виртуальные потоки эфемерны: каждый HTTP-запрос выполняется в новом потоке, поэтому «закешированное» значение создаётся заново для каждого запроса. Разница составила 2216 раз. Оба счётчика были сняты с конечных точек /actuator/prometheus Spring Boot Actuator сразу после завершения теста. На рисунке 3 показана частота вызовов во время сценария threadlocal-stress.

Ось Y имеет логарифмический масштаб с основанием 10. Синее плато слева соответствует платформенному режиму и держится примерно на уровне шести операций в секунду. Именно этого ожидает прикладной код, кеширующий SimpleDateFormat в ThreadLocal. Оранжевое плато справа соответствует тому же коду в виртуальном режиме и при такой же нагрузке достигает примерно 9800 операций в секунду. Для одного и того же ключа кеша нагрузка на выделение памяти возрастает на три порядка. «Закешированный» объект незаметно создаётся заново при каждом запросе, поскольку виртуальные потоки не переиспользуются. Ключевая метрика здесь — threadlocal_initializations_total; её интерактивная панель находится в репозитории бенчмарка.
При этом в том же запуске пропускная способность выросла более чем на 107%, а задержка p99 снизилась на 64% при нулевом числе ошибок в обоих режимах. В платформенном режиме нагрузка встаёт в очередь внутри Tomcat из-за ограничения пула в 200 потоков. Виртуальные потоки снимают этот предел, а задержка уменьшается, поскольку из хвостовой задержки исчезает компонент, связанный с очередью.
Контекст InheritableThreadLocal становится null в дочерних задачах
Второй сценарий проявился в хронологии инцидентов. Контекст трассировки, данные аутентифицированного пользователя и идентификаторы корреляции запросов правильно попадали в виртуальный поток, обрабатывающий запрос, но бесследно исчезали после создания дочерних задач через StructuredTaskScope.fork(). Дочерние потоки получали null.
Это не регрессия, а документированное поведение StructuredTaskScope при создании потоков. Однако в локальных тестах с низким параллелизмом передача контекста проверяется редко, поэтому проблема может впервые проявиться в продакшене именно тогда, когда контекст трассировки нужен для расследования инцидента.
JEP 444, представивший виртуальные потоки, прямо предупреждает об этом. Для обратной совместимости виртуальные потоки поддерживают ThreadLocal, но из-за их большого числа использовать этот механизм следует осмотрительно. Не следует использовать ThreadLocal как замену пулу для дорогостоящих объектов. По той же причине команда JDK убрала многие внутренние применения ThreadLocal из модуля java.base.
Теперь ограничением становятся зависимые системы и ресурсы
JEP 491 устранил закрепление виртуальных потоков при работе с мониторами — проблему, которая раньше с наибольшей вероятностью проявлялась первой после перехода на виртуальные потоки. Судя по отчётам об использовании в продакшене, опубликованным до конца 2025 года, и по результатам нашей оценки, после обновления чаще всего исчерпывались ресурсы зависимых систем: достигался предел пула соединений, заканчивались файловые дескрипторы, срабатывали ограничения частоты запросов к внешним API или росли очереди запросов к базе данных.
Пропускная способность увеличивалась, как и ожидалось. Однако следующий эксплуатационный инцидент почти всегда был связан с тем, что один из ресурсов ниже по цепочке достигал своего предела. Раньше конкурентную нагрузку сдерживал размер пула потоков Tomcat. Виртуальные потоки снимают это ограничение, поэтому давление принимает на себя следующее узкое место, где бы оно ни находилось.
JDBC-сценарий сопутствующего бенчмарка воспроизводит эту картину. При тех же 500 одновременных пользователях и 60-секундном запуске оба режима обращаются к одному экземпляру PostgreSQL через HikariCP. В платформенном профиле пул содержит 20 соединений, а в виртуальном — 50.
Метрика | Платформенные потоки, пул 20 | Виртуальные потоки, пул 50 | Изменение |
|---|---|---|---|
Пропускная способность, запросов/с | 624 | 1539 | +147% |
Задержка p99, мс | 1363 | 613 | −55% |
Доля ошибок, % | 36,91 | 37,08 | Практически одинакова |
Рост пропускной способности и снижение задержки реальны. Но ключевое наблюдение — одинаковая доля ошибок около 37%. Устранение ограничения модели потоков не устранило узкое место, а только переместило его. При такой входной нагрузке оба режима исчерпывают пул HikariCP. Пул платформенного режима заполняется раньше: за 20 соединений конкурируют 200 потоков Tomcat. В виртуальном режиме 50 соединений делят между собой неограниченное число виртуальных потоков. В итоге заполняются оба пула. На рисунке 4 показано поведение HikariCP в JDBC-сценарии.

На верхней панели показаны активные соединения. Платформенный режим, обозначенный синим цветом, достигает настроенного предела 20, а виртуальный, обозначенный оранжевым, — предела 50. Во время нагрузки оба удерживаются на максимуме.
На нижней панели видно число ожидающих запросов на получение соединения. В платформенном режиме число ожидающих запросов достигает примерно 180, а в виртуальном — около 450. В обоих случаях пул соединений становится новым ограничением, хотя абсолютный размер очереди различается. Ограничение просто переместилось из пула потоков Tomcat в пул соединений HikariCP, что и подтверждает основной тезис статьи.
Для решения этой проблемы необходимо изменить подход к ограничению ресурсов, который делают возможным виртуальные потоки. В модели на основе пулов один механизм — пул потоков — одновременно изолировал работу и ограничивал использование ресурсов. Виртуальные потоки разделяют эти задачи. Для каждой задачи можно создать отдельный виртуальный поток, не ограничивая их количество размером пула. А доступ к каждому общему ограниченному ресурсу регулируется явно, например с помощью Semaphore.
Именно этот подход рекомендует Николай Парлог из Oracle в материале Managing Throughput with Virtual Threads: ограничивать следует не число потоков, а число разрешений (permits) на одновременную работу с конкретным ресурсом.
private static final Semaphore DB_PERMITS = new Semaphore(50); String queryDatabase(String id) throws InterruptedException { DB_PERMITS.acquire(); try { return jdbcTemplate.queryForObject( "SELECT name FROM items WHERE id = ?", String.class, id); } finally { DB_PERMITS.release(); } }
Новая модель предполагает виртуальные потоки для работы и семафоры у каждого общего ограниченного ресурса. Это более глубокое изменение, чем простая замена пула потоков виртуальными потоками. Теперь прикладной код должен явно ограничивать ресурсы там, где раньше это неявно делал пул потоков.
При этом семафор ограничивает параллелизм, но не заменяет семантику обратного давления. Если производитель должен замедляться, когда потребитель не успевает обрабатывать данные, одного семафора недостаточно: он лишь поставит задачи в очередь или отклонит их.
По состоянию на середину 2026 года в HikariCP нет отдельного исправления для виртуальных потоков. Запрос на изменение HikariCP #2055, предлагавший заменить внутренние блоки synchronized на ReentrantLock, был закрыт. Мейнтейнер проекта предпочёл дождаться JEP 491, а не поддерживать отдельный альтернативный механизм блокировок. После JEP 491 ограничением HikariCP становится число соединений, а не блокировка.
Для версий до JDK 24 сообщалось также о взаимной блокировке при инициализации HikariCP и Logback с виртуальными потоками: issue #2293. Обновление до JDK 24 или новее устраняет этот сценарий благодаря JEP 491.
Scoped Values окончательно утверждены в JDK 25
На рисунке 5 два описанных сценария отказа ThreadLocal в левой колонке сопоставлены с решениями на основе ScopedValue в правой.

В верхней строке значение создаётся один раз для области конкретного запроса и передаётся всем операциям внутри неё. Это устраняет повторную инициализацию в дочерних задачах, но не превращает ScopedValue в межзапросный кеш. В нижней строке контекст, который становился равным null после StructuredTaskScope.fork(), автоматически наследуется дочерними потоками, созданными внутри области привязки ScopedValue.
JEP 506 Scoped Values был окончательно утверждён в JDK 25 после пяти предварительных версий. ScopedValue неизменяем внутри области привязки, структурно связан с создавшим его блоком и без копирования автоматически передаётся дочерним потокам в StructuredTaskScope. Этот механизм решает обе описанные проблемы ThreadLocal: контекст передаётся явно и структурированно, поэтому не создаётся заново в каждом виртуальном потоке и не исчезает в дочерних задачах.
// Было: InheritableThreadLocal для контекста запроса private static final InheritableThreadLocal<RequestContext> CTX = new InheritableThreadLocal<>(); void handleRequest(Request req) { CTX.set(new RequestContext(req.userId(), req.traceId())); try { processRequest(); } finally { CTX.remove(); // легко забыть на одном из путей обработки ошибки } } // Дочерние задачи, созданные через StructuredTaskScope, читают из CTX null. // Стало: ScopedValue в JDK 25, JEP 506 private static final ScopedValue<RequestContext> CTX = ScopedValue.newInstance(); void handleRequest(Request req) { ScopedValue.runWhere(CTX, new RequestContext(req.userId(), req.traceId()), this::processRequest); // При выходе из блока контекст очищается автоматически; finally не нужен. } // Дочерние задачи StructuredTaskScope.fork() автоматически наследуют CTX.
Переход с InheritableThreadLocal на ScopedValue требует целенаправленного рефакторинга из-за различий в API чтения, но в большинстве прикладных сценариев он несложен. Привязка выполняется во входной точке обработчика запроса, а не через запись в поле. Читающая сторона, как и прежде, вызывает CTX.get(). Для фреймворков трассировки, передачи контекста безопасности и собственного контекста запроса приложения Scoped Values — наиболее подходящий встроенный механизм в JDK 25.
При финализации изменилась одна деталь семантики: ScopedValue.orElse(null) больше не допускается. Следует либо передавать в orElse(someNonNullDefault) ненулевое значение по умолчанию, либо сначала вызывать isBound(). Код, написанный для предварительной версии API, может потребовать небольшой правки.
Для кешей, которым действительно необходимо повторно использовать значение в пределах потока, решение другое. Выполняйте такую работу в ограниченном ExecutorService фиксированного размера с платформенными потоками, а виртуальные потоки оставьте для частей запроса, ограниченных вводом-выводом.
Стоит ли переходить с WebFlux
Настройка spring.threads.virtual.enabled=true в Spring Boot 3.2 и новее не меняет модель обработки запросов в приложении Spring WebFlux. WebFlux работает в цикле событий Netty, у которого собственная неблокирующая модель потоков, не зависящая от пула Tomcat. Эта настройка включает виртуальные потоки в Tomcat и Jetty, но не в Netty. Иначе говоря, практическая польза относится к обработке запросов в servlet-контейнерах, а не в WebFlux на Netty.
Параметр spring.threads.virtual.enabled остаётся отключённым по умолчанию и в Spring Boot 3.5, и в 4.0. Включение виртуальных потоков в Spring по-прежнему требует явного решения.
Это стоит проговорить, поскольку некоторые команды устанавливали параметр, видели, что запросы по-прежнему выполняются в потоках цикла событий Netty, и заключали, что виртуальные потоки не работают. Виртуальные потоки действительно не участвовали, а параметр не оказывал эффекта. Но это ожидаемое поведение, а не ошибка.
Поэтому вопрос миграции следует поставить иначе: нужно ли переводить сервис с WebFlux на Spring MVC? Ответ зависит от причины, по которой изначально был выбран реактивный подход.
Наиболее очевидный случай для перехода — сервисы, использующие WebFlux главным образом ради масштабирования блокирующего ввода-вывода. С виртуальными потоками в JDK 24 и новее блокирующие вызовы JDBC, синхронные HTTP-клиенты и стандартные запросы через Jakarta Persistence (JPA) приемлемо масштабируются без реактивной модели. Код упрощается, трассировки стека снова легко читать, а отладка ведёт себя привычно.
На уровне контроллеров Mono<T> заменяется на T, Flux<T> — на List<T>, а цепочки операторов Reactor Mono и Flux с .flatMap() превращаются в последовательные вызовы методов. Параллельные запросы, для которых прежде применялся реактивный zip, можно выразить через параллельно выполняемые блокирующие вызовы в StructuredTaskScope или ExecutorService с ограниченным числом платформенных потоков..
Переход на уровне работы с базой данных требует более тщательной оценки. R2DBC придётся заменить на JDBC или JPA. Если сервис выбрал R2DBC в расчёте на более высокую производительность по сравнению с JDBC при большой нагрузке, важно учитывать, что результаты сравнения R2DBC и JDBC на виртуальных потоках зависят от характера нагрузки, настроек пула соединений и реализации драйвера.
Мне не удалось найти общедоступный бенчмарк, в котором эти подходы сравнивались бы в одинаковых условиях и который позволял бы сделать однозначный вывод для типичных сервисов, работающих по модели «запрос — ответ». Поэтому перед окончательным решением о миграции стоит провести измерения в собственной среде.
Важное уточнение
Речь не о том, что WebFlux устарел, а о критериях выбора. Если WebFlux внедрили исключительно для обработки большого числа одновременных операций блокирующего ввода-вывода, MVC с виртуальными потоками может существенно упростить сервис. Если он нужен для потоковой передачи, обратного давления или долгоживущих потоков событий, его следует сохранить.
Команда Reactor не публиковала официальной рекомендации уходить с WebFlux. Перевод блокирующих сервисов на MVC — формирующаяся практика сообщества, а не официально одобренный путь миграции.
Когда WebFlux следует сохранить
Конечные точки Server-Sent Events, WebSocket и потоковые шлюзы с реальными требованиями к обратному давлению плохо подходят для такой миграции. Механизм обратного давления Reactor нужен потому, что быстрый производитель может перегрузить медленного потребителя, а блокирующий ввод-вывод сам по себе не позволяет выразить это ограничение. Виртуальные потоки ничего здесь не меняют.
Если сервис передаёт данные из Kafka HTTP-клиенту и должен замедлять потребителя Kafka (consumer), когда HTTP-клиент не успевает, WebFlux остаётся правильным инструментом. Аргументы в пользу миграции относятся только к сервисам, которые выбрали реактивную модель как единственный доступный способ обеспечить конкурентный ввод-вывод, но не к системам, которым нужна реактивная семантика.
Состояние структурной многопоточности
Структурную многопоточность (Structured Concurrency) стоит изучать, но пока не следует выносить её в стабильные публичные API. В JDK 25 она поставляется в пятой предварительной версии в рамках JEP 505. В следующих версиях продолжает уточняться Joiner API.
Модель хорошо подходит для параллельного выполнения нескольких частей запроса, отмены и управления временем жизни задач. Однако до финализации API командам следует ожидать изменений исходного кода при обновлении JDK. Самым заметным изменением JDK 25 стала замена публичных конструкторов StructuredTaskScope статическим фабричным методом StructuredTaskScope.open(). Из-за этого код для предварительного API JDK 24 перестаёт компилироваться. Scoped Values находятся в другом положении: в JDK 25 они финализированы и уже пригодны для стабильного использования в продакшене.
Практическая последовательность внедрения
Ниже приведён порядок, который мы использовали для блокирующих сервисов «запрос — ответ» на Spring Boot 3.2 и новее. Он позволяет обнаружить проблемы до выхода в продакшен.
1. Начните с JDK 25 LTS
JEP 491 появился в JDK 24 и включён в JDK 25, а Scoped Values в JDK 25 окончательно утверждены. Для команд с консервативным подходом к продакшену, которые в 2026 году выбирают между JDK 21 LTS и JDK 25 LTS, естественной целью становится JDK 25.
Если по соображениям совместимости необходимо остаться на JDK 21, все описанные риски сохраняются, а закрепление потоков остаётся реальной угрозой. В этом случае особое внимание следует уделить первой половине статьи.
2. Проверьте модель потоков до нагрузочного теста
Быстрая проверка через служебный эндпоинт (endpoint) /health покажет, активны ли виртуальные потоки:
@GetMapping("/health") public Map<String, Object> health() { return Map.of( "thread", Thread.currentThread().getName(), "virtual", Thread.currentThread().isVirtual() ); }
Если возвращается virtual=false, сервис либо работает на WebFlux, либо настройка не применилась. Исправьте это до продолжения тестирования.
3. Проверьте использование ThreadLocal в своём коде и основных зависимостях
Найдите все случаи ThreadLocal.withInitial(), используемые для кеширования, и InheritableThreadLocal, применяемые для передачи контекста. Кеширование нужно либо перенести в ограниченный пул платформенных потоков, либо осознанно принять, что значение будет создаваться для каждой задачи, а не для потока. Передачу контекста в JDK 25 следует перевести на ScopedValue.
4. Добавьте семафор у каждого ограниченного ресурса
Это тот самый концептуальный переворот, который описан выше. Пулы соединений, внешние API с ограничениями частоты, бюджеты файловых дескрипторов и любые ресурсы с максимальным числом одновременных операций требуют явного ограничения при работе с виртуальными потоками. Неявного ограничения со стороны пула потоков больше нет.
5. Запустите JFR до внедрения
Настройте события jdk.VirtualThreadPinned с порогом 20 мс, используемым в JDK 25 по умолчанию, и выполните стандартный нагрузочный тест. Так проявятся закрепления из-за нативных вызовов в зависимостях. В большинстве веб-сервисов на чистой Java такая проверка, вероятно, не выявит закреплений. В сервисах с большим числом JNI-зависимостей JFR поможет определить, откуда возникает оставшаяся нагрузка на потоки-носители.
Наблюдаемость виртуальных потоков в продакшене
Ниже перечислены три вещи, которые нам стоило включить с первого дня. Каждая связана с одним из разобранных сценариев отказа.
Событие JFR
jdk.VirtualThreadPinnedв JDK 25 включено по умолчанию с порогом 20 мс. Используйте его вместе сjdk.VirtualThreadSubmitFailedи настройте оповещения для обоих событий. Так можно обнаружить оставшиеся виды закрепления до того, как они приведут к инциденту.Команда
jcmd <pid> Thread.dump_to_file -format=jsonсоздаёт структурированный дамп с поддержкой виртуальных потоков и группировкой по потокам-носителям. Именно такого средства диагностики не хватало во время остановки сервисов Netflix: в выводе jstack виртуальных потоков не было. Добавьте команду в эксплуатационный регламент. Для локальной отладки IntelliJ IDEA в конце 2025 года получила отображение дампов с учётом виртуальных потоков. При этом снять такой дамп внутри IDE можно только при запуске приложения под отладчиком. Старые версии IDE показывают лишь потоки-носители.Добавьте счётчик
ThreadLocal.initialValue()для важных экземпляров ThreadLocal. Создайте подкласс ThreadLocal и увеличивайте счётчик метрик вinitialValue(). Резкий рост этой величины указывает на описанный сценарий с промахами кеша. В репозитории бенчмарка есть эталонная реализация InstrumentedThreadLocal, а на рисунке 3 показано, как соответствующая метрика выглядит в Grafana.
Чего виртуальные потоки не меняют
Виртуальные потоки повышают пропускную способность при конкурентном вводе-выводе. Они не сокращают задержку вычислительных задач, не добавляют семантику обратного давления и не ускоряют медленные внешние сервисы. Если сервис тратит основную часть времени на вычисления, преобразование данных или инференс, смена модели потоков ему не поможет. Создание тысяч виртуальных потоков для вычислительных задач добавит накладные расходы планировщика без соответствующего выигрыша.
Область применимости решения можно сформулировать так. Если сервис главным образом ожидает ввода-вывода — базы данных, внешние API или чтение файлов, — блокирующий ввод-вывод на виртуальных потоках теперь пригоден для масштабирования. Если сервис занят прежде всего вычислениями или должен управлять обратным давлением в потоке данных, модель потоков не является его узким местом и переход не даст результата.
Заключение
JDK 25 — первый LTS-выпуск, в который вошли исправление закрепления виртуальных потоков из JEP 491 и финализированный API Scoped Values. Поэтому в 2026 году команды, выбирающие между Java 21 LTS и Java 25 LTS, получают существенно разные модели работы с многопоточностью.
В JDK 25 проблему закрепления при работе с мониторами решает сама JVM, а Scoped Values предоставляют стабильный механизм передачи контекста. Остальные задачи перехода относятся уже к коду приложения: необходимо проверить использование ThreadLocal, явно ограничить нагрузку на каждый дефицитный ресурс, включить регистрацию событий закрепления в JFR и оповещения по ним, а также регулярно обновлять JDK.
Главное изменение связано с самой моделью. Раньше пул потоков одновременно разделял выполнение отдельных задач и ограничивал нагрузку на ресурсы. Виртуальные потоки позволяют развести эти функции: каждый запрос получает собственный виртуальный поток, а доступ к общим ограниченным ресурсам регулируется отдельно, например с помощью семафоров.
Такой подход точнее отражает назначение каждого механизма. Прежняя связь между выполнением задач и ограничением ресурсов возникла из-за высокой стоимости потоков операционной системы, а не в результате осознанного архитектурного решения. Теперь ограничения приходится явно задавать в коде приложения там, где раньше их косвенно обеспечивал размер пула потоков. В этом и заключается основная практическая сложность перехода в 2026 году, но объём необходимых изменений уже меньше, чем при внедрении виртуальных потоков в JDK 21.
Работа с многопоточностью в Java стала заметно проще и последовательнее, чем в 2021 году. Виртуальные потоки обеспечивают масштабирование операций ввода-вывода, Scoped Values отвечают за передачу контекста, а Structured Concurrency после финализации позволит координировать параллельно выполняемые задачи и явно управлять временем их жизни. В JDK 24 была устранена проблема закрепления виртуальных потоков за потоками-носителями, которая препятствовала широкому внедрению. В JDK 25 финализировали API для передачи контекста. Поэтому для большинства блокирующих веб-сервисов оставшиеся вопросы связаны с проверкой зависимостей и настройкой размеров пулов, а не с фундаментальными рисками на уровне JVM.
Методика воспроизведения результатов
Все числовые данные взяты из общедоступного бенчмарка, в котором среда остаётся неизменной, а различия между профилями заданы явно. Благодаря этому любой читатель может воспроизвести описанные сценарии отказа на собственном оборудовании. Топология показана на рисунке 1.
Профили намеренно различаются по двум параметрам: модели потоков и, в JDBC-сценариях, максимальному размеру пула соединений. Для конечной точки с ThreadLocal различие пулов не имеет значения, поскольку она не использует JDBC. В JDBC-сценарии оно введено специально и моделирует перемещение ресурсного ограничения после снятия предела пула Tomcat.
Репозиторий: jdk-virtual-threads-benchmark
Хост: AWS EC2 c7i.2xlarge, 8 vCPU Intel Sapphire Rapids, 15 ГиБ оперативной памяти.
ОС: Ubuntu Server 24.04 LTS, ядро 6.17.
JDK: Eclipse Temurin 25.0.3+9 LTS.
Приложение: Spring Boot 3.4, встроенный Tomcat, HikariCP, Spring RestClient, блокирующий JDBC.
Зависимые сервисы: PostgreSQL 16 с max_connections=300 и shared_buffers=128MB; WireMock 3.9.1 с заданной задержкой ответа 300 мс.
Сравниваемые профили: платформенный — пул Tomcat 200, пул HikariCP 20; виртуальный — spring.threads.virtual.enabled=true, пул HikariCP 50.
Параметры JVM одинаковы для обоих профилей:
-Xms512m -Xmx512m -XX:+UseG1GC -Dhttp.maxConnections=5000
JFR работает непрерывно; порог jdk.VirtualThreadPinned равен 0
Нагрузка: JMeter 5.6.3 в режиме network_mode: host; разогрев с нарастанием нагрузки в течение 30 секунд, измерение в течение 60 секунд. Порядок запуска: платформенный профиль, пауза 30 секунд, виртуальный профиль. ConstantThroughputTimer поддерживает одинаковую входную нагрузку для профилей. Указанные значения пропускной способности соответствуют числу запросов, завершённых сервером при этой нагрузке, а не частоте, жёстко заданной клиентом.
Наблюдаемость: Prometheus и Grafana, а также node-exporter для ОС, postgres-exporter для внутренних метрик БД и cAdvisor для контейнеров. Они дают ту же картину метрик, которую показали бы Datadog или Dynatrace. Рисунки 2, 3 и 4 являются непосредственными снимками этих панелей.
Воспроизведение:
make up && make up-app && make benchmark-full; для пятиминутной быстрой проверки —make benchmark-quick. Необработанные JTL-файлы исключены из Git, а сводные таблицы в каталогеresults/summaries/добавлены в репозиторий.
Бенчмарк намеренно ограничен контролируемой средой: на одном хосте запускается по одному экземпляру приложения для каждого профиля, используется синтетическая нагрузка, а различия конфигураций явно заданы. Благодаря этому отдельные сценарии отказа видны сами по себе, а не теряются в шуме продакшена. В реальных системах абсолютные значения изменятся в зависимости от версий зависимостей, задержек внешних сервисов и настроек пулов.
Не изменятся сами закономерности: кеши ThreadLocal, рассчитанные на повторное использование потока; пулы соединений, которые становятся новым пределом после снятия ограничения пула потоков; и исчезновение закреплений на мониторах начиная с JDK 24. Репозиторий позволяет проверить эти эффекты на собственном оборудовании, адаптировать параметры под свой стек и увидеть сценарии отказа в контролируемой среде, не дожидаясь инцидента в продакшене.

