Пятнадцать лет Java-сообщество обсуждало лёгкие потоки, а цела библиотека волокон с инструментацией байткода существовала и работала, но так и осталась достоянием энтузиастов. В сентябре 2023 года вопрос закрыл выпуск Java 21, очередной с меткой LTS: JEP 444 довёл виртуальные потоки до финального состояния. Превью шло долго, от JEP 425 в Java 19 до второй итерации в Java 20, сам проект Loom заявлен ещё в 2017 году. Смысл новинки в одном предложении: код в стиле поток-на-запрос снова становится дешёвым, масштаб блокирующего обслуживания перестаёт упираться в размер пула. Ниже механика, честный выигрыш, подвохи миграции и границы, за которыми виртуальные потоки не помогают.

Механика монтирования виртуального потока на носитель и цена стека в арифметике

Платформенный поток это обёртка над нитью ядра операционной системы: свой стек, для 64-битных сборок HotSpot по умолчанию около мегабайта, свой вес при каждом переключении контекста. Виртуальный поток живёт внутри виртуальной машины: исполняется на платформенном потоке-носителе, а его стек хранится в куче небольшими сегментами, растущими по мере углубления вызовов. Когда код виртуального потока блокируется на сокете или паре, JVM снимает его с носителя, освобождённый носитель берёт следующую работу, а при ответе сети поток монтируется обратно, возможно уже на другой носитель. Привязки к конкретному носителю нет, вызов Thread.currentThread() честно возвращает сам виртуальный поток.

Планировщик это отдельный ForkJoinPool в режиме FIFO, не тот общий пул, что обслуживает параллельные стримы в LIFO. Параллельность планировщика по умолчанию равна числу доступных процессоров и меняется системным свойством jdk.virtualThreadScheduler.parallelism, потолок платформенных потоков задаётся свойством jdk.virtualThreadScheduler.maxPoolSize. Арифметика выигрыша простая. Десять тысяч платформенных потоков стоят порядка десяти гигабайт только под стеки. Десять тысяч виртуальных потоков умещаются в сотни мегабайт кучи, а миллион задач, зависимых от внешних сервисов, перестаёт быть фантастикой.

Реальный выигрыш на блокирующем IO в сценарии сервера поток на запрос

Возьмём сервис, который на каждый запрос ходит в базу через JDBC и тратит 100 миллисекунд на ответ СУБД. Классический стек поднимает пул на двести потоков, и при десяти тысячах одновременных клиентов 9800 из них стоят в очереди, растут таймауты и повторные запросы. Поставь тот же код на виртуальные потоки, и каждый запрос получает свой, ничего не стоящий поток: все десять тысяч спокойно ждут свою сотню миллисекунд, ядра процессора заняты полезной работой, а не переключением контекста.

Канонический пример из спецификации укладывается в несколько строк:

try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    IntStream.range(0, 10_000).forEach(i ->
        executor.submit(() -> {
            Order order = orderDao.find(i);
            return order.total();
        }));
}

Десять тысяч задач с обращением к базе современному железу не страшны. Неявный вызов close() в конце блока дожидается завершения всех работ. Отдельная нить запускается и короче:

Thread worker = Thread.ofVirtual()
    .name("order-", 0)
    .start(() -> notifier.send(orderDao.find(orderId)));

Выигрыш виден и в устойчивости к всплескам. Сервер на фиксированном пуле переживает пик с болью: очередь растёт, клиенты уходят по таймаутам, повторные запросы множат нагрузку. Сервер на виртуальных потоках просто содержит десять тысяч ожидающих задач, каждая в своей спокойной нити, и отвечает по мере готовности.

Экосистема подтянулась быстро. Spring Boot 3.2 в ноябре 2023 года получил флаг spring.threads.virtual.enabled=true, поднимающий встроенный Tomcat на схеме поток-на-запрос. Контейнеры Tomcat 10.1 и Jetty 12 умеют работать с виртуальными потоками, сервер Helidon в четвёртой версии изначально построен вокруг модели поток-на-запрос. Для приложений на JDBC, SOAP-клиентах и унаследованных SDK это дешёвый масштаб без переписывания на реактивность и без переподготовки всей команды.

Пиннинг на synchronized блоках как главный подвох первой миграции

Не всякое блокирование равнозначно. Внутри synchronized блока или метода с нативными кадрами виртуальный поток не может размонтироваться: он приколочен к носителю, носитель стоит в ожидании, а рядом стоят остальные виртуальные потоки планировщика. Планировщик не компенсирует пиннинг расширением параллельности, масштаб приложения тихо проседает. Рекомендация из самого JEP точечная: горячие synchronized участки переводить на ReentrantLock, редкие не трогать вовсе. Блокировки внутри самого JDK, например Object.wait, компенсируются временным расширением параллельности, число носителей может превысить число процессоров, а вот synchronized в JDK 21 держал носитель до конца ожидания. Перевод выглядит механически:

private final ReentrantLock lock = new ReentrantLock();

public void update(Order order) {
    lock.lock();
    try {
        orderRepository.save(order);
    } finally {
        lock.unlock();
    }
}

Захват ReentrantLock паркует виртуальный поток и освобождает носитель, а захват synchronized в JDK 21 оставлял носитель ждать рядом.

Диагностика встроена в JDK Flight Recorder. Событие jdk.VirtualThreadPinned включено по умолчанию и срабатывает с порогом 20 миллисекунд, события старта и завершения jdk.VirtualThreadStart и jdk.VirtualThreadEnd по умолчанию выключены, а событие провала запуска jdk.VirtualThreadSubmitFailed активно сразу:

jfr print --events jdk.VirtualThreadPinned recording.jfr

У истории с пиннингом есть развязка: в JDK 24, вышедшем в марте 2025 года, монтирование при synchronized переработано, и приколоченные потоки перестали удерживать носитель. Меняются и драйверы: в свежих выпусках JDBC-драйвера PostgreSQL synchronized уступил место ReentrantLock ради дружбы с виртуальными потоками. Приложение на JDK 21 всё же стоит прогнать с записью JFR перед миграцией, список удерживающих мест часто оказывается неожиданным.

ThreadLocal и прямой запрет пулов виртуальных потоков

Спецификация формулирует коротко: пулы виртуальных потоков не нужны. Пул существует ради дорогого ресурса, а виртуальный поток именно что дёшев. Перенос привычки "пул потоков размера тридцать два" на новый мир это антипаттерн: ограничение конкурентности выражается семафором, а не числом дорогих нитей в резерве.

Semaphore database = new Semaphore(50);

Переменные потока поддерживаются, но с оговоркой о размышлении перед использованием. С миллионами живых потоков кеш на каждый из них превращается в заметный счёт кучи: буферы форматирования, кеши нарезок, разборщики дат, всё то, что годами сваливали в ThreadLocal, теперь умножается на небывалое число владельцев. Отдельная засада с наследуемыми переменными InheritableThreadLocal, которые копируют значения родителя в порождённый поток: пул переиспользовал десятки нитей, а виртуальных потоков заводятся миллионы, и каждая копия становится собственником. Направление развития указано в ScopedValue, значение области видимости вместо значения потока. Унаследованный код с переменными потока работает, но новый так писать уже не стоит.

Виртуальные потоки приехали в JDK не одни, рядом с ними появились сопутствующие инструменты, и о них стоит знать отдельно. Структурированный параллелизм из превью JEP 453 в той же Java 21 оформляет связь подзадач одного запроса: несколько потоков открываются внутри блока try-with-resources, выход из блока дожидается всех, исключение в одной подзадаче отменяет соседей. Идея прямая: дерево задач одного запроса завершается единообразно, а не остаётся в фоне поодиночке. API в превью менялся от версии к версии, поэтому в продакшен его несли осторожно.

Наблюдаемость достроена без полумер. Классический снимок нитей jstack виртуальных потоков не показывает вовсе, за них отвечает новый формат снимка через jcmd, где у каждой задачи видны имя, состояние и носитель:

jcmd 12345 Thread.dump_to_file -format=json dump.json

Файл в формате JSON читается скриптами и собирается в дашборды, число живущих виртуальных потоков считается по событиям JFR. Отладчики, профилировщики и JMX видят оба вида потоков, поэтому миграция не ломает привычный процесс эксплуатации.

Перенос боевого сервиса с пулов на виртуальные потоки по шагам

Миграция зрелого приложения обычно идёт по одному маршруту, и он не сводится к переводу одного флага:

  1. обновить JDK до 21 LTS или сразу до 25 LTS, чтобы получить финальный API и исправления первых выпусков;
  2. прогнать нагрузочный тест с записью JFR и собрать события jdk.VirtualThreadPinned с порогом двадцать миллисекунд;
  3. заменить synchronized на ReentrantLock в горячих путях, которые удержание носителя показало явно;
  4. пересмотреть переменные ThreadLocal и старые пулы, заменив лимиты пулов семафорами, а кеши явными структурами;
  5. включить spring.threads.virtual.enabled=true и перепроверить размеры пулов соединений с базой и внешними сервисами;
  6. оставить платформенные потоки в фоновых математических задачах и долгоживущих служебных процессах.

После включения главной точкой внимания становится пул соединений с базой. Виртуальные потоки снимают цену ожидания в очереди, но не расширяют лимиты самой СУБД: десять тысяч параллельных запросов к базе упрутся в настройки соединений быстрее, чем в память. Разумная настройка по-прежнему держит пул соединений по пропускной способности базы, а семафор наверху дисциплинирует очередь.

Проверкой служит не зелёный лог, а сравнение чисел до и после: пропускная способность, перцентили времени отклика, расход кучи и частота сборок мусора, снятые одним и тем же профилем нагрузки. Инструменты вроде Gatling или k6 воспроизводят профиль стабильнее ручных проверок, а графики числа живущих виртуальных потоков в метриках показывают, как армия ожидающих задач переживает пиковые минуты.

Границы применимости и почему замена всех потоков сразу это ошибка

Виртуальный поток не добавляет процессоров. Параллелизм планировщика равен числу ядер, вычислительная задача не ускоряется от смены обёртки. Для обработки больших массивов стримы остаются штатным инструментом параллелизма, сама спецификация прямо не ставит цели менять модель параллельных вычислений. Вытеснения по времени планировщик пока не делает: долгий счёт без блокировок внутри виртуального потока задерживает соседей на его носителе, так что чистую математику оставляют платформенным потокам.

Не выигрывают и реактивные стеки. Netty и WebFlux решают масштаб иначе, через событийные циклы, и перевод их нити на виртуальные потоки не даёт ничего, кроме лишней прослойки. Долгоживущие служебные процессы, сборщики метрик, слушатели сокетов, периодические задачи тоже остаются платформенными: у них нет блокирующего ввода-вывода в цикле и нет числа, которое хотелось бы масштабировать. Классический платформенный поток никуда не уходит, у обоих видов своя работа.

Отдельная группа сюрпризов прячется в старых допущениях. Код, годами живший на пуле фиксированного размера, мог неявно полагаться на его ограничение: пул сам служил семафором, и смежные системы не замечали всплесков. Снятие ограничения вскрывает тайную зависимость сразу: внешние сервисы получают незнакомую нагрузку, кеши разогреваются иначе, очереди копятся быстрее. Перед миграцией стоит спросить, где именно приложение ограничивает конкурентность, и перевести эти места в явные семафоры.

Спор пятнадцати лет закрылся не заменой одного инструмента другим, а появлением второй сущности под конкретный класс задач. Виртуальные потоки вернули простоту блокирующего кода без цены масштаба, и для сервисов, забитых ожиданием внешних систем, это честный выигрыш с первой недели. Но как любое сильное средство, они требуют понимания механики: пиннинг, переменные потока и слепой перенос пулов оборачиваются новыми сюрпризами. Java 25 LTS с сентября 2025 года несёт уже зрелую реализацию, и отговорок в духе "подождём релиза" больше не осталось.