В чому перевага Virtual Threads перед звичайними потоками?
У цьому файлі ми розбираємо чому віртуальні потоки ефективніші за звичайні, а не просто констатуємо факт. Ключова відмінність: Platform Threads — це обгортка над потоком ОС (дор...
У цьому файлі ми розбираємо чому віртуальні потоки ефективніші за звичайні, а не просто констатуємо факт. Ключова відмінність: 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
- VT для I/O-bound — веб-сервери, API, проксі
- ReentrantLock замість synchronized — уникнення pinning
- Scoped Values замість ThreadLocal — для контексту
- Semaphore для обмеження ресурсів — замість обмеження пулу
- Не для CPU-bound — FixedThreadPool краще
- -Djdk.tracePinnedThreads=full — при розробці
- Моніторте P99 — має стабілізуватися
- Оновіть 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]]