Що таке Virtual Threads в Java 21
Віртуальні потоки радикально здешевлюють створення й утримання потоків: їхній стек зберігається у звичайній купі (Heap) і динамічно зростає від кількох сотень байтів (замість фі...
🟢 Junior Level
Короткий опис для співбесіди (30 секунд)
Virtual Threads (віртуальні потоки) — це легковажні потоки, представлені як стабільна функціональність у Java 21 (JEP 444) у межах проєкту Project Loom. На відміну від традиційних потоків платформи (Platform Threads), які транслюються 1:1 на потоки ядра операційної системи (OS Threads), віртуальні потоки керуються самою віртуальною машиною (JVM) та реалізують модель M:N (мільйони віртуальних потоків мультиплексуються на невеликий пул потоків операційної системи — Carrier Threads).
Віртуальні потоки радикально здешевлюють створення й утримання потоків: їхній стек зберігається у звичайній купі (Heap) і динамічно зростає від кількох сотень байтів (замість фіксованого резервування ~1 МБ нативного стека для кожного Platform Thread). Це повертає в Java просту синхронну модель «один потік на завдання» (Thread-per-request) з пропускною здатністю, порівнянною з асинхронними реактивними фреймворками (Netty, WebFlux), проте без ускладнення кодової бази та «callback hell».
Порівняльна таблиця: Platform Threads vs Virtual Threads
| Характеристика | Platform Threads (Звичайні потоки) | Virtual Threads (Віртуальні потоки) |
|---|---|---|
| Керування | Ядро ОС (OS Kernel Scheduler) | Середовище виконання JVM (User-space Runtime) |
| Мапінг на потоки ОС | 1:1 (Один потік Java = один потік ОС) | M:N (Мільйони VT ділять N Carrier потоків) |
| Розмір стека | Фіксований (~1 МБ нативної пам’яті) | Динамічний у Heap (від сотень байтів до кілобайтів) |
| Перемикання контексту | На рівні ядра (Syscall, CPU Context Switch, ~1–10 мкс) | На рівні JVM (User-space jump, ~десятки наносекунд) |
| Максимальна кількість | Тисячі (обмежено пам’яттю ОС та ulimit) | Мільйони (обмежено лише доступним обсягом Heap) |
| Пулінг (ThreadPool) | Обов’язковий (створення потоку вкрай дороге) | Антипатерн (потоки створюються та знищуються «на льоту») |
| Цільовий профіль завдань | Змішані, включно з CPU-bound | Виключно I/O-bound (очікування мережі/диска/БД) |
Створення та запуск у Java 21
У Java 21 клас java.lang.Thread отримав фабричні методи та білдер для створення віртуальних потоків:
import java.util.concurrent.Executors;
public class VirtualThreadBasicsDemo {
public static void main(String[] args) throws Exception {
// 1. Швидкий запуск через Thread.startVirtualThread
Thread vThread1 = Thread.startVirtualThread(() -> {
System.out.println("Запущено віртуальний потік: " + Thread.currentThread());
});
vThread1.join();
// 2. Створення через Fluent Builder Thread.ofVirtual()
Thread vThread2 = Thread.ofVirtual()
.name("user-request-handler-", 1)
.inheritInheritableThreadLocals(false)
.start(() -> {
System.out.println("Працює іменований потік: " + Thread.currentThread());
});
vThread2.join();
// 3. Стандартний спосіб у продакшені — ExecutorService на кожне завдання
// Створює новий Virtual Thread для кожного переданого Runnable/Callable
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 10_000; i++) {
final int taskId = i;
executor.submit(() -> {
// Імітація блокуючого мережевого I/O
Thread.sleep(100);
return "Task " + taskId + " complete";
});
}
} // try-with-resources автоматично очікує завершення всіх завдань (AutoCloseable)!
}
}
🟡 Middle Level
Архітектура під капотом: Continuations та Carrier Threads
В основі реалізації віртуальних потоків лежить низькорівневий механізм переривних обчислень — Delimited Continuations (jdk.internal.vm.Continuation):
Віртуальні потоки (User Space):
[ VT 1 (Blocked I/O) ] [ VT 2 (Running) ] [ VT 3 (Runnable) ]
│ │
(Unmounted у Heap) (Mounted)
▼
Потоки-носії (Carrier Threads / ForkJoinPool):
[ Carrier Worker Thread 1 (OS Thread) ]
- Carrier Threads (Потоки-носії):
- Віртуальний потік не може виконуватися «у вакуумі» — для виконання машинних інструкцій процесора йому необхідний реальний потік ОС.
- За замовчуванням віртуальні потоки виконуються на спеціальному пулі ForkJoinPool (робочі потоки якого називаються Carrier Threads).
- Розмір цього пулу за замовчуванням дорівнює кількості доступних ядер процесора (
Runtime.getRuntime().availableProcessors()), налаштовується прапорцем-Djdk.virtualThreadScheduler.parallelism=N. - На відміну від класичного робочого пулу, планувальник віртуальних потоків працює в режимі FIFO, а не LIFO, що запобігає голодуванню завдань.
- Монтування (Mounting) та Розмонтування (Unmounting):
- Mounting: Коли віртуальний потік готовий до виконання, JVM зв’язує його з вільним Carrier Thread. Фрейми стека віртуального потоку копіюються з купи в нативний стек потоку-носія.
- Unmounting: При виклику будь-якої стандартної блокуючої операції в Java (наприклад, читання з
SocketInputStream, викликThread.sleep(), очікування наCompletableFutureабоLockSupport.park()):- JVM перехоплює блокування в нативному шарі;
- Поточний стан стека (stack frames) віртуального потоку серіалізується та зберігається в купі у вигляді спеціальних об’єктів
StackChunk; - Віртуальний потік «відчіпляється» (unmounts) від потоку-носія;
- Carrier Thread стає абсолютно вільним і негайно бере на виконання інший віртуальний потік.
- Wakeup & Reschedule: Коли ОС повідомляє про готовність мережевих даних (через системні механізми мультиплексування
epollу Linux,kqueueу macOS абоIOCPу Windows), внутрішній системний полер (Poller) JVM будить віртуальний потік, переводячи його у статусRunnable, і планувальник монтує його на будь-який вільний Carrier Thread (не обов’язково той самий).
Поведінка блокуючого I/O: Мережа vs Файли
Практично всі блокуючі операції JDK були переписані в Java 21 під механізм розмонтування:
// Приклад: читання даних із сокета
try (Socket socket = new Socket("api.internal.service", 8080);
InputStream in = socket.getInputStream()) {
byte[] buffer = new byte[1024];
// У ЗВИЧАЙНОМУ ПОТОЦІ: потік ОС заблокований ядром, стек займає 1 МБ, CPU очікує.
// У VIRTUAL THREAD: потік розмонтовується! Carrier вільний для інших запитів!
int bytesRead = in.read(buffer);
}
[!NOTE] Блокуючий файловий ввід-вивід (
FileChannel,FileInputStream):
В операційних системах Linux стандартний дисковий ввід-вивід не завжди підтримує неблокуючий epoll. Тому під час операцій із файлами через традиційнийjava.ioвіртуальний потік може залишатися прив’язаним до Carrier Thread, а ForkJoinPool компенсує це тимчасовим створенням додаткового потоку-носія (параметрmaxPoolSize). Для повністю неблокуючого I/O рекомендується використовуватиjava.nio.channels.AsynchronousFileChannel.
🔴 Senior Level
Under the Hood: Carrier Pinning (Пришпилення потоку)
Найнебезпечніша проблема під час переходу на віртуальні потоки — Pinning (пришпилення / застрягання). Це стан, за якого віртуальний потік переходить у блокуючу операцію, проте не може розмонтуватися і намертво блокує підлеглий потік ОС (Carrier Thread).
2 головні причини Pinning:
- Виконання блокуючої операції всередині блоку/методу
synchronized: Всередині JVM HotSpot механізмsynchronizedприв’язаний до структуриObjectMonitorу пам’яті C++, яка історично жорстко пов’язана з нативним дескриптором потоку ОС (pthread_t). Якщо віртуальний потік стикається з блокуючим I/O абоThread.sleep()усерединіsynchronized, він не може звільнити стек. - Виконання нативного коду (JNI) або Foreign Function Calls (Project Panama):
Нативний стек C/Assembler перебуває поза контролем збирача сміття та механізму
ContinuationJVM, тому розмонтування фізично неможливе.
public class PinningProblemDemo {
private final Object monitorLock = new Object();
private final java.util.concurrent.locks.ReentrantLock reentrantLock = new java.util.concurrent.locks.ReentrantLock();
// ❌ КАТАСТРОФА ДЛЯ VIRTUAL THREADS (Призводить до Pinning):
public void badProcessing() {
synchronized (monitorLock) {
// Пришпилює Carrier Thread до цього віртуального потоку на 5 секунд!
// Якщо всі 8-16 Carrier Threads будуть пришпилені, увесь застосунок зависне!
callExternalHttpService();
}
}
// ✅ ІДЕАЛЬНО ДЛЯ VIRTUAL THREADS:
public void goodProcessing() {
reentrantLock.lock();
try {
// ReentrantLock побудований на LockSupport.park() та AQS.
// Він коректно розмонтовує віртуальний потік, звільняючи Carrier Thread!
callExternalHttpService();
} finally {
reentrantLock.unlock();
}
}
private void callExternalHttpService() {
try { Thread.sleep(5000); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
}
}
Діагностика та моніторинг Pinning у продакшені:
Щоб виявити пришпилення потоків до падіння системи, використовуйте JVM-прапорець:
# Виводить стек-трейс під час кожного випадку пришпилення віртуального потоку:
java -Djdk.tracePinnedThreads=full -jar application.jar
У продакшен-моніторингу відстежуйте JFR-подію: jdk.VirtualThreadPinned.
Проблема ThreadLocal та альтернатива: ScopedValue
Традиційно в мікросервісах контекст запиту (Trace ID, Security Context, User ID) зберігався у ThreadLocal:
public static final ThreadLocal<RequestContext> CONTEXT = new ThreadLocal<>();
Чому в епоху Virtual Threads це проблема:
- Якщо застосунок створює 1 000 000 віртуальних потоків, створюється 1 000 000 геш-таблиць
ThreadLocalMap. Якщо об’єкт контексту «важить» 50 КБ, це вимагатиме 50 ГБ оперативної пам’яті, що призведе до миттєвогоOutOfMemoryError: Java heap space. - У Java 21 представлено механізм Scoped Values (JEP 446):
- Значення незмінні (Immutable);
- Автоматично очищаються збирачем сміття при виході зі скоупу;
- Передаються дочірнім віртуальним потокам за посиланням без копіювання таблиць.
import java.lang.ScopedValue;
public class ScopedValueDemo {
public static final ScopedValue<String> TRACE_ID = ScopedValue.newInstance();
public void handleRequest() {
// Прив'язуємо контекст до лексичного блоку:
ScopedValue.where(TRACE_ID, "req-xyz-9876").run(() -> {
processBusinessLogic();
});
// Поза блоком TRACE_ID автоматично недоступний і очищений!
}
private void processBusinessLogic() {
System.out.println("Processing with trace: " + TRACE_ID.get());
}
}
Планування та некооперативна / невитесняюча багатозадачність (Cooperative Scheduling)
Критично важлива деталь архітектури HotSpot: віртуальні потоки не мають апаратного квантування часу (Time-Slicing Preemption).
- Перемикання між віртуальними потоками відбувається кооперативно — лише в точках переривання (I/O, паркування замка, sleep, yield).
- Якщо віртуальний потік виконує нескінченний важкий цикл без звернень до I/O або
Thread.yield():Thread.startVirtualThread(() -> { while (true) { // Чистий CPU-bound цикл без блокувань! } });Цей потік намертво захопить Carrier Thread і ніколи його не звільнить. Якщо запустити такі потоки в кількості, що дорівнює числу ядер процесора, планувальник JVM повністю перестане обробляти інші віртуальні потоки.
Діагностика в Production: Налагодження мільйонів потоків
Класична утиліта jstack <pid> непридатна для віртуальних потоків: форматування стеків мільйона потоків призведе до генерації гігабайтного текстового виводу та тривалої паузи застосунку.
Рекомендований інструментарій у Java 21:
# Генерація компактного JSON-дампа потоків через jcmd:
jcmd <PID> Thread.dump_to_file -format=json thread_dump.json
Дамп у форматі JSON групує віртуальні потоки за точками очікування, зменшуючи розмір файлу на порядки.
🎯 Шпаргалка для інтерв’ю
1. 30-секундний есенс (Elevator Pitch)
Virtual Threads (Project Loom, Java 21) — це керовані JVM легковажні потоки, чий стек динамічно зберігається в купі (Heap), а виконання мультиплексується на малий пул потоків ОС (Carrier Threads на базі ForkJoinPool). Під час будь-якої блокуючої операції введення-виведення віртуальний потік автоматично розмонтовується (unmount), зберігає стек у StackChunk та віддає Carrier Thread іншим завданням. Це забезпечує пропускну здатність реактивного програмування при збереженні синхронного імперативного стилю коду. Головні небезпеки: пришпилення потоків (Pinning) через synchronized/JNI, надмірне споживання пам’яті через ThreadLocal і непридатність для CPU-bound обчислень.
2. 4 каверзних питання з глибокими відповідями
Питання 1: Чому ключове слово synchronized викликає пришпилення (pinning) віртуального потоку, а ReentrantLock — ні?
Відповідь:
Механізм synchronized реалізований на рівні ядра JVM HotSpot через нативну C++ структуру ObjectMonitor. Історично дескриптор монітора жорстко прив’язується до нативного ідентифікатора потоку ОС (pthread_t), і JVM не вміє безпечно копіювати стан монітора між потоками ядра під час розмонтування. Як наслідок, Carrier Thread змушений блокуватися разом із віртуальним потоком.
На відміну від synchronized, java.util.concurrent.locks.ReentrantLock написаний чистою Java поверх AbstractQueuedSynchronizer (AQS) та примітива LockSupport.park(). Під час виклику park() JVM перехоплює операцію і нативно викликає Continuation.yield(), коректно розмонтовуючи віртуальний потік.
Питання 2: Що станеться, якщо пул віртуальних потоків задіяти для важких CPU-bound обчислень (криптографія, розрахунок матриць)?
Відповідь:
Продуктивність впаде порівняно з класичним Executors.newFixedThreadPool(N). Віртуальні потоки перемикаються кооперативно лише в точках блокування (I/O, locks, sleep). У суто CPU-bound завданнях точок переривання немає: віртуальний потік монополізує Carrier Thread і працює як звичайний потік. Проте накладні витрати на керування мільйоном об’єктів у купі, роботу планувальника JVM і періодичні збирання сміття додадуть оверхед за пам’яттю та CPU, не давши жодного приросту пропускної здатності.
Питання 3: Чому пул віртуальних потоків (newFixedThreadPool з фабрикою Virtual Threads) вважається грубою архітектурною помилкою?
Відповідь:
Пули потоків (ThreadPoolExecutor) створювалися для Platform Threads, оскільки створення потоку ОС вимагало ~1 МБ пам’яті та тисяч мікросекунд на системний виклик ядра. Віртуальний потік важить кілька сотень байтів і створюється за мікросекунди як звичайний легковажний Java-об’єкт. Обмежувати віртуальні потоки пулом — означає втрачати їхню головну перевагу (модель Thread-Per-Task). Якщо необхідно обмежити конкурентний доступ до дефіцитного зовнішнього ресурсу (наприклад, пул із 20 з’єднань до реляційної БД), потрібно використовувати java.util.concurrent.Semaphore, а не пулінг потоків.
Питання 4: Як віртуальні потоки взаємодіють із моделлю пам’яті Java (JMM) і правилом Happens-Before?
Відповідь:
З погляду специфікації JMM (JLS Chapter 17) віртуальний потік є повноцінним екземпляром java.lang.Thread. Усі гарантії Happens-Before (видимість змін через volatile, звільнення/захоплення блокувань Lock, вхід/вихід із синхронізації, запуск Thread.start() і завершення Thread.join()) діють для віртуальних потоків абсолютно ідентично. Перенесення стека між різними Carrier Threads гарантує повну синхронізацію регістрів CPU та кешів ядер через внутрішні бар’єри пам’яті JVM під час монтування та розмонтування.
3. Типові помилки та Red Flags
❌ «Віртуальні потоки завжди швидші за звичайні потоки.»
Правильно: Віртуальний потік виконує код із тією ж швидкістю (або ледь повільніше через перевірки Continuation). Вони збільшують не швидкість окремої операції (latency), а сумарну пропускну здатність (throughput) системи за тисяч паралельних I/O операцій.
❌ «Пулінг віртуальних потоків для оптимізації пам’яті.»
Правильно: Віртуальні потоки не можна пулити. Для них застосовується Executors.newVirtualThreadPerTaskExecutor(), де потік знищується одразу після завершення завдання.
❌ «Використання synchronized поверх мережевих або зовнішніх HTTP-викликів.»
Правильно: У коді з віртуальними потоками будь-яке блокуюче введення-виведення всередині synchronized викликає Carrier Pinning. Слід використовувати ReentrantLock.
❌ «Масивне збереження контексту в ThreadLocal.»
Правильно: Мільйон потоків призведе до витоку пам’яті в ThreadLocalMap. Необхідно мінімізувати розмір контексту або переходити на ScopedValue.