Virtual Threads

Virtual Threads (виртуальные потоки) — лёгкие потоки, которых можно создавать миллионами. Обычный поток (Thread) — тяжёлый поток ОС: создание стоит дорого, больше нескольких тысяч одновременно не запустишь. Virtual Thread создаётся почти бесплатно:

Virtual Threads (миллионы) VT #1 VT #2 VT #3 ForkJoinPool (carrier threads) N carrier потоков = число CPU ядер OS потоки (физические)

При блокировке VT снимается с carrier’а carrier берёт другой VT → нет простоя потоков ОС

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 Threads и чем они дешевле обычных;
• как блокирующий вызов внутри Virtual Thread не блокирует реальный поток ОС (если дошёл до 3-го слоя).