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

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

обычная блокировка: unmount носитель VT1 работает socket.read()стек VT1 → в кучу, носитель свободен VT2 работает VT1 вернулся под synchronized (Java 21–23): pinning носитель VT3 работает VT3 ждёт ответа — и держит носитель socket.read()стек остаётся, VT4 ждёт очереди

Носитель — настоящий поток операционной системы. Сверху: VT1 доходит до блокирующего чтения, его стек уезжает в кучу, освободившийся носитель тут же занимает VT2, а когда ответ пришёл — VT1 монтируется обратно, не обязательно на тот же носитель. Снизу тот же вызов под synchronized на Java 21–23: отцепиться нельзя, стек остаётся на носителе, и всё время ожидания носитель занят одной задачей, а VT4 стоит в очереди. На Java 24+ нижняя строка ведёт себя как верхняя.

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

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

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

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

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

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

1000 задач по потоку на задачу каждый просит разрешение Semaphore(20) 20 разрешений прошли 20, остальные ждут запрос к базе в полёте ровно 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(...).

Что почитать дальше