Что такое Virtual Threads в Java 21?
Virtual Threads (виртуальные потоки) — это лёгкие потоки, управляемые JVM, а не операционной системой. Они стали стабильной фичей в Java 21 (LTS-версия), но разрабатывались в ра...
Virtual Threads (виртуальные потоки) — это лёгкие потоки, управляемые JVM, а не операционной системой. Они стали стабильной фичей в Java 21 (LTS-версия), но разрабатывались в рамках Project Loom с 2017 года и проходили через несколько preview-релизов (Java 19-20).
Важная оговорка: хотя API стабилен в Java 21, некоторые связанные фичи (Scoped Values, Structured Concurrency) всё ещё в preview. Это значит, что в будущих версиях их API может измениться. Если вы используете только базовое Thread.ofVirtual(), это production-ready.
Junior уровень
Базовое понимание
Virtual Threads (виртуальные потоки) — это лёгкие потоки, управляемые JVM, а не операционной системой. Они позволяют создавать миллионы потоков вместо тысяч.
Почему обычные потоки дорогие: каждый Platform Thread резервирует ~1MB стека в памяти ОС при создании, даже если использует лишь несколько килобайт. Кроме того, переключение между потоками требует перехода в режим ядра (kernel mode), что занимает тысячи CPU-циклов.
Почему виртуальные потоки дешёвые: их стек хранится в обычной куче JVM (heap) и растёт динамически — от нескольких килобайт. Переключение происходит в user-space без системных вызовов, что в 100-1000 раз быстрее.
Простая аналогия
- Platform Threads (обычные): как такси — каждое требует машину (1MB памяти)
- Virtual Threads: как маршрутка — много пассажиров на одном транспорте
Создание и использование
// Platform Thread (старый способ)
Thread platform = new Thread(() -> {
System.out.println("Platform thread");
});
platform.start();
// Virtual Thread (Java 21+)
Thread virtual = Thread.ofVirtual().start(() -> {
System.out.println("Virtual thread");
});
// Или через билдер
Thread vThread = Thread.startVirtualThread(() -> {
System.out.println("Virtual thread");
});
Сравнение
| Характеристика | Platform Threads | Virtual Threads |
|---|---|---|
| Кем управляется | ОС (Kernel) | JVM (Runtime) |
| Размер стека | ~1 MB (статический) | Несколько KB (динамический) |
| Время создания | ~1-10 ms | ~микросекунды |
| Максимум | Тысячи | Миллионы |
Пример: Thread-per-request
// С виртуальными потоками — простая модель снова работает!
ServerSocket server = new ServerSocket(8080);
while (true) {
Socket client = server.accept();
Thread.ofVirtual().start(() -> {
handleRequest(client); // Один виртуальный поток на запрос
});
}
// Миллионы соединений — без реактивного программирования!
Middle уровень
Архитектура: Continuations
В основе виртуальных потоков лежит концепция Continuations (продолжений):
1. Виртуальный поток выполняет код на Carrier Thread (реальный поток ОС)
2. При блокирующей операции (sleep, I/O):
a. JVM сохраняет стек виртуального потока в кучу (Heap)
b. Поток "отлепляется" (unmount) от Carrier Thread
c. Carrier Thread идёт выполнять другой виртуальный поток
3. Когда I/O завершается:
a. JVM восстанавливает стек из кучи
b. Поток "прилепляется" (mount) к свободному Carrier Thread
c. Продолжает выполнение с места остановки
Carrier Thread Pool
// Виртуальные потоки делят реальные потоки (Carrier Threads)
// По умолчанию: ForkJoinPool с размером = число ядер CPU
// Пример: 1,000,000 виртуальных потоков на 8 Carrier Threads
// Каждый Carrier Thread обрабатывает ~125,000 виртуальных потоков
// по очереди, когда они размонтируются
Механизм Stack Chunking
JVM хранит стек виртуального потока в виде объектов StackChunk в куче:
Виртуальный поток:
┌────────────────────────┐
│ StackChunk #1 (в куче) │ ← Текущий стек
│ StackChunk #2 (в куче) │ ← Предыдущий фрейм
│ StackChunk #3 (в куче) │ ← Ещё глубже
└────────────────────────┘
При монтировании данные копируются из кучи в реальный стек процессора, при размонтировании — обратно.
Создание пула виртуальных потоков
// ExecutorService для виртуальных потоков
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 1_000_000; i++) {
executor.submit(() -> {
// Обрабатываем задачу
Thread.sleep(1000); // Размонтируется — не блокирует Carrier
});
}
}
// Миллион задач — и JVM не упала!
Senior уровень
Under the Hood: Pinning (Пригвождение)
Это главная ловушка виртуальных потоков. Виртуальный поток может “прилипнуть” к Carrier Thread и не размонтироваться при блокировке:
Причины Pinning
| Причина | Описание | Решение |
|---|---|---|
| synchronized блок/метод | Виртуальный поток в synchronized НЕ размонтируется | Заменить на ReentrantLock |
| Native методы (JNI) | Вызовы нативного кода не поддерживают unmount | Обернуть в отдельный поток |
// ПЛОХО: synchronized блокирует Carrier Thread
synchronized(lock) {
Thread.sleep(10000); // Виртуальный поток "прилип" к носителю на 10 секунд!
}
// ХОРОШО: ReentrantLock поддерживает unmount
ReentrantLock lock = new ReentrantLock();
lock.lock();
try {
Thread.sleep(10000); // Размонтируется — Carrier Thread свободен
} finally {
lock.unlock();
}
Диагностика Pinning
# Обязательный флаг при разработке с Virtual Threads
java -Djdk.tracePinnedThreads=full MyApp
# Выведет stack trace каждый раз, когда поток "прилипает"
ThreadLocal и Virtual Threads
// ОСТОРОЖНО: миллион VT = миллион копий ThreadLocal!
ThreadLocal<ExpensiveObject> local = ThreadLocal.withInitial(ExpensiveObject::new);
// Решение: Scoped Values (Java 21 Preview)
static final ScopedValue<UserContext> CONTEXT = new ScopedValue<>();
ScopedValue.runWhere(CONTEXT, new UserContext("user123"), () -> {
// CONTEXT.get() доступен внутри
});
// После выхода — автоматически очищается, нет утечек
Производительность и Highload
Thread-per-request возвращается
До VT:
1000 platform threads max → Reactive/CompletableFuture → Callback Hell
С VT:
1,000,000 virtual threads → Thread-per-request → Простой блокирующий код
Spring Boot 3.2+
# application.yml
spring:
threads:
virtual:
enabled: true
# Весь Tomcat/Undertow переходит на виртуальные потоки!
Carrier Thread Pool sizing
// По умолчанию: ForkJoinPool.commonPool()
// Размер = число логических ядер CPU
// Кастомизация:
System.setProperty("java.util.concurrent.ForkJoinPool.common.parallelism", "16");
Диагностика
JFR Events
java -XX:StartFlightRecording=filename=rec.jfr MyApp
События:
jdk.VirtualThreadStart/jdk.VirtualThreadEndjdk.VirtualThreadPinned— критично для мониторинга
jcmd для дампов
# jstack может "обалдеть" от миллиона потоков
jcmd <pid> Thread.dump_to_file -format=json threads.json
# JSON формат эффективнее для анализа
Memory Analysis
Миллион виртуальных потоков = миллион объектов в куче
Каждый VT ~ нескольких KB (стек) + объекты
Проверьте -Xmx перед запуском!
Рекомендуется: минимум 256MB для миллиона VT
Когда VT НЕ дают преимущества
| Сценарий | Почему | Альтернатива |
|---|---|---|
| CPU-bound задачи | VT не размонтируются, оверхед планировщика | FixedThreadPool (N+1) |
| synchronized-heavy code | Pinning — блокируют Carrier Threads | ReentrantLock |
| Ограничение ресурсов | Миллион VT положит БД | Semaphore |
Best Practices
- Используйте VT для I/O-bound — веб-серверы, API-шлюзы, прокси
- Заменяйте synchronized на ReentrantLock — избежание pinning
- Используйте Semaphore — для ограничения доступа к дефицитным ресурсам
- Осторожно с ThreadLocal — миллион копий = много памяти
- Scoped Values — для передачи контекста (Java 21 Preview)
- -Djdk.tracePinnedThreads=full — при разработке
- Не используйте VT для CPU-bound — FixedThreadPool лучше
- Мониторьте jdk.VirtualThreadPinned — через JFR
Когда НЕ использовать Virtual Threads
- Java ниже 21 — VT недоступны. Для Java 17 используйте реактивный стек (WebFlux, Completable- Future)
- CPU-bound задачи (вычисления, ML, криптография) — VT не размонтируются при вычислениях, только добавляют оверхед планировщика. Используйте
Executors.newFixedThreadPool(N)где N = число ядер + 1 - synchronized-heavy legacy-код — pinning заблокирует Carrier Threads. Сначала рефакторите на ReentrantLock
- Ограниченные ресурсы (пул БД на 20 соединений) — миллион VT положит БД. Используйте Semaphore
- ThreadLocal-heavy приложения — миллион VT = миллион копий ThreadLocal = OutOfMemoryError. Переходите на Scoped Values
Virtual Threads vs Platform Threads vs ExecutorService: что выбрать?
| Ситуация | Выбор | Почему |
|---|---|---|
| Веб-сервер, API-шлюз (I/O-bound) | Virtual Threads | Миллионы одновременных соединений, простой код |
| Вычисления (ML, парсинг) | FixedThreadPool | VT не размонтируются, оверхед без пользы |
| Legacy с synchronized | FixedThreadPool + рефакторинг | Pinning VT убьёт производительность |
| Ограниченный ресурс (БД, API лимит) | VT + Semaphore | VT масштабируются, Semaphore ограничивает |
🎯 Шпаргалка для интервью
Обязательно знать:
- Virtual Threads (VT) — лёгкие потоки, управляемые JVM, а не ОС; стек в куче (динамически), а не нативный стек (статический 1MB)
- При блокирующей I/O VT “размонтируется” — JVM сохраняет стек в StackChunk и отдаёт Carrier Thread другому VT
- Carrier Thread Pool по умолчанию = ForkJoinPool с размером = число ядер CPU
- Pinning: synchronized и native методы не позволяют VT размонтироваться — Carrier Thread заблокирован; решение: ReentrantLock
- ThreadLocal + миллион VT = OutOfMemoryError; альтернатива: Scoped Values (Java 21 Preview)
- VT для I/O-bound (веб-серверы, API), НЕ для CPU-bound (вычисления)
- Spring Boot 3.2+:
spring.threads.virtual.enabled=true
Частые уточняющие вопросы:
- Почему VT не подходят для CPU-bound задач? — VT не размонтируются при вычислениях, только добавляют оверхед планировщика JVM
- Что такое StackChunk? — Объект в куче, хранящий стек виртуального потока; при монтировании копируется в реальный стек процессора
- Как диагностировать pinning? — Флаг
-Djdk.tracePinnedThreads=fullвыводит stack trace при каждом случае пригвождения - Сколько VT можно создать? — Миллионы; ограничение — heap (рекомендуется минимум 256MB для миллиона VT)
Красные флаги (НЕ говорить):
- ❌ “Virtual Threads — это то же самое, что threads в Go” — VT похожи на goroutines, но работают на JVM с Carrier Threads
- ❌ “VT всегда быстрее Platform Threads” — VT быстрее только для I/O-bound; для CPU-bound VT медленнее из-за оверхеда
- ❌ “Можно просто заменить все synchronized на VT без изменений” — synchronized вызывает pinning, нужно заменить на ReentrantLock
- ❌ “VT несовместимы с ExecutorService” —
Executors.newVirtualThreadPerTaskExecutor()— стандартный способ работы с VT
Связанные темы:
- [[24. В чём преимущества Virtual Threads перед обычными потоками]]
- [[25. Когда стоит использовать Virtual Threads]]
- [[26. Что такое structured concurrency]]
- [[27. В чём разница между Thread и Runnable]]