Привет! Меня зовут Денис, я руковожу группой R&D в Naumen Service Management Platform. В этой статье — разбор виртуальных потоков (VT) в Java: почему исторически обычные потоки упирались в I/O, как и зачем появились виртуальные потоки, что пошло не так в Java 21, что исправили в JDK 24–25, а также когда виртуальные потоки необходимы, а когда — лучше от них отказаться.

Денис Абрамов
Руководитель группы R&D
Зачем вообще нужны виртуальные потоки
Исторически Thread в Java — это полноценный поток операционной системы, который мапится 1 к 1. Такой поток:
дорого создавать и держать;
ограничен по количеству;
простаивает на долгих I/O‑операциях, например, при обращениях к базе данных, по сети: поток «ждет байтики», занимая OS‑поток и ничего не делая.

С этим пытались бороться через thread pool, ExecutorService, утилитарные методы. Это помогало, но это компромисс, а не решение. Главная боль — простой на I/O — никуда не исчезает, а при исчерпании пула мы упираемся во внутреннюю очередь, и задержки начинают расти экспоненциально.

Сообщество Java стало искать более легкие и быстрые подходы к параллелизму. Логичный путь — посмотреть на другие языки: какие решения там уже есть и какие из них доказали свою эффективность. В первую очередь это Go с его goroutines и Kotlin с coroutines.
Что подсмотрели у соседей: goroutines и coroutines
Go | Goroutines
Функция, выполняющаяся конкурентно с другими горутинами в том же адресном пространстве.
Как работает: достаточно написать ключевое слово go перед вызовом функции — она запустится как отдельная горутина. Здесь впервые закрепилась модель M:N: много горутин мапятся на мало OS‑потоков. Планирование — в user space, логика снята с ОС и реализована в рантайме языка. Горутин могут быть десятки и сотни тысяч; они «накатываются» на ограниченное число носителей.

В коде это выглядит так: в функции main пишем go hello() — и hello работает как легкий «второстепенный» поток.
package main
import (
"fmt"
"time"
)
func hello() {
fmt.Println("Привет из горутины!")
}
func main() {
go hello()
fmt.Println("Привет из main")
time.Sleep(time.Second)
}
Плюсы
Простое API: go f() — и готово.
Масштабируемость по умолчанию.
Автоматическая работа с блокировками I/O.
Минусы
Нет контроля над временем жизни — нет structured concurrency.
Потенциальные утечки: забыли завершить — висят.
Возможны блокировки, если не соблюдать правила, например, mutex.
Kotlin | Coroutines
Блоки кода, которые позволяют приостанавливать и возобновлять выполнение в различных точках, не блокируя поток выполнения.
Как работает: тут иная точка входа — suspend‑функции. Это точки в программе, где можно приостанавливать выполнение. Работает все за счет «магии» state‑machine компилятора. Никаких встроенных нативных низкоуровневых механизмов — только конечный автомат и компиляторная трансформация. Так получаем отсутствие тяжеловесных OS‑потоков + можно создавать огромное количество «легковесных» задач.

В коде — тоже просто: запускаем, и все работает.
import kotlinx.coroutines.*
fun main() = runBlocking {
launch {
println("Привет из корутины!")
}
println("Привет из main")
delay(1000L)
}
Плюсы
Очень экономичны по ресурсам.
Structured concurrency, runBlocking, coroutineScope.
Совместимы с существующим синхронным кодом.
Минусы
Сложная внутренняя реализация — CPS, state‑machine.
Нужно уметь работать с context«ами, scope, Job, Dispatcher.
Возможны утечки при неправильном использовании.
Project Loom: от идеи к стабильному релизу
Именно эта логика — много легких короткоживущих «потоков» положить на уже существующую в Java модель нативных потоков — и легла в основу Java‑подхода. Так появился Project Loom.
Сначала — JEP 425, который долго был в preview: формулирует, что хотим получить от виртуальных потоков.
Затем — JEP 444: фактически «финальный коммит», вошедший в Java 21, где виртуальные потоки стали stable.

Что такое виртуальные потоки в JVM
Виртуальные потоки — это легковесные потоки, которыми управляет JVM, а не ОС. «Один поток Java = один OS‑поток» больше не аксиома: можно создавать очень много виртуальных потоков, которые мапятся на ограниченное число OS‑потоков.
Создаются быстро.
Удешевляют конкурентность: теперь не нужно думать, как пулить — на каждую независимую операцию можно завести свой виртуальный поток.

M:N-планирование и как работает virtual thread под капотом
M:N‑планирование — это когда M виртуальных потоков ложатся на N реальных OS‑потоков. За то, как прикреплять/откреплять, отвечает планировщик внутри JVM. Вращаются они на всем известном ForkJoinPool с доработками и оптимизациями.
Как это работает под капотом:
Виртуальный поток — это по сути Runnable‑задача, которая ставится в очередь ForkJoinPool.
Во время блокировки, например, sleep(), I/O, виртуальный поток открепляется от carrier‑потока — в это время carrier может взять другой виртуальный поток.
Планировщик периодически чекает и возвращает управление, позволяя выполнять множество операций, не умирая на долгих I/O.

Почему это важно
Java 21 — не просто очередной релиз, а большой шаг для Java Concurrency. Разработчику больше не нужно «писать многопоточность» в классическом смысле: достаточно использовать виртуальные потоки там, где это уместно. На практике, правда, все оказалось сложнее.
Java 21: релиз виртуальных потоков и первые кейсы на практике
Как включить виртуальные потоки и какие есть API
Первые API появились в Java 19 — фича была в preview, включалась флагом. К моменту релиза в Java 21 API почти не изменились.
Рабочие способы запуска
Прямой запуск виртуального потока
Thread.startVirtualThread(() -> System.out.println("Виртуальный поток работает"));
Простой способ, подходит для одноразовых задач.
Thread.startVirtualThread(...) — практическая замена обычного Thread.start(), только гораздо быстрее. Ребята из OpenJDK замеряли — примерно в 5 раз быстрее создания обычного треда, что логично: без нативных вызовов и без тяжелого стек-аллоцирования.
Использование фабрики Thread.ofVirtual()
Thread thread = Thread.ofVirtual().start(() -> System.out.println("Создан через ofVirtual"));
Позволяет настраивать поток: name и другие.
Ближе по семантике к Thread.Builder.
ExecutorService с виртуальными потоками
try (var executor = Executors.newVirtualThreadPerTaskExecutor())
{
executor.submit(() -> System.out.println("Задача через VirtualThreadPerTaskExecutor"));|
}
Подходит для большого количества задач.
Совместим с ExecutorService, Callable, Future.
Автоматически завершает все потоки при закрытии.
Получаете executor и сабмитите в него задачи — без размышлений про размер пула и таймауты. Это самый удобный путь миграции: заменяется только объявление executor'a, остальной код, включая invokeAll, остается прежним.
Параллельный запуск задач
List<Callable<String>> tasks = List.of(
() -> "task 1",
() -> "task 2",
() -> "task 3"
);
try (var executor = Executors.newVirtualThreadPerTaskExecutor())
{
var results = executor.invokeAll(tasks);
for (var result : results)
{
System.out.println(result.get());
}
}
Показывает, как масштабируется invokeAll() с миллионами задач.
Что обещали и что получилось
Ожидания: миллионы легких потоков, «магическое» устранение простоев на I/O, совместимость со старым кодом, простота reasoning, минимум усилий со стороны разработчика.
Реальность в целом подтвердилась — если не задевать проблемные места. Там, где нет synchronized, нет повсюду ThreadLocal и не нужно дебажить потоки, все работает, как задумано. Но в продакшене быстро проявились нюансы.

История № 1 — кейс Netflix
На Medium есть отличная статья о том, как Netflix экспериментировал с Java. Как только Java 21 стала стабильной, они начали переводить сервисы и решили попробовать виртуальные потоки. Система на Spring Boot, внутренняя инфраструктура, к сервису идут внешние запросы.
Включили виртуальные потоки — сначала все нормально, затем сервис перестал отвечать при живой JVM. Увидели, что число сокетов в CLOSE_WAIT улетает в небеса и бьется в потолок — приложение замирает.

Пытались снимать обычные стеки (jstack) — там все idle. Потом сняли полный thread dump и увидели огромное количество тредов вида VirtualThread‑...: виртуальные потоки созданы, но ничего не делают — не прикрепились, не получили задачу, просто висят в своем внутреннем состоянии. Их было примерно столько же, сколько соединений в CLOSE_WAIT.

Ковыряли дальше и увидели в стеке: некий виртуальный поток зашел в секцию BraveSpan, где операция finish() выполнялась внутри synchronized.

Как только виртуальный поток входит в synchronized, он намертво прикрепляется к OS‑потоку и сможет выйти только после выхода из синхронизированного блока. Они увидели пять виртуальных потоков, ждущих освобождения ReentrantLock, и один обычный платформенный поток, тоже ждущий.

Фактическая картина: было четыре carrier‑потока. Четыре VT зашли в synchronized, взяли блокировки и ждут. Следующий VT стоит в очереди, один обычный платформенный поток тоже ждет освобождения Lock, чтобы передать работу дальше, но не может — все carrier заняты «прибитыми» виртуальными потоками. Получается классический дедлок: система «готова», но ресурса нет, все ждут друг друга.

Кратко, что в итоге произошло:
В Zipkin использовался ReentrantLock для синхронизации при логировании трейсинг‑данных, который вызывался из synchronized блока.
После перехода на виртуальные потоки началась деградация производительности.
Причина — VT входит в блокирующую операцию внутри синхронизированного блога или метода, где закрепляется за потоком ОС и не может быть «размонтирован» = дедлок.
Какие были симптомы:
Рост latency во всем сервисе.
Пропускная способность ниже, чем на обычных потоках.
Проблема проявлялась только под нагрузкой.
Какие выводы:
Synchronized небезопасен с виртуальными потоками до Java 24.
Виртуальные потоки могут приводить к дедлоку и занимать все свободные ОС‑потоки.
История № 2 — наш опыт с Tomcat
После перехода на Java 21 и обновления Tomcat до версии с поддержкой VT переключение оказалось действительно простым. Включили — ожидали прирост производительности, но получили обратный эффект: система хаотично «замирала» и переставала отвечать — симптоматика очень похожа на описанную выше. Но о кейсе Netflix мы узнали позже, поэтому шли своим путем.
<Executor
name="virtualThreadExecutor"
namePrefix="vt-"
className="org.apache.catalina.concurrent.VirtualThreadExecutor" />
<Connector
port="8080"
protocol="HTTP/1.1"
executor="virtualThreadExecutor"
connectionTimeout="20000"
redirectPort="8443" />
Мы наблюдали следующее: при каждом «замирании» наш платформенный поток (JavaMelody) висел на попытке взять Lock. Скорее всего, сценарий был аналогичный: как тот самый «поток у Netflix», он стоял ниже в очереди и честно ждал, пока освободится цепочка виртуальных потоков впереди.
В итоге отключили виртуальные потоки в Tomcat, и проблемы исчезли.
Что произошло:
После включения виртуальных потоков — резкий рост latency и залипание запросов.
Висящие потоки с JavaMelody на попытке взять лок в ReentrantReadWriteLock.
В стеках «тишина».
Какие были симптомы:
Tomcat начинал обрабатывать меньше запросов в секунду.
Сложно было отдебажить — метрики не отражали проблему.
После отключения виртуальных потоков в Tomcat все вернулось в норму.
Выводы по Java 21
Работает «из коробки». Виртуальные потоки действительно легкие: можно запускать десятки тысяч.
Совместимость со старым кодом — в целом «да».
Узкое место — synchronized. Множество библиотек, заточенных под старую модель, оказались не готовы.
Дебажить сложно. Проблема проявляется под нагрузкой, а еще когда инструменты для анализа не совсем готовы.
Java 23 и 24: починка самых больных мест
Structured Concurrency — пока в preview, но «по делу»
В линейке 23–24 появилась Structured Concurrency. Это инструмент, который позволяет управлять группой виртуальных потоков как единым блоком: создаем, ждем завершения, обрабатываем ошибки. А еще нет утечек и «зомби‑потоков»: все потоки завершаются структурировано и предсказуемо. Structured Concurrency стабильно работает с VT и рекомендуется для любого продвинутого многопоточного кода.
Важно: пока еще в preview, даже в Java 25. В Java 24 четвертая версия (JEP 499) улучшены отмена, обработка ошибок и совместимость с дебаггерами.
Scoped Values вместо ThreadLocal — безопасная передача контекста
Дальше — Scoped Values. По плану они становятся stable в Java 25. рекомендую избавляться от ThreadLocal и идти к Scoped Values.
Почему так:
ThreadLocal плохо подходит для Loom, так как увеличивает накладные расходы при тысячах потоков и чреват утечками памяти, если не очищать вручную.
Scoped Values дают безопасную альтернативу — не сохраняются между задачами, работают в рамках scope. Ничего не нужно очищать вручную, задачи передаются явно. Подходят для structured concurrency, работает с StructuredTaskScope.
Для удобства собрал маленькую табличку, где отмечено, где и как стоит использовать Scoped Values вместо ThreadLocal.

JEP 491 (Java 24): снят пиннинг внутри synchronized

Самое важное изменение для VT — JEP 491 в Java 24. Он убирает ключевую причину замираний на Java 21: виртуальный поток больше не остается «пристегнутым» к OS‑потоку внутри synchronized.
В Java 25 это поведение становится stable. Практический эффект: можно безбоязненно пользоваться synchronized.
Улучшения стеков, дебага и JVM introspection
JEP 425: в Java 21–22 инструменты (jstack, профайлеры) почти не видели виртуальные потоки.
JEP 491 (Java 24): стек‑трейсы VT теперь полноценно отображаются в jstack, jcmd и IDE.
Поддержка JFR‑событий jdk.VirtualThreadPinned в VisualVM, IntelliJ и JFR.
Thread.getStackTrace() возвращает корректный стек даже для «размонтированного» VT.
Что (вроде бы) остается опасным даже в 24–25
На границе 24–25 Java виртуальные потоки в почти идеальном состоянии, но важно помнить про «наследие» до Java 21:
Библиотеки, полагающиеся на ThreadLocal, например, старые реализации MDC в логировании, становятся узким местом при миллионах виртуальных потоков.
Вызовы через JNI по‑прежнему блокируют carrier thread.
Долгоживущие VT, например, бесконечные воркеры, = неоптимальное использование.
Когда виртуальные потоки — must have, а когда лучше не надо
Хорошие кейсы
Web-сервисы с пулами потоков и независимыми запросами, например, Tomcat, Jetty. После Java 25, где JEP 491 стабилен, можно смело включать — обработка тысяч параллельных запросов без издержек на thread pool.
HTTP-клиенты — практически «бесплатный» прирост производительности, можно запускать тысячи исходящих запросов без перегрузки потоков.
Параллельная обработка задач внутри бизнес-логики — каждый шаг в своем виртуальном потоке, без shared state.
Data-pipelines и ETL другая сложная ветвистая многопоточность — также можно смело переводить = упрощение структур многопоточности и дебага.
Плохие кейсы
Долгоживущие потоки/воркеры. Не используйте VT так же, как использовали старые: их не нужно пулить и держать в бесконечном цикле весь runtime.
Неоптимизированные библиотеки с ThreadLocal и большим числом блокирующих нативных вызовов — прироста не будет, скорее наоборот.
Вызовы через JNI могут навсегда занять carrier thread = потеря масштабируемости.
Простые советы, если вы уже перешли на свежую Java или только готовитесь
Мониторьте pinned-потоки. Несмотря на прогресс, проблемы с виртуальными потоками возможны, например, из-за JNI. Следите, чтобы не поймать кейсы «зависаний», как в нашем примере и у Netflix.
После того как Scoped Values стали stable, переходите на них. Избегайте бесконечных циклов и обновляйте библиотеки — это закрывает уязвимости, улучшает перформанс и адаптирует код под современные возможности языка.
Что можно писать сейчас и с какой версии Java
Делюсь своей табличкой‑шпаргалкой: тип задачи → применять ли виртуальные потоки → с какой версии Java → на что обратить внимание. Можете зафиксировать ее как напоминалку и использовать при планировании миграций.
