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

В чому перевага Virtual Threads перед звичайними потоками?

У цьому файлі ми розбираємо чому віртуальні потоки ефективніші за звичайні, а не просто констатуємо факт. Ключова відмінність: Platform Threads — це обгортка над потоком ОС (дор...

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

У цьому файлі ми розбираємо чому віртуальні потоки ефективніші за звичайні, а не просто констатуємо факт. Ключова відмінність: Platform Threads — це обгортка над потоком ОС (дорогий, фіксований розмір), а Virtual Threads — об’єкт у купі JVM (дешевий, динамічний розмір).


Junior рівень

Базове розуміння

Віртуальні потоки (VT) перевершують звичайні (Platform Threads) за кількома ключовими параметрами.

1. Масштабованість

Характеристика Platform Threads Virtual Threads
Максимум потоків ~2,000-5,000 ~1,000,000+
Обмеження Пам’ять ОС (1MB на потік) Heap JVM
Приклад 1000 потоків = ~1GB 1,000,000 потоків = ~256MB

2. Економія пам’яті

Platform Thread:  ~1MB стеку (резервується одразу)
Virtual Thread:   ~кілька KB (динамічно у купі)

3. Простота коду

До VT (реактивний стиль):

// Callback Hell — складно читати і відлагоджувати
httpClient.get(url)
    .thenApply(this::parse)
    .thenCompose(data -> db.save(data))
    .thenAccept(result -> respond(result))
    .exceptionally(ex -> handleError(ex));

З VT (простий блокуючий стиль):

// Простий послідовний код
String response = httpClient.get(url);  // "Блокується" (розмонтується)
Data data = parse(response);
Result result = db.save(data);
respond(result);

Middle рівень

Теорема Літтла (Little’s Law)

L = λ × W

L = кількість активних задач (потоків)
λ = пропускна здатність (requests per second)
W = час обробки одного запиту (latency)

Проблема Platform Threads:

  • L обмежено ~2000-5000 потоками
  • Якщо W (латентність) зростає → λ (throughput) неминуче падає

Рішення Virtual Threads:

  • L може бути мільйонами
  • Навіть при повільних I/O відповідях throughput залишається високим

Ефективність CPU

Platform Thread Context Switch

1. Перехід у режим ядра (Kernel Mode) — syscall
2. Збереження регістрів процесора в TSS (Task State Segment)
3. Зміна таблиць сторінок пам'яті (TLB flush)
4. Завантаження нового потоку
5. Повернення у користувацький режим

Час: ~1-10 мікросекунд (~1000-10000 CPU cycles)
Чому дорого: кожен крок вимагає участі ОС та CPU

Virtual Thread Context Switch

1. Збереження стеку в StackChunk (купа) — звичайна memcpy
2. Зміна вказівника на інший VT у черзі JVM
3. Відновлення стеку з купи — звичайна memcpy

Час: ~10-50 CPU cycles (наносекунди)
Чому дешево: все у user-space, без системних викликів

Чому VT перемикання у 100-1000 разів швидше: Platform Thread switch вимагає переходу в kernel mode (syscall), що включає TLB flush та перемикання таблиць сторінок. VT switch — це просто копіювання даних у купі, що CPU робить на швидкості роботи з пам’яттю.

Спрощення архітектури

Підхід Складність Відлагодження VT потрібен?
Thread-per-request Низька Проста Так — VT повертають цей підхід
Thread Pool Середня Середня Ні — пули працюють і без VT
Reactive (WebFlux) Висока Складна Ні — VT замінюють реактивність
CompletableFuture Середня Середня Ні — але VT спрощують

Коли VT НЕ дають переваги

Сценарій Чому Рішення
CPU-bound задачі VT не розмонтуються, оверхед планувальника FixedThreadPool
synchronized-heavy Pinning — блокують Carrier Threads ReentrantLock
Старі JDBC драйвери Всередині synchronized Оновити драйвер

Senior рівень

Under the Hood: Мінімізація перемикань контексту ОС

Platform Thread Switch

Thread A (User Mode)
    ↓ syscall
Kernel Mode:
    - Save A's registers to TSS
    - Update page tables
    - Load B's registers from TSS
    - Update GDT/LDT
    ↓ iret
Thread B (User Mode)

~1000+ CPU cycles

Virtual Thread Switch

VT A on Carrier Thread 1
    ↓ yield (I/O блокування)
    - Зберегти стек в StackChunk (купа)
    - Звільнити Carrier Thread 1
    ↓
Carrier Thread 1 бере VT B з черги
    - Відновити стек VT B зі StackChunk
    ↓
VT B продовжує виконання

~10-50 CPU cycles

Memory: Heap vs Native Stack

Platform Threads:
┌─────────────────────┐
│  JVM Heap           │
│  (objects, etc.)    │
├─────────────────────┤
│  Native Memory      │
│  Thread Stack: 1MB  │ ← Поза купою (RSS)
│  Thread Stack: 1MB  │
│  Thread Stack: 1MB  │
│  ...                │
└─────────────────────┘

Virtual Threads:
┌─────────────────────┐
│  JVM Heap           │
│  StackChunk #1: 4KB │
│  StackChunk #2: 8KB │
│  StackChunk #3: 2KB │
│  ... (динамічно)    │
├─────────────────────┤
│  Native Memory      │
│  8 Carrier Threads  │ ← Лише 8 стеків по 1MB
└─────────────────────┘

Латентність та P99

Platform Threads при перевантаженні:
  P50: 10ms
  P99: 500ms  ← Черга на захоплення системних потоків

Virtual Threads:
  P50: 10ms
  P99: 15ms   ← Стабільно, немає черги на потоки

Pinning — головна проблема

// Віртуальний потік "прилипає" до Carrier Thread при:

// 1. synchronized блок/метод
synchronized(lock) {
    blockingIO(); // Carrier Thread заблокований на час I/O!
}

// 2. Native методи (JNI)
nativeMethod(); // Не можна розмонтувати

Діагностика:

java -Djdk.tracePinnedThreads=full MyApp

Рішення:

// Замінити synchronized на ReentrantLock
ReentrantLock lock = new ReentrantLock();
lock.lock();
try {
    blockingIO(); // Розмонтується — Carrier Thread вільний
} finally {
    lock.unlock();
}

ThreadLocal → Scoped Values

// ThreadLocal — проблема з VT
ThreadLocal<User> user = new ThreadLocal<>();
// Мільйон VT = мільйон копій User = Memory Problem!

// Scoped Values (Java 21 Preview) — рішення
static final ScopedValue<User> CURRENT_USER = new ScopedValue<>();

ScopedValue.runWhere(CURRENT_USER, user, () -> {
    process(); // CURRENT_USER.get() доступний
});
// Автоматично очищається, немає витоків

Діагностика

Memory Dumps

Віртуальні потоки виглядають як звичайні об’єкти у heap:

jmap -dump:file=heap.hprof <pid>
# VT = звичайні об'єкти — MAT, YourKit працюють

Latency Monitoring

// При переході на VT — P99 стабілізується
long start = System.nanoTime();
handleRequest();
long elapsed = System.nanoTime() - start;

// VT: P99 ~ стабільний
// Platform: P99 ~ зростає при навантаженні

-XX:+PrintAssembly

# Точки переходу (yield points) у VT:
call Continuation.yield
# JVM оптимізує ці переходи

Best Practices

  1. VT для I/O-bound — веб-сервери, API, проксі
  2. ReentrantLock замість synchronized — уникнення pinning
  3. Scoped Values замість ThreadLocal — для контексту
  4. Semaphore для обмеження ресурсів — замість обмеження пулу
  5. Не для CPU-bound — FixedThreadPool краще
  6. -Djdk.tracePinnedThreads=full — при розробці
  7. Моніторте P99 — має стабілізуватися
  8. Оновіть JDBC драйверы — старі використовують synchronized

Коли НЕ варто переходити на Virtual Threads

  • Ваш додаток вже стабільно працює з пулом потоків — не чиніть те, що не зламано. VT дають перевагу при I/O-bound навантаженні, не при CPU-bound
  • Команда не готова аудитувати залежності на synchronized — старі JDBC, HTTP-клієнти, SimpleDateFormat всередині synchronized. Без аудиту VT будуть “прилипати” і працювати гірше
  • Java нижче 21 — VT недоступні. Для Java 17- використовуйте реактивний підхід
  • Низька конкуренція (< 100 RPS) — при малому навантаженні різниця непомітна, а ризики міграції реальні
  • ThreadLocal-heavy додаток без альтернатив — мільйон VT = мільйон копій ThreadLocal. Scoped Values ще в preview

VT vs Platform Threads: коли різниця помітна

Метрика Platform Threads Virtual Threads Коли різниця видна
Пропускна здатність (I/O) 100-500 RPS 10,000-100,000 RPS При високій I/O навантаженні
P99 латентність Зростає з навантаженням Стабільна При перевантаженні (черга на потоки)
Пам’ять на 10K задач ~10 GB ~50-100 MB При масштабуванні
CPU-bound задачі Швидше Повільніше (оверхед) Ніколи не використовуйте VT для обчислень

🎯 Шпаргалка для інтерв’ю

Обов’язково знати:

  • VT перемикання контексту = ~10-50 CPU cycles (user-space, memcpy), Platform Thread = ~1000-10000 cycles (kernel mode, TLB flush)
  • Закон Літтла: L = λ × W — VT збільшують L (кількість паралельних задач) без падіння throughput
  • Пам’ять: Platform Thread ~1MB нативний стек, VT ~кілька KB динамічно у купі (StackChunk)
  • VT повертають модель Thread-per-Request — простий блокуючий код замість Reactive/Callback Hell
  • P99 латентність: Platform Threads зростає з навантаженням, VT залишається стабільним
  • Pinning — головна проблема: synchronized у VT блокує Carrier Thread

Часті уточнюючі запитання:

  • Чому Platform Thread context switch такий дорогий? — Вимагає переходу в kernel mode (syscall), TLB flush, перемикання таблиць сторінок
  • Що таке ефект VT на P99? — Platform Threads при перевантаженні: P99 зростає через чергу на потоки; VT: P99 стабільний, оскільки немає черги на створення потоків
  • Чому VT не дають виграш при низькій конкуренції? — При < 100 RPS різниця непомітна, а ризики міграції (аудит synchronized, оновлення драйверів) реальні
  • Чи можна використовувати VT і Platform Threads разом? — Так: VT для I/O-bound, Platform Threads (FixedThreadPool) для CPU-bound

Червоні прапорці (НЕ говорити):

  • ❌ “VT використовують менше пам’яті бо вони простіші” — VT дешевші бо стек у купі (динамічний), а не бо “простіші”
  • ❌ “VT замінюють ExecutorService” — VT — це інший тип виконавця, ExecutorService все ще використовується (Executors.newVirtualThreadPerTaskExecutor())
  • ❌ “Reactive програмування більше не потрібне” — VT замінюють реактивність для I/O-bound, але реактивний підхід все ще актуальний для streaming, backpressure
  • ❌ “VT автоматично розв’язують всі проблеми продуктивності” — VT допомагають лише при I/O-bound; при CPU-bound або pinning можуть погіршити

Пов’язані теми:

  • [[23. Що таке Virtual Threads в Java 21]]
  • [[25. Коли варто використовувати Virtual Threads]]
  • [[26. Що таке structured concurrency]]
  • [[27. В чому різниця між Thread та Runnable]]