Когда стоит использовать 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 |
| Ограниченные ресурсы (БД) | Need throttling | Semaphore |
| ThreadLocal-heavy | Memory | Scoped Values |
Best Practices
- VT для I/O-bound — API-шлюз, веб-сервер, прокси
- ReentrantLock вместо synchronized — избежание pinning
- Semaphore для ограничения — вместо ограничения пула
- Scoped Values вместо ThreadLocal — для контекста
- Аудит зависимостей — проверьте библиотеки на synchronized
- -Djdk.tracePinnedThreads=full — при разработке
- Обновите JDBC/HTTP драйверы — VT-compatible версии
- Не для 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-compatible? — Запустить с
-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]]