До Java 21 каждый поток в JVM напрямую соответствовал потоку операционной системы. Это ставило жёсткий потолок на число одновременных задач. Виртуальные потоки снимают это ограничение — и позволяют писать простой блокирующий код там, где раньше требовался реактивный стиль.
Носитель — настоящий поток операционной системы. Сверху: VT1 доходит до блокирующего чтения, его стек уезжает в кучу, освободившийся носитель тут же занимает VT2, а когда ответ пришёл — VT1 монтируется обратно, не обязательно на тот же носитель. Снизу тот же вызов под synchronized на Java 21–23: отцепиться нельзя, стек остаётся на носителе, и всё время ожидания носитель занят одной задачей, а VT4 стоит в очереди. На Java 24+ нижняя строка ведёт себя как верхняя.
Проблема: платформенные потоки дорогие
Платформенный поток (Thread в классическом смысле) — это обёртка над потоком ОС. Каждый такой поток потребляет:
- около 1–2 МБ стека по умолчанию,
- системный дескриптор от ОС,
- время на переключение контекста.
Типичный сервер может держать несколько тысяч платформенных потоков — и это уже предел. Если каждый запрос ждёт ответа от базы данных, все потоки блокируются впустую: процессор не занят, но новые задачи принять некому.
Исторически это решали двумя способами:
- Пулы потоков (
ExecutorService,ForkJoinPool) — потоки переиспользуются, но лимит остаётся. - Реактивный стиль (
CompletableFuture, Project Reactor, RxJava) — задача разбивается на колбэки, поток не блокируется. Работает, но код становится сложно читать и отлаживать.
Виртуальные потоки: поток на задачу снова дёшев
Виртуальный поток — это поток, управляемый JVM, а не ОС. Его можно создавать тысячами и десятками тысяч: он не резервирует мегабайты стека заранее и не привязан к дескриптору ОС постоянно.
Короткая формула: виртуальный поток — лёгкая задача, которую JVM планирует поверх небольшого пула несущих потоков (carrier threads), привязанных к ядрам процессора.
Когда виртуальный поток уходит в блокирующий вызов (чтение из сети, запрос к БД, Thread.sleep), JVM снимает его с несущего потока и освобождает носитель для другого виртуального потока. Когда блокировка снимается — виртуальный поток ставится обратно в очередь.
Итог: тысячи одновременных блокирующих операций при горстке реальных потоков ОС.
Как это устроено внутри: continuation и планировщик
Когда виртуальный поток доходит до блокирующего вызова, инструментированного JDK (сетевой ввод-вывод, Thread.sleep, BlockingQueue.take и т.п.), происходит unmount: JVM сворачивает стек виртуального потока и переносит его в кучу в виде небольших объектов-фрагментов (stack chunks), а несущий поток освобождается. Когда операция готова продолжиться, планировщик делает mount — копирует стек обратно на какой-нибудь свободный несущий поток (не обязательно тот же, что был раньше) и продолжает выполнение.
Чтобы это было возможно, виртуальный поток устроен как пара из двух частей: continuation, захваченное состояние выполнения (стек вызовов), которое можно приостановить и возобновить, и планировщик (scheduler), который решает, на каком несущем потоке его возобновить.
Именно поэтому стек виртуального потока стоит дёшево: он не резервирует 1–2 МБ в памяти ОС заранее, а живёт в куче и растёт порциями по мере глубины вызовов. Начальная стоимость — сотни байт, а не мегабайты.
Планировщик по умолчанию — это специальный ForkJoinPool в режиме FIFO. Число несущих потоков (степень параллелизма) по умолчанию равно числу доступных ядер. Его можно настроить системными свойствами на старте JVM:
-Djdk.virtualThreadScheduler.parallelism=8 # сколько носителей работают параллельно
-Djdk.virtualThreadScheduler.maxPoolSize=256 # потолок носителей (для компенсации при pinning/блокировках)
Эти два числа — про разное, хотя оба про носителей. parallelism — сколько носителей работают одновременно в нормальном режиме; по умолчанию это число ядер. maxPoolSize — сколько носителей планировщику разрешено завести вообще; по умолчанию 256. Второе больше первого не по недосмотру: когда носитель застревает — виртуальный поток прибит к нему или ушёл в вызов, который JDK не умеет прерывать, — планировщик заводит временный носитель взамен. Это называют компенсацией, и в здоровой системе она почти не случается: разрыв между числами просто стоит незадействованным.
Важное следствие: планирование кооперативное. Виртуальный поток уступает носитель только в точках блокировки, которые знает JDK. Об этом — ниже, в разделе про CPU-интенсивный код.
Как создать виртуальный поток
Самый простой способ — фабричный метод Thread.ofVirtual(). Вот полная программа, которая запускает один виртуальный поток и печатает, кто он такой:
живой пример
public class VirtualThreadDemo {
public static void main(String[] args) throws InterruptedException {
Thread vt = Thread.ofVirtual().name("worker-1").start(() -> {
System.out.println("до ожидания: " + Thread.currentThread());
try {
Thread.sleep(100); // блокирующий вызов — здесь это нормально
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
System.out.println("после ожидания: " + Thread.currentThread());
});
vt.join();
System.out.println("vt.isVirtual() = " + vt.isVirtual());
}
}
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
В выводе после имени виртуального потока стоит имя носителя — @ForkJoinPool-1-worker-1. Это и есть тот самый настоящий поток ОС, поверх которого выполняется виртуальный.
Для пула задач — Executors.newVirtualThreadPerTaskExecutor(). Он создаёт новый виртуальный поток под каждую задачу:
живой пример
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicInteger;
public class ManyTasks {
public static void main(String[] args) {
AtomicInteger done = new AtomicInteger();
long t0 = System.currentTimeMillis();
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 10_000; i++) {
executor.submit(() -> {
Thread.sleep(100); // каждая задача ждёт 100 мс
done.incrementAndGet();
return null;
});
}
} // закрытие ждёт завершения всех задач
System.out.println("задач выполнено: " + done.get());
System.out.println("ядер у машины: " + Runtime.getRuntime().availableProcessors());
System.out.println("заняло, мс: " + (System.currentTimeMillis() - t0));
}
}
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
Десять тысяч задач по 100 мс ожидания укладываются в пару сотен миллисекунд на горстке ядер — почти столько же, сколько заняла бы одна: пока задачи спят, носители свободны.
С платформенными потоками исход зависел бы от машины. Где-то десять тысяч потоков поднимутся — стек ведь резервируется, а не занимается целиком, — но на создание уйдут секунды, а планировщик ОС будет жить одними переключениями. Где-то программа просто упадёт с OutOfMemoryError: unable to create native thread: это не про нехватку кучи, это операционная система отказалась заводить ещё один поток. Гарантирован тут не отказ, а то, что дёшево не будет.
Pinning: когда виртуальный поток прибивается к носителю
Есть подводный камень — pinning (прибивание). Это ситуация, когда виртуальный поток не может отцепиться (unmount) от несущего потока во время блокировки: носитель занят на всё время ожидания, как если бы это был обычный платформенный поток. Если такого кода много, число реально работающих носителей упирается в потолок и выгода от виртуальных потоков теряется.
Версии имеют значение — это место заметно менялось:
- Java 21–23. Pinning возникает в трёх случаях: блокирующий вызов внутри блока
synchronized, вызовObject.wait()и работа внутри нативного метода (native). Первые два — про мониторы: владение монитором JVM отслеживала на уровне несущего потока ОС, поэтому отцепить от него виртуальный поток было нельзя. Рекомендованный обход — заменить в секциях с вводом-выводомsynchronizedнаReentrantLock, аwait()/notify()наCondition.
// так не надо (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. То же самое починилось и дляObject.wait(). Переписывать код наReentrantLockради этого больше не нужно. Остаётся pinning только на нативных кадрах (native-методы) и при выполнении на нём class initializer'а.
Короткая формула: на Java 21 избегайте блокирующих вызовов под synchronized; на Java 24+ эта проблема в основном снята самой JVM.
Диагностика pinning
Способ диагностики тоже зависит от версии:
- Java 21–23: системное свойство
-Djdk.tracePinnedThreads=full(илиshort) выводит стектрейс каждый раз, когда виртуальный поток прибивается к носителю. Учтите: вместе с JEP 491 в Java 24 этот флаг удалён. - Современный способ — JFR. JVM пишет событие
jdk.VirtualThreadPinned, когда виртуальный поток остаётся прибит дольше порога. Включается через Flight Recorder и видно в записи.jfr— это рекомендованный путь, не зависящий от удалённого флага.
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, а не размер пула потоков. Эта программа запускает тысячу задач и считает, сколько их дошло до «базы» одновременно:
живой пример
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Semaphore;
import java.util.concurrent.atomic.AtomicInteger;
public class SemaphoreLimit {
public static void main(String[] args) {
Semaphore dbPermits = new Semaphore(20); // не больше 20 запросов к базе разом
AtomicInteger inFlight = new AtomicInteger();
AtomicInteger peak = new AtomicInteger();
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 1_000; i++) {
executor.submit(() -> {
dbPermits.acquire();
try {
peak.accumulateAndGet(inFlight.incrementAndGet(), Math::max);
Thread.sleep(5); // «запрос к базе»
return null;
} finally {
inFlight.decrementAndGet();
dbPermits.release();
}
});
}
}
System.out.println("задач запущено: 1000");
System.out.println("одновременно в базе, пик: " + peak.get());
}
}
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
Пик всегда 20. Задач может быть хоть сто тысяч — остальные виртуальные потоки дёшево ждут на семафоре, не занимая носителей.
Тысяча виртуальных потоков упирается не в размер пула, а в семафор: разрешений двадцать, столько запросов и доходит до базы, остальные дёшево ждут очереди.
Семафор здесь не оптимизация, а замена исчезнувшего ограничителя, и это главное, что меняется при переходе на виртуальные потоки. Раньше пул из тридцати потоков был ограничителем нагрузки для всего, что за ним: больше тридцати запросов к базе одновременно не уходило физически, а лишние ждали в очереди пула. Виртуальные потоки этот ограничитель убирают: десять тысяч запросов дойдут до базы все десять тысяч, и упрётесь вы уже не в потоки, а в то, что за ними — пул соединений (HikariCP по умолчанию десять), лимит запросов внешнего API, память под тела запросов и ответов.
Отсюда вторая, менее очевидная перемена: исчезает честный отказ. У ограниченного пула переполненная очередь давала RejectedExecutionException — сервис быстро говорил «перегружен», и вызывающий мог отступить. Без очереди отказывать нечему: работа просто накапливается, задержки растут, а под нагрузкой сервис не отклоняет запросы, а тихо деградирует, пока не кончится память. Поэтому при переходе на виртуальные потоки ограничители не убирают, а переносят: семафор на каждый внешний ресурс, таймауты на вызовы и явный предел одновременных запросов на входе (server.tomcat.max-connections, ограничитель в шлюзе). Правило: раньше ограничивал пул, теперь ограничивать надо осознанно и там, где настоящий дефицитный ресурс.
ThreadLocal и ScopedValue
Виртуальные потоки поддерживают ThreadLocal, и всё, что на нём построено, продолжает работать: MDC с traceId, контекст Spring Security, сессия Hibernate, транзакция. Ничего переписывать не нужно — но на масштабе это ловушка. С платформенными потоками их было сотни, и значения в 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();
});
Запись выше — из финальной версии, Java 25. Пока возможность была предварительной, форма вызова менялась: в промежуточных выпусках то же самое писали как ScopedValue.runWhere(USER_ID, "user-42", () -> handleRequest()). Так что сигнатуру, как и у StructuredTaskScope, стоит сверить со своим JDK.
Структурированная конкурентность
Когда одна задача порождает несколько подзадач (запросить два сервиса параллельно и собрать результат), удобно управлять их жизненным циклом как единым целым: если одна упала — отменить остальные; дождаться всех в одном месте. Для этого есть StructuredTaskScope.
try (var scope = StructuredTaskScope.open()) { // Java 25; раньше — ShutdownOnFailure
var user = scope.fork(() -> fetchUser(id)); // каждый fork — отдельный виртуальный поток
var order = scope.fork(() -> fetchOrder(id));
scope.join(); // упала любая — остальные отменены, ошибка проброшена
return combine(user.get(), order.get());
}
Это устраняет «утечки» подзадач (зависшую подзадачу некому отменить) и делает обработку ошибок предсказуемой. StructuredTaskScope всё ещё предварительная возможность (preview), и API между версиями менялся — сверяйте сигнатуры со своим JDK. Полный разбор — области, Joiner-политики, дедлайны и современный open()-API — в отдельной статье.
Наблюдаемость
Виртуальных потоков слишком много для обычного 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, и большинство стандартных библиотек и фреймворков работает с ними без изменений. Несколько свойств всё же отличаются, и они кусают при переезде со старого кода на new Thread(...).
Виртуальный поток всегда демон: setDaemon(false) бросит исключение. Значит, он не удерживает JVM от выхода, и программа, которая раньше жила, пока работал фоновый поток, теперь завершится сразу — работу надо дожидаться явно (join, закрытие исполнителя). Приоритет у него всегда обычный, setPriority ничего не меняет. Группы потоков для них фиктивны, а Thread.currentThread().getName() по умолчанию пустая строка: имя задают через Thread.ofVirtual().name("worker-", 0), иначе в логах и дампах не отличить один от другого.
Цифры для ориентира: создание виртуального потока это порядка микросекунды против десятков микросекунд у платформенного, а начальная память — сотни байт стека в куче против мегабайта зарезервированного адресного пространства. Практически это значит, что миллион одновременно живущих виртуальных потоков — реальная цифра на обычном сервере, если каждый из них просто ждёт, и совершенно нереальная, если каждый держит в стеке мегабайт распарсенного JSON. А вот про драйверы баз данных и пулы соединений стоит насторожиться отдельно: внутри у них хватает synchronized вокруг сетевых вызовов, и на Java 21–23 это был главный источник pinning. Выглядит такой случай обманчиво: код работает, тесты зелёные, а под нагрузкой носители заняты ожиданием и выигрыша нет. Проверяют это замером — событием jdk.VirtualThreadPinned в JFR, — а не на глаз. На Java 24+ вопрос в основном снят самой JVM.
Spring Boot 3.2+ включает поддержку виртуальных потоков одной строкой в application.yml — spring.threads.virtual.enabled: true. Если нужен только веб-слой, тот же эффект даёт бин:
@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). - Лимит переехал с потоков на ресурс: соединения к базе, лимиты внешних API, память; ограничитель ставят семафором и таймаутами, иначе перегрузка превращается не в честный отказ, а в тихий рост задержек.
- Виртуальный поток всегда демон, приоритет и группа фиктивны, имя по умолчанию пустое: задают через
Thread.ofVirtual().name(...).
Что почитать дальше
- Пулы потоков и ExecutorService — как управлять параллельным выполнением задач через пулы.
- Структурированная конкурентность — области, Joiner-политики и отмена подзадач на API Java 25.
- CompletableFuture — асинхронные цепочки для задач, где результат нужен позже.
- Типичные ошибки многопоточного кода — гонки, дедлоки и другие проблемы, которые встречаются на практике.
- Корутины Kotlin — тот же выигрыш на той же JVM другим способом: приостановка вместо блокировки и чем это отличается от виртуальных потоков.