Коли варто використовувати Virtual Threads?
Віртуальні потоки (VT) — це не заміна звичайним потокам, а спеціалізований інструмент для конкретних задач. Вони дають перевагу коли додаток проводить більшу частину часу в очік...
Junior рівень
Базове розуміння
Віртуальні потоки (VT) — це не заміна звичайним потокам, а спеціалізований інструмент для конкретних задач. Вони дають перевагу коли додаток проводить більшу частину часу в очікуванні (I/O): відповіді від БД, мікросервісів, читання з S3, HTTP-запити.
Чому VT допомагають саме при I/O: коли звичайний потік викликає блокуючу операцію (наприклад, socket.read()), він засинає і займає 1MB пам’яті ОС. Віртуальний потік при тій самій операції “розмонтується” — JVM зберігає його стек у купу (кілька KB) і віддає Carrier Thread іншому віртуальному потоку. Коли I/O завершується, VT “примонтується” назад і продовжить роботу.
Ідеальні сценарії для VT
1. I/O-bound навантаження (очікування)
Якщо додаток витрачає 90% часу на очікування (відповідь від БД, мікросервіса, читання з S3):
// VT — найкращий вибір
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (Request request : requests) {
executor.submit(() -> {
String data = httpClient.get(url); // Чекаємо мережу
Result result = db.query(data); // Чекаємо БД
s3.upload(result); // Чекаємо S3
return result;
});
}
}
// Поки один VT чекає — Carrier Thread обробляє інші VT
// Carrier Thread — це реальний потік ОС, на якому "сидять" десятки тисяч VT
2. Thread-per-request модель
// Простий і зрозумілий код замість реактивного
public void handleRequest(HttpServletRequest req, HttpServletResponse resp) {
String data = database.query(req.getParameter("id")); // Блокуючий виклик
String processed = externalApi.call(data); // Блокуючий виклик
resp.getWriter().write(processed); // Блокуючий виклик
}
// З VT: мільйон таких запитів обробляються паралельно!
Коли НЕЛЬЗЯ використовувати VT
1. CPU-bound задачі (обчислення)
// ПОГАНО: VT для обчислень
Thread.ofVirtual().start(() -> {
calculatePi(1_000_000); // Немає блокувань — VT не розмонтується
});
// Оверхед планувальника JVM без користі
// ГАРНО: FixedThreadPool
ExecutorService cpuPool = Executors.newFixedThreadPool(
Runtime.getRuntime().availableProcessors() + 1
);
cpuPool.submit(() -> calculatePi(1_000_000));
2. Обмеження ресурсів (Throttling)
// ПОГАНО: VT без обмеження до БД
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 1_000_000; i++) {
executor.submit(() -> {
database.execute("UPDATE ..."); // Мільйон запитів до БД одночасно!
});
}
}
// БД впаде!
// ГАРНО: Semaphore для обмеження
Semaphore dbSemaphore = new Semaphore(20); // Макс 20 підключень до БД
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 1_000_000; i++) {
executor.submit(() -> {
dbSemaphore.acquire();
try {
database.execute("UPDATE ...");
} finally {
dbSemaphore.release();
}
});
}
}
Middle рівень
Spring Boot 3.2+ з VT
# application.yml
spring:
threads:
virtual:
enabled: true
// Весь Tomcat/Undertow переходить на VT автоматично
@RestController
public class MyController {
@GetMapping("/user/{id}")
public User getUser(@PathVariable Long id) {
// Кожен запит — окремий VT
return userService.findById(id); // Блокуючий виклик — OK!
}
}
Проблема Pinning (Пригвождення)
Віртуальний потік “прилипає” до Carrier Thread і не розмонтується:
| Причина | Вплив | Рішення |
|---|---|---|
synchronized |
Carrier Thread заблокований | ReentrantLock |
| Native методи (JNI) | Carrier Thread заблокований | Окремий потік |
// ПОГАНО: synchronized у VT
public synchronized Data loadData() {
return httpClient.get(url); // VT "прилип" — Carrier Thread зайнятий
}
// ГАРНО: ReentrantLock
private final ReentrantLock lock = new ReentrantLock();
public Data loadData() {
lock.lock();
try {
return httpClient.get(url); // VT розмонтується — Carrier вільний
} finally {
lock.unlock();
}
}
ThreadLocal проблема
// Мільйон VT = мільйон копій ThreadLocal = Memory Problem!
ThreadLocal<Context> context = ThreadLocal.withInitial(Context::new);
// Рішення: Scoped Values (Java 21 Preview)
static final ScopedValue<Context> CONTEXT = new ScopedValue<>();
ScopedValue.runWhere(CONTEXT, new Context(), () -> {
process(); // CONTEXT.get() доступний
});
Аудит залежностей
Перед переходом на VT:
# Перевірте бібліотеки на synchronized блоки
java -Djdk.tracePinnedThreads=full -jar app.jar
# Шукайте у логах:
# "VirtualThread pinning" — вказує на проблемні місця
Типові проблемні бібліотеки:
- Старі JDBC драйвери (використовують
synchronized) java.text.SimpleDateFormat(всерединіsynchronized)- Деякі старі HTTP-клієнти
Senior рівень
Under the Hood: Чому Pinning відбувається
synchronized не підтримує unmount
VT входить у synchronized:
1. Захоплення монітора (як завжди)
2. Блокуюча операція всередині
3. JVM НЕ може розмонтувати — монітор треба тримати
4. VT "прилипає" до Carrier Thread
5. Carrier Thread зайнятий → інші VT чекають
Результат: commonPool забитий → всі parallel streams гальмують
Native методи
VT викликає native method:
1. Перехід у нативний код (JNI)
2. JVM не знає, коли нативний код завершиться
3. Не можна розмонтувати — стек у нативному коді
4. VT "прилипає" до повернення з native
Throttling через Semaphore
public class ThrottledExecutor {
private final Semaphore semaphore;
private final ExecutorService virtualExecutor;
public ThrottledExecutor(int maxConcurrent) {
this.semaphore = new Semaphore(maxConcurrent);
this.virtualExecutor = Executors.newVirtualThreadPerTaskExecutor();
}
public Future<?> submit(Runnable task) {
return virtualExecutor.submit(() -> {
semaphore.acquire();
try {
task.run();
} finally {
semaphore.release();
}
});
}
}
// Використання:
ThrottledExecutor dbExecutor = new ThrottledExecutor(20); // Макс 20 до БД
ThrottledExecutor apiExecutor = new ThrottledExecutor(100); // Макс 100 до API
Продуктивність та Benchmarks
Сценарій: 10,000 HTTP запитів (latency 100ms кожен)
Platform Threads (100 pool):
- Час: ~10 секунд
- Потоків: 100
- Пам'ять: ~100MB
Virtual Threads:
- Час: ~1 секунда
- Потоків: 10,000
- Пам'ять: ~50MB
- Carrier Threads: 8
Діагностика
-Djdk.tracePinnedThreads=full
# Виводить повний stack trace при кожному pinning
java -Djdk.tracePinnedThreads=full MyApp
# Вивід:
# VirtualThread pinning detected at:
# at java.net.SocketInputStream.socketRead0(Native Method)
# at com.example.MyClass.synchronizedMethod(MyClass.java:42)
JMX Monitoring
// Метрика: кількість пригвождених потоків
MBeanServer mbs = ManagementFactory.getPlatformMBeanServer();
ObjectName name = new ObjectName("jdk:type=VirtualThread");
Long pinned = (Long) mbs.getAttribute(name, "VirtualThreadPinned");
if (pinned > 0) {
log.warn("Pinned virtual threads detected: {}", pinned);
}
Thread Dumps
# jstack неефективний для мільйона потоків
jstack <pid> # Може зайняти хвилини
# Використовуйте:
jcmd <pid> Thread.dump_to_file -format=json threads.json
# JSON формат — швидший та ефективніший
Коли VT дають максимальний виграш
| Сценарій | Виграш |
|---|---|
| API Gateway (проксирування) | 10x-100x throughput |
| Web Scraper (багато HTTP запитів) | 5x-50x throughput |
| Мікросервіс з БД + HTTP викликами | 3x-10x throughput |
| Файловий процесор (I/O) | 5x-20x throughput |
Коли VT непотрібні або шкідливі
| Сценарій | Проблема | Рішення |
|---|---|---|
| Обчислення (ML, крипто) | Немає блокувань | FixedThreadPool |
| synchronized-heavy legacy | Pinning | ReentrantLock |
| Обмежені ресурси (БД) | Потрібен throttling | Semaphore |
| ThreadLocal-heavy | Пам’ять | Scoped Values |
Best Practices
- VT для I/O-bound — API-шлюз, веб-сервер, проксі
- ReentrantLock замість synchronized — уникнення pinning
- Semaphore для обмеження — замість обмеження пулу
- Scoped Values замість ThreadLocal — для контексту
- Аудит залежностей — перевірте бібліотеки на synchronized
- -Djdk.tracePinnedThreads=full — при розробці
- Оновіть JDBC/HTTP драйвери — VT-сумісні версії
- Не для CPU-bound — використовуйте FixedThreadPool для обчислень
- Моніторте VirtualThreadPinned — через JMX/JFR
- Spring Boot 3.2+ —
spring.threads.virtual.enabled=true
🎯 Шпаргалка для інтерв’ю
Обов’язково знати:
- VT ідеальні для I/O-bound: веб-сервери, API-шлюз, проксі, мікросервіси з викликами БД + HTTP
- VT НЕ підходять для CPU-bound (обчислення, ML, криптографія) — використовуйте FixedThreadPool(N+1)
- При роботі з обмеженими ресурсами (пул БД на 20 з’єднань) — VT + Semaphore
- Pinning: synchronized і JNI не дозволяють VT розмонтуватися; замінюйте synchronized на ReentrantLock
- ThreadLocal + мільйон VT = Memory Problem; альтернатива — Scoped Values (Java 21 Preview)
- Аудит залежностей обов’язковий: старі JDBC драйвери, SimpleDateFormat використовують synchronized
- Виграш: API Gateway 10-100x throughput, Web Scraper 5-50x, мікросервіс 3-10x
Часті уточнюючі запитання:
- Як обмежити кількість одночасних запитів до БД при використанні VT? — Semaphore з лімітом, обгорнутий у VT executor
- Чому Spring Boot 3.2+ спрощує міграцію на VT? —
spring.threads.virtual.enabled=trueпереводить весь Tomcat на VT автоматично, без зміни коду контролерів - Як перевірити, що бібліотека VT-сумісна? — Запустити з
-Djdk.tracePinnedThreads=fullі перевірити логи на “VirtualThread pinning” - Що робити якщо dependency використовує synchronized всередині? — Обгорнути виклик в окремий Platform Thread або замінити бібліотеку
Червоні прапорці (НЕ говорити):
- ❌ “VT завжди швидше — потрібно всюди використовувати” — VT повільніше для CPU-bound і додають оверхед при низькій конкуренції
- ❌ “Мільйон VT до БД без обмежень — нормально” — БД впаде, потрібен Semaphore
- ❌ “Міграція на VT безкоштовна” — потрібен аудит залежностей на synchronized, оновлення JDBC/HTTP драйверів
- ❌ “VT розв’язують проблему ThreadLocal” — VT погіршують проблему: мільйон VT = мільйон копій ThreadLocal
Пов’язані теми:
- [[23. Що таке Virtual Threads в Java 21]]
- [[24. В чому перевага Virtual Threads перед звичайними потоками]]
- [[26. Що таке structured concurrency]]
- [[20. Як запобігти deadlock]]