До Java 21 каждый поток в JVM напрямую соответствовал потоку операционной системы. Это ставило жёсткий потолок на число одновременных задач. Виртуальные потоки снимают это ограничение — и позволяют писать простой блокирующий код там, где раньше требовался реактивный стиль.
Проблема: платформенные потоки дорогие
Платформенный поток (Thread в классическом смысле) — это обёртка над потоком ОС. Каждый такой поток потребляет:
- около 1–2 МБ стека по умолчанию,
- системный дескриптор от ОС,
- время на переключение контекста.
Типичный сервер может держать несколько тысяч платформенных потоков — и это уже предел. Если каждый запрос ждёт ответа от базы данных, все потоки блокируются впустую: процессор не занят, но новые задачи принять некому.
Исторически это решали двумя способами:
- Пулы потоков (
ExecutorService,ForkJoinPool) — потоки переиспользуются, но лимит остаётся. - Реактивный стиль (
CompletableFuture, Project Reactor, RxJava) — задача разбивается на колбэки, поток не блокируется. Работает, но код становится сложно читать и отлаживать.
Виртуальные потоки: поток на задачу снова дёшев
Виртуальный поток — это поток, управляемый JVM, а не ОС. Его можно создавать тысячами и десятками тысяч: он не резервирует мегабайты стека заранее и не привязан к дескриптору ОС постоянно.
Короткая формула: виртуальный поток — лёгкая задача, которую JVM планирует поверх небольшого пула несущих потоков (carrier threads), привязанных к ядрам процессора.
Когда виртуальный поток уходит в блокирующий вызов (чтение из сети, запрос к БД, Thread.sleep), JVM снимает его с несущего потока и освобождает носитель для другого виртуального потока. Когда блокировка снимается — виртуальный поток ставится обратно в очередь.
Итог: тысячи одновременных блокирующих операций при горстке реальных потоков ОС.
Как это устроено внутри: continuation и планировщик
Под капотом виртуальный поток — это пара из двух частей:
- continuation — захваченное состояние выполнения (стек вызовов), которое можно приостановить и возобновить;
- планировщик (scheduler), который решает, на каком несущем потоке возобновить continuation.
Когда виртуальный поток доходит до блокирующего вызова, инструментированного JDK (сетевой ввод-вывод, Thread.sleep, BlockingQueue.take и т.п.), происходит unmount: JVM сворачивает стек виртуального потока и переносит его в кучу в виде небольших объектов-фрагментов (stack chunks), а несущий поток освобождается. Когда операция готова продолжиться, планировщик делает mount — копирует стек обратно на какой-нибудь свободный несущий поток (не обязательно тот же, что был раньше) и продолжает выполнение.
Именно поэтому стек виртуального потока стоит дёшево: он не резервирует 1–2 МБ в памяти ОС заранее, а живёт в куче и растёт порциями по мере глубины вызовов. Начальная стоимость — сотни байт, а не мегабайты.
Планировщик по умолчанию — это специальный ForkJoinPool в режиме FIFO. Число несущих потоков (степень параллелизма) по умолчанию равно числу доступных ядер. Его можно настроить системными свойствами на старте JVM:
-Djdk.virtualThreadScheduler.parallelism=8 # сколько носителей работают параллельно
-Djdk.virtualThreadScheduler.maxPoolSize=256 # потолок носителей (для компенсации при pinning/блокировках)
Важное следствие: планирование кооперативное. Виртуальный поток уступает носитель только в точках блокировки, которые знает JDK. Об этом — ниже, в разделе про CPU-интенсивный код.
Как создать виртуальный поток
Самый простой способ — фабричный метод Thread.ofVirtual():
Thread vt = Thread.ofVirtual()
.name("worker-1")
.start(() -> {
// обычный блокирующий код — здесь это нормально
String result = fetchFromDatabase();
System.out.println(result);
});
vt.join();
Для пула задач — Executors.newVirtualThreadPerTaskExecutor(). Он создаёт новый виртуальный поток под каждую задачу:
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 10_000; i++) {
executor.submit(() -> handleRequest());
}
} // здесь executor закрывается и ждёт завершения всех задач
Запустить 10 000 задач таким способом — нормально. С платформенными потоками это был бы гарантированный OutOfMemoryError.
Pinning: когда виртуальный поток прибивается к носителю
Есть подводный камень — pinning (прибивание). Это ситуация, когда виртуальный поток не может отцепиться (unmount) от несущего потока во время блокировки: носитель занят на всё время ожидания, как если бы это был обычный платформенный поток. Если такого кода много, число реально работающих носителей упирается в потолок и выгода от виртуальных потоков теряется.
Версии имеют значение — это место заметно менялось:
- Java 21–23. Pinning возникает, когда блокирующий вызов происходит внутри блока
synchronizedили внутри нативного метода (native). Причина дляsynchronized: владение монитором JVM отслеживала на уровне несущего потока ОС, поэтому отцепить виртуальный поток было нельзя. Рекомендованный обход — заменитьsynchronizedнаReentrantLockв секциях, содержащих ввод-вывод.
// Java 21: synchronized + блокирующий вызов = pinning
synchronized (lock) {
String data = socket.read(); // носитель не освобождается
}
// Java 21: ReentrantLock не вызывает pinning
ReentrantLock lock = new ReentrantLock();
lock.lock();
try {
String data = socket.read(); // виртуальный поток корректно отцепляется
} finally {
lock.unlock();
}
- Java 24+ (JEP 491). JVM научилась отслеживать владение монитором на уровне самого виртуального потока, и
synchronizedбольше не вызывает pinning — виртуальный поток спокойно отцепляется даже из-под блокирующего вызова внутриsynchronized. Переписывать код наReentrantLockради этого больше не нужно. Остаётся pinning только на нативных кадрах (native-методы) и при выполнении на нём class initializer'а.
Короткая формула: на Java 21 избегай блокирующих вызовов под synchronized; на Java 24+ эта проблема в основном снята самой JVM.
Диагностика pinning
Способ диагностики тоже зависит от версии:
- Java 21–23: системное свойство
-Djdk.tracePinnedThreads=full(илиshort) выводит стектрейс каждый раз, когда виртуальный поток прибивается к носителю. Учти: в новых версиях этот флаг признан устаревшим и удаляется. - Современный способ — JFR. JVM пишет событие
jdk.VirtualThreadPinned, когда виртуальный поток остаётся прибит дольше порога. Включается через Flight Recorder и видно в записи.jfr— это рекомендованный путь, не зависящий от устаревшего флага. Рядом полезны событияjdk.VirtualThreadStart,jdk.VirtualThreadEndиjdk.VirtualThreadSubmitFailed(последнее сигналит, что планировщик не смог принять задачу).
CPU-интенсивный код и кооперативное планирование
Планирование виртуальных потоков кооперативное, без вытеснения по таймеру. Виртуальный поток отдаёт носитель только в точках блокировки, которые знает JDK. Если задача крутит долгий цикл вычислений без единого блокирующего вызова, она не уступит носитель сама — и будет держать его, пока не закончит.
// Антипример: чистый CPU-цикл в виртуальном потоке
Thread.ofVirtual().start(() -> {
long acc = 0;
for (long i = 0; i < 100_000_000_000L; i++) acc += i; // ни одной точки unmount
});
Несколько таких задач займут все носители, и остальные виртуальные потоки будут ждать — даже те, что готовы выполнять полезную работу. Виртуальные потоки рассчитаны на код, который много ждёт, а не много считает. Для тяжёлых вычислений используйте платформенные потоки или ForkJoinPool. Если очень нужно «дать дорогу» из длинного цикла — поможет Thread.yield(), но это лечение симптома, а не показание к виртуальным потокам.
Не создавайте пул виртуальных потоков
Виртуальные потоки не нужно пулить и переиспользовать — это меняет саму модель. Их дёшево создавать по одному на задачу, поэтому Executors.newVirtualThreadPerTaskExecutor() именно так и делает: новый поток на каждую задачу. Фиксированный пул из N виртуальных потоков — антипаттерн: он искусственно возвращает то самое ограничение, ради снятия которого виртуальные потоки и придуманы.
Когда ограничить параллелизм всё-таки нужно — например, чтобы не перегрузить пул соединений к базе или внешний сервис, — ограничивайте доступ к конкретному ресурсу через Semaphore, а не размер пула потоков:
Semaphore dbPermits = new Semaphore(20); // не больше 20 одновременных запросов к БД
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (var task : tasks) {
executor.submit(() -> {
dbPermits.acquire();
try {
return queryDatabase(task); // под защитой семафора
} finally {
dbPermits.release();
}
});
}
}
Так задач может быть хоть сто тысяч, а к базе одновременно идёт максимум 20 — остальные виртуальные потоки дёшево ждут на семафоре, не занимая носителей.
ThreadLocal и ScopedValue
Виртуальные потоки поддерживают ThreadLocal — но на масштабе это ловушка. С платформенными потоками их было сотни, и значения в ThreadLocal стоили немного. Виртуальных потоков — сотни тысяч; если каждый держит увесистое значение в ThreadLocal, память распухает. Плюс типичный приём «кэшировать дорогой объект в ThreadLocal на пул потоков» с моделью «поток на задачу» теряет смысл — переиспользования нет.
Замена для передачи контекста (идентификатор пользователя, trace id) — ScopedValue: неизменяемое значение, привязанное к области выполнения и автоматически видимое дочерним задачам, без риска утечки. ScopedValue финализирован в Java 25 (в Java 21–24 — предварительная возможность, preview).
private static final ScopedValue<String> USER_ID = ScopedValue.newInstance();
ScopedValue.where(USER_ID, "user-42").run(() -> {
// внутри этой области USER_ID.get() == "user-42",
// значение видно и дочерним задачам; за пределами области — недоступно
handleRequest();
});
Structured concurrency
Когда одна задача порождает несколько подзадач (запросить два сервиса параллельно и собрать результат), удобно управлять их жизненным циклом как единым целым: если одна упала — отменить остальные; дождаться всех в одном месте. Для этого есть StructuredTaskScope.
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
var user = scope.fork(() -> fetchUser(id)); // каждый fork — отдельный виртуальный поток
var order = scope.fork(() -> fetchOrder(id));
scope.join(); // дождаться обеих
scope.throwIfFailed(); // если любая упала — отменить вторую и пробросить ошибку
return combine(user.get(), order.get());
}
Это устраняет «утечки» подзадач (зависшую подзадачу некому отменить) и делает обработку ошибок предсказуемой. На момент написания StructuredTaskScope всё ещё предварительная возможность (preview) — API между версиями менялся, уточняйте сигнатуры под свою версию JDK.
Наблюдаемость
Виртуальных потоков слишком много для обычного jstack — по умолчанию он их не печатает. Полный дамп с виртуальными потоками снимается через jcmd:
jcmd <pid> Thread.dump_to_file -format=json threads.json
Для непрерывного наблюдения используйте JFR-события jdk.VirtualThreadStart / jdk.VirtualThreadEnd (жизненный цикл), jdk.VirtualThreadPinned (прибивание дольше порога) и jdk.VirtualThreadSubmitFailed (планировщик не принял задачу).
Когда виртуальные потоки, когда платформенные
Виртуальные потоки хорошо подходят для:
- HTTP-серверов с тысячами одновременных запросов,
- сервисов, активно работающих с базой данных или внешними API,
- любого IO-интенсивного кода, где поток большую часть времени ждёт.
Платформенные потоки остаются предпочтительными для:
- долгих CPU-интенсивных вычислений (например, обработка изображений, шифрование),
- кода с жёсткими требованиями к планировщику ОС.
Виртуальные потоки не ускоряют вычислительные задачи — они снимают ограничение на число одновременных IO-ожиданий.
Совместимость с существующим кодом
Виртуальные потоки реализуют тот же интерфейс Thread. Большинство стандартных библиотек, JDBC-драйверов и фреймворков работает с ними без изменений.
Spring Boot 3.2+ включает поддержку виртуальных потоков: достаточно добавить в конфигурацию:
@Bean
public TomcatProtocolHandlerCustomizer<?> virtualThreadsCustomizer() {
return handler -> handler.setExecutor(
Executors.newVirtualThreadPerTaskExecutor()
);
}
После этого каждый входящий HTTP-запрос обрабатывается в отдельном виртуальном потоке — никакого реактивного стиля, никаких колбэков.
Коротко
- Платформенный поток = поток ОС: дорогой, лимит — тысячи.
- Виртуальный поток управляется JVM и дёшев: можно создавать сотни тысяч.
- Создаются через
Thread.ofVirtual()илиExecutors.newVirtualThreadPerTaskExecutor(). - Внутри — continuation (стек живёт в куче, растёт порциями) + планировщик на базе
ForkJoinPool(носителей по числу ядер, настраивается системными свойствами). - При блокировке происходит unmount: стек уезжает в кучу, носитель свободен; при разблокировке — mount на любой свободный носитель.
- Pinning: на Java 21–23 его вызывает
synchronized+ блокирующий вызов (обход —ReentrantLock); на Java 24+ (JEP 491)synchronizedбольше не пиннит, остаются только нативные кадры. Диагностика — JFR-событиеjdk.VirtualThreadPinned. - Планирование кооперативное: чистый CPU-цикл не уступает носитель. Выгода — для IO-интенсивного кода; CPU-задачи выигрыша не получат.
- Не пульте виртуальные потоки (один на задачу); ограничивайте доступ к ресурсу через
Semaphore, а не размер пула. - Контекст лучше передавать через
ScopedValue(финал в Java 25), а неThreadLocal; для управляемого fan-out —StructuredTaskScope(пока preview).
Что почитать дальше
- Пулы потоков и ExecutorService — как управлять параллельным выполнением задач через пулы.
- CompletableFuture — асинхронные цепочки для задач, где результат нужен позже.
- Типичные ошибки многопоточного кода — гонки, дедлоки и другие проблемы, которые встречаются на практике.