← назад к разделу

До Java 21 каждый поток в JVM напрямую соответствовал потоку операционной системы. Это ставило жёсткий потолок на число одновременных задач. Виртуальные потоки снимают это ограничение — и позволяют писать простой блокирующий код там, где раньше требовался реактивный стиль.

Проблема: платформенные потоки дорогие

Платформенный поток (Thread в классическом смысле) — это обёртка над потоком ОС. Каждый такой поток потребляет:

  • около 1–2 МБ стека по умолчанию,
  • системный дескриптор от ОС,
  • время на переключение контекста.

Типичный сервер может держать несколько тысяч платформенных потоков — и это уже предел. Если каждый запрос ждёт ответа от базы данных, все потоки блокируются впустую: процессор не занят, но новые задачи принять некому.

Исторически это решали двумя способами:

  1. Пулы потоков (ExecutorService, ForkJoinPool) — потоки переиспользуются, но лимит остаётся.
  2. Реактивный стиль (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 (планировщик не принял задачу).

Когда виртуальные потоки, когда платформенные

diagram

Виртуальные потоки хорошо подходят для:

  • 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 — асинхронные цепочки для задач, где результат нужен позже.
  • Типичные ошибки многопоточного кода — гонки, дедлоки и другие проблемы, которые встречаются на практике.