Virtual Threads
Virtual Threads (виртуальные потоки) — лёгкие потоки, которых можно создавать миллионами. Обычный поток (Thread) — тяжёлый поток ОС: создание стоит дорого, больше нескольких тысяч одновременно не запустишь. Virtual Thread создаётся почти бесплатно:
Thread.startVirtualThread(() -> {
System.out.println("Я виртуальный поток!");
});
// или через ExecutorService
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
executor.submit(() -> doWork());
}
Код пишется точно так же, как с обычными потоками — это и есть смысл фичи: не учить новый API, а просто получить дешёвые потоки.
Копнуть глубже
Зачем это вообще нужно. Раньше для масштабирования сервера с тысячами одновременных запросов использовали небольшой пул потоков ОС + асинхронный/реактивный код (CompletableFuture, реактивные стримы) — потому что создать поток ОС на каждый запрос было слишком дорого. Virtual Threads позволяют писать простой последовательный код (один поток на один запрос), но с производительностью, близкой к асинхронному подходу:
// один Virtual Thread на каждый HTTP-запрос — нормально, даже если их 100 000
executor.submit(() -> {
var data = fetchFromDatabase(); // поток "блокируется", но дёшево
var result = process(data);
sendResponse(result);
});
Главное правило: блокирующий вызов (поход в БД, сетевой запрос) внутри Virtual Thread — это нормально, в отличие от обычных потоков, где блокировка тысяч потоков ОС была бы катастрофой для памяти и планировщика.
Под капотом
Virtual Threads не привязаны 1-к-1 к потокам ОС — много виртуальных потоков по очереди используют небольшой пул реальных потоков ОС (carrier threads). Когда виртуальный поток блокируется (например, ждёт ответа от БД), JVM снимает его с потока-носителя и освобождает этот поток ОС для другого виртуального потока — а когда блокирующая операция завершается, виртуальный поток “подхватывается” любым свободным потоком-носителем и продолжает работу.
Именно поэтому тысячи “заблокированных” виртуальных потоков не блокируют реальные ресурсы ОС — в любой момент времени реально занято лишь несколько потоков-носителей, а остальные виртуальные потоки просто ждут в стороне, не потребляя память стека ОС (стек виртуального потока хранится в куче и растёт/уменьшается динамически).
Pinning — главная ловушка Virtual Threads. Виртуальный поток примёрзает к потоку-носителю и не освобождает его когда внутри встречается synchronized-блок или нативный метод. В этом случае блокирующая операция внутри synchronized держит реальный поток ОС занятым — полностью теряется преимущество виртуальных потоков:
// Проблема: synchronized удерживает carrier thread
synchronized (lock) {
var result = slowDatabaseCall(); // PINNING! carrier thread заблокирован
}
// Решение: использовать ReentrantLock вместо synchronized
ReentrantLock lock = new ReentrantLock();
lock.lock();
try {
var result = slowDatabaseCall(); // виртуальный поток освобождает carrier
} finally {
lock.unlock();
}
Для диагностики Pinning: -Djdk.tracePinnedThreads=full — JVM будет логировать каждый случай. Spring Boot 3.2+ и большинство современных библиотек уже адаптированы к Virtual Threads и избегают synchronized в горячих путях.
• как блокирующий вызов внутри Virtual Thread не блокирует реальный поток ОС (если дошёл до 3-го слоя).