Питання 22 · Розділ 9

Що таке Virtual Threads в Java 21?

Virtual Threads (віртуальні потоки) — це легкі потоки, керовані JVM, а не операційною системою. Вони стали стабільною фічею в Java 21 (LTS-версія), але розроблялися в рамках Pro...

Мовні версії: English Russian Ukrainian

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.VirtualThreadEnd
  • jdk.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 код Pinning — блокують Carrier Threads ReentrantLock
Обмеження ресурсів Мільйон VT покладе БД Semaphore

Best Practices

  1. Використовуйте VT для I/O-bound — веб-сервери, API-шлюзи, проксі
  2. Замінюйте synchronized на ReentrantLock — уникнення pinning
  3. Використовуйте Semaphore — для обмеження доступу до дефіцитних ресурсів
  4. Обережно з ThreadLocal — мільйон копій = багато пам’яті
  5. Scoped Values — для передачі контексту (Java 21 Preview)
  6. -Djdk.tracePinnedThreads=full — при розробці
  7. Не використовуйте VT для CPU-bound — FixedThreadPool краще
  8. Моніторте jdk.VirtualThreadPinned — через JFR

Коли НЕ використовувати Virtual Threads

  • Java нижче 21 — VT недоступні. Для Java 17 використовуйте реактивний стек (WebFlux, CompletableFuture)
  • 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]]