Виртуальные потоки Java: чем отличаются от обычных и когда профит
Когда в Java 21 ввели виртуальные потоки (JEP 444), в презентациях зазвучала цифра «миллионы потоков в одной JVM». Звучит как маркетинг — но за ней стоит конкретный механизм, и чтобы понять, где он реально помогает, а где нет, придётся заглянуть под капот.
Чем виртуальный поток отличается от платформенного
Обычный (platform) поток в Java — это тонкая обёртка над потоком операционной системы (ОС-thread). Один Java-поток = один поток ОС = один стек в ядре. Стек потока ОС — это мегабайты зарезервированной памяти, переключение контекста — дорогая операция ядра. Поэтому тысячи платформенных потоков уже болят, десятки тысяч — упираются в память и планировщик ОС.
Виртуальный поток — это сущность внутри JVM. У него тоже есть стек, но он хранится в куче (heap), как любой другой объект, и может расти и ужиматься. Реальный код виртуального потока крутит не он сам, а carrier-поток — обычный платформенный поток из пула ForkJoinPool, который JVM создаёт в количестве примерно равном числу ядер CPU (Runtime.getRuntime().availableProcessors()).
Что происходит на блокирующем вызове
Вот ключевое. Когда виртуальный поток вызывает блокирующую операцию — socket.read(), Thread.sleep(), блокировку по Lock — JVM не блокирует carrier-поток. Вместо этого она отцепляет (unmount) виртуальный поток от carrier'а, сохраняет его стек в кучу и возвращает carrier пулу, чтобы тот взял другой готовый виртуальный поток. Когда операция завершается (данные пришли, таймер сработал), виртуальный поток снова ставится в очередь и подхватывается любым свободным carrier'ом.
Это и есть «магия» Loom: блокирующий код выглядит как обычный синхронный, но под капотом работает как асинхронный — без callback'ов и без reactive-цепочек.
«Миллионы» — это про память, а не про CPU
Теперь цифра из презентаций становится понятнее. Платформенный поток жрёт ~1 МБ стека → 10 000 потоков = ~10 ГБ только под стеки. Виртуальный поток стартует с крошечным стеком (порядка сотен байт – нескольких КБ) и растёт по необходимости → миллион виртуальных потоков реально помещается в кучу современного сервера.
Но миллион виртуальных потоков не сделает работу быстрее в миллион раз. Параллелизм выполнения всё равно ограничен числом carrier-потоков, то есть числом ядер. Виртуальные потоки увеличивают конкурентность (сколько задач может одновременно «висеть» в ожидании I/O), а не параллелизм (сколько задач физически крутит CPU одновременно).
Где прирост честный
I/O-bound нагрузки — главная мишень. Классический кейс: HTTP-сервер, где на каждый запрос — обращение к БД, внешний API, кеш. Раньше для этого делали либо пул потоков (и упирались в его размер), либо reactive-стек (WebFlux, Akka, Vert.x) с его сложностью. Виртуальные потоки дают тот же throughput реактивщины, но на привычном блокирующем коде:
// Старый мир: один поток на запрос, пул размером 200
// Новый мир: один виртуальный поток на запрос, их могут быть сотни тысяч
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
IntStream.range(0, 100_000).forEach(i ->
executor.submit(() -> {
var data = fetchFromDb(i); // блокируется — carrier свободен
return process(data); // крутится на carrier'е
})
);
}
Официальный бенчмарк Oracle (JEP 444) показал: HTTP-сервер на виртуальных потоках обрабатывает ~76% запросов от эталонного реактивного сервера на том же железе, но со значительно более простой моделью программирования. То есть throughput сравним, а код — обычный синхронный.
CPU-bound вычисления — виртуальные потоки не помогут. Если задача грузит процессор (хэширование, ML-инференс, тяжёлая обработка), у неё нет блокирующих пауз, чтобы отдать carrier. Тут выигрыш дадут обычные платформенные потоки в количестве availableProcessors() или parallel-стрим.
Ловушки, о которых стоит знать
Pinning (закрепление)
До Java 24 виртуальный поток не мог отцепиться от carrier'а, если находился внутри блока synchronized. Это называлось pinning: carrier висел заблокированным вместе с виртуальным потоком. JEP 491 (Java 24) это исправил — synchronized больше не пинит. Но на Java 21–23 правило живо: для I/O внутри synchronized используйте ReentrantLock вместо synchronized.
ThreadLocal
Виртуальных потоков может быть очень много, и ThreadLocal-переменная создаётся для каждого. Если в ней тяжёлый объект — память течёт. Для этого завели ScopedValue (JEP 446, preview) — неизменяемые значения, привязанные к области видимости, без накладных расходов на каждый поток.
Пулы соединений
Если каждый запрос крутится в своём виртуальном потоке, а пул соединений к БД держит 20 коннектов — значит, 20 виртуальных потоков получат коннект, а остальные 999 980 будут висеть в ожидании. Loom не отменил того, что внешний ресурс (БД, внешний API) имеет свой потолок. Виртуальные потоки ускоряют сервер, но не базу данных за ним.
Стоит ли выбрасывать WebFlux
Нет, не повсеместно. Реактивный стек по-прежнему оправдан, когда:
- нужна тонкая ручная контроль над backpressure (поток данных быстрее, чем потребитель);
- важна предсказуемо низкая latency под экстремальной нагрузкой;
- кодовая база уже реактивная и переход ничего не даст.
Виртуальные потоки выигрывают там, где блокирующий код проще читать и поддерживать, а требования к backpressure нет. Для большинства типовых CRUD-сервисов это именно так.
Короткое summary
Виртуальные потоки — это инструмент для конкурентного I/O, а не универсальный ускоритель. Они снимают боль «потоков мало, а запросов много» в привычной синхронной модели кода. На CPU-bound задачах или там, где потолок задаёт внешний ресурс (БД), их добавление ничего не поменяет. А pinned-поток внутри synchronized на старых версиях Java напомнит, что «магия» всегда имеет мелкий шрифт.
Что почитать
- JEP 444: Virtual Threads — первоисточник, с разделом про бенчмарки и подводные камни.
- JEP 491: Synchronize Virtual Threads without Pinning — про исправление pinning в Java 24.
- Inside Java — официальный канал Oracle с разборами Loom и
ScopedValue.