Виртуальные потоки в Java: где они помогают, а где нет
· 3 мин чтения
Виртуальные потоки (JEP 444) стали стабильными в Java 21. Вокруг них много ожиданий в духе «включил флаг, и сервис стал быстрее». Это не так, и полезно понимать почему.
Что они меняют
Платформенный поток — это обёртка над потоком ОС: мегабайт стека, дорогое создание, переключение через ядро. Поэтому классический thread-per-request упирается в размер пула: 200 потоков Tomcat — это максимум 200 одновременных запросов, даже если каждый из них просто ждёт ответа от базы.
Виртуальный поток живёт в куче и выполняется на небольшом пуле carrier-потоков (по умолчанию ForkJoinPool размером с число ядер). Когда виртуальный поток блокируется на I/O, он отсоединяется от carrier, и тот берёт следующую задачу. Создать миллион виртуальных потоков вполне реально.
Главный эффект: растёт пропускная способность I/O-bound сервиса, при этом код остаётся простым блокирующим, без реактивщины и колбэков.
Чего они не делают
- Не ускоряют отдельный запрос. Латентность запроса определяется тем, что он ждёт. Виртуальный поток ждёт столько же.
- Не помогают CPU-bound задачам. Ядер столько же, сколько было. Для вычислений по-прежнему нужен ограниченный пул.
- Не убирают внешние ограничения. Об этом ниже, это самое важное.
Узкое место переезжает
Допустим, сервис ходит в PostgreSQL через HikariCP с maximumPoolSize = 20. С пулом из 200 потоков Tomcat 180 из них ждали соединение. С виртуальными потоками ждать соединение будут 10 000 потоков. База не стала обслуживать больше запросов, просто очередь переехала в другое место и стала длиннее.
Отсюда практическое правило: ограничение конкурентности надо делать явно, а не через размер пула потоков, как раньше.
private final Semaphore paymentsLimit = new Semaphore(50);
Response callPayments(Request req) throws InterruptedException {
paymentsLimit.acquire();
try {
return paymentsClient.send(req);
} finally {
paymentsLimit.release();
}
}
И, по той же причине, виртуальные потоки не пулят: они одноразовые. Нужно создавать новый поток на каждую задачу:
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
List<Future<Price>> prices = providers.stream()
.map(p -> executor.submit(() -> p.quote(item)))
.toList();
// ...
}
Pinning
Виртуальный поток не может отсоединиться от carrier, если блокируется внутри synchronized (в Java 21–23) или внутри нативного вызова. Carrier тоже остаётся занятым, а их всего столько, сколько ядер. Несколько таких мест под нагрузкой, и сервис встаёт.
В Java 24 (JEP 491) pinning на synchronized убрали. Если вы на 21-й LTS, то:
- ищите pinning через JFR-событие
jdk.VirtualThreadPinned; - в горячих местах с блокирующим I/O под монитором заменяйте
synchronizedнаReentrantLock; - проверяйте библиотеки, особенно старые JDBC-драйверы и клиенты.
ThreadLocal
Код, который кэширует в ThreadLocal тяжёлые объекты (форматтеры, буферы), рассчитывал на 200 долгоживущих потоков. При 100 000 виртуальных потоков получается 100 000 копий. Для передачи контекста лучше подходят ScopedValue (стабильны с Java 25), а тяжёлые объекты стоит делать потокобезопасными или переиспользовать через явный пул.
Как включить в Spring Boot
Начиная со Spring Boot 3.2 достаточно одного свойства:
spring.threads.virtual.enabled=true
Перед этим стоит проверить три вещи: размеры пулов соединений ко всем зависимостям, отсутствие pinning под нагрузочным тестом и использование ThreadLocal в своём коде и библиотеках.
А что с Kotlin
У корутин другая модель: они дают структурированную конкурентность и отмену, которых виртуальные потоки сами по себе не дают (structured concurrency в Java пока в preview). Подходы совместимы: блокирующий код внутри корутин можно запускать на диспетчере поверх виртуальных потоков:
val Loom = Executors.newVirtualThreadPerTaskExecutor().asCoroutineDispatcher()
suspend fun loadUser(id: Long) = withContext(Loom) { userRepository.findById(id) }
Итог
Виртуальные потоки — способ писать простой блокирующий код и при этом не упираться в число потоков. Они не делают систему быстрее сами по себе. Они убирают одно искусственное ограничение, и после этого становятся видны настоящие: база, внешние API, пулы соединений. Именно их и нужно ограничивать явно.