Что такое виртуальные потоки в Java 21+? Чем они отличаются от платформенных и когда их использовать?
Короткий ответ
- Виртуальный поток — лёгкий поток JVM, не привязанный к потоку ОС намертво
- При блокирующем IO он отмонтируется от carrier-потока, освобождая его
- Можно создавать миллионы — стек растёт динамически в куче
- Выигрыш для IO-bound нагрузки, для CPU-bound выгоды нет
- Пиннинг: synchronized с блокировкой внутри до Java 24 держал carrier
- Пулы виртуальных потоков не нужны — поток на задачу
- ThreadLocal работает, но на миллионах потоков дорог — есть ScopedValue
Виртуальные потоки позволяют писать простой блокирующий код с масштабируемостью асинхронного — для IO-bound сервисов.
Как сказать вслух
пример ответаВиртуальный поток — это лёгкий поток, которым управляет сама JVM, а не операционная система. Когда он блокируется на вводе-выводе, JVM снимает его с несущего потока ОС и ставит туда другой — поэтому их можно создавать миллионами и писать обычный блокирующий код, который масштабируется как асинхронный. Смысл есть для IO-нагрузки: много запросов к базе и внешним API. Для чисто вычислительных задач выгоды нет — ядер больше не становится.
Подробный ответ
Основной ответ
Виртуальные потоки (стабильны с Java 21) — это user-mode потоки: JVM мультиплексирует их на небольшой пул carrier-потоков (ForkJoinPool по числу ядер). При блокирующей операции (сокеты, JDBC, sleep) виртуальный поток размонтируется, его стек сохраняется в куче, а carrier выполняет другие потоки. Это убирает главное ограничение модели «поток на запрос» — дороговизну потоков ОС, без переписывания кода на реактивщину. Создание: Thread.ofVirtual().start(...) или Executors.newVirtualThreadPerTaskExecutor(); пулить их не нужно — они одноразовые и дешёвые. Ограничения: пиннинг — до Java 24 блокировка внутри synchronized-секции удерживала carrier (лечится ReentrantLock, в новых версиях JDK исправлено); нативные вызовы тоже пиннят. Для передачи контекста на больших количествах потоков вместо ThreadLocal появился ScopedValue. Spring Boot включает их флагом spring.threads.virtual.enabled.
Ключевые моменты
- Mount/unmount. Блокирующий вызов снимает поток с carrier; стек живёт в куче и растёт по необходимости.
- Где выигрыш. IO-bound: тысячи параллельных запросов к БД/HTTP; CPU-bound ограничен числом ядер как и раньше.
- Пиннинг. synchronized + блокировка внутри до JDK 24 держали carrier; диагностика -Djdk.tracePinnedThreads.
- Практика использования. Не пулить, поток на задачу; осторожно с ThreadLocal-кэшами и ограничением конкурентности — нужен семафор, а не пул.
Практический контекст
Актуальный senior-вопрос: проверяют, следит ли кандидат за платформой и понимает ли, что виртуальные потоки — альтернатива реактивному стеку, а не ускоритель всего. Практические темы рядом: пулы соединений БД становятся узким местом (виртуальных потоков тысячи, коннектов десятки), ограничение параллелизма семафором, совместимость со старыми библиотеками, использующими synchronized. Хороший ответ сравнивает: простота блокирующего кода против сложности CompletableFuture/WebFlux.
Пример кода
// Поток на задачу: 10_000 параллельных блокирующих вызовов
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
List<Future<String>> results = IntStream.range(0, 10_000)
.mapToObj(i -> executor.submit(() -> httpClient.send(
request(i), HttpResponse.BodyHandlers.ofString())
.body()))
.toList();
}
// Ограничение конкурентности — семафором, не пулом:
// Semaphore permits = new Semaphore(100);Частые ошибки
- Ожидают ускорения CPU-bound задач от виртуальных потоков
- Создают пул виртуальных потоков фиксированного размера, теряя весь смысл
- Не знают про пиннинг на synchronized и получают деградацию на старых библиотеках