🧵 Раздел 9 · Вопрос #22

Что такое Virtual Threads в Java 21

Виртуальные потоки радикально удешевляют создание и удержание потоков: их стек хранится в обычной куче (Heap) и динамически растет от сотен байт (вместо фиксированного резервиро...


🟢 Junior Level

Краткий ответ для собеседования (30 секунд)

Virtual Threads (виртуальные потоки) — это легковесные потоки, представленные в качестве стабильной функциональности в Java 21 (JEP 444) в рамках проекта Project Loom. В отличие от традиционных потоков платформы (Platform Threads), которые маппятся 1:1 на потоки ядра операционной системы (OS Threads), виртуальные потоки управляются самой виртуальной машиной (JVM) и реализуют модель M:N (миллионы виртуальных потоков мультиплексируются на небольшой пул потоков операционной системы — Carrier Threads).

Виртуальные потоки радикально удешевляют создание и удержание потоков: их стек хранится в обычной куче (Heap) и динамически растет от сотен байт (вместо фиксированного резервирования ~1 МБ нативного стека для каждого Platform Thread). Это возвращает в Java простую синхронную модель «один поток на задачу» (Thread-per-request) при пропускной способности, сравнимой с асинхронными реактивными фреймворками (Netty, WebFlux), но без усложнения кодовой базы и callback hell.


Сравнительная таблица: Platform Threads vs Virtual Threads

Характеристика Platform Threads (Обычные потоки) Virtual Threads (Виртуальные потоки)
Управление Ядро ОС (OS Kernel Scheduler) Среда исполнения JVM (User-space Runtime)
Маппинг на потоки ОС 1:1 (Один поток Java = один поток ОС) M:N (Миллионы VT делят N Carrier потоков)
Размер стека Фиксированный (~1 МБ нативной памяти) Динамический в Heap (от сотен байт до килобайт)
Переключение контекста На уровне ядра (Syscall, CPU Context Switch, ~1–10 мкс) На уровне JVM (User-space jump, ~десятки наносекунд)
Максимальное число Тысячи (ограничено памятью ОС и ulimit) Миллионы (ограничено только объемом Heap)
Пулинг (ThreadPool) Обязателен (создание потока крайне дорогое) Антипаттерн (потоки создаются и уничтожаются «на лету»)
Целевой профиль задач Смешанные, включая CPU-bound Исключительно I/O-bound (ожидание сети/диска/БД)

Создание и запуск в Java 21

В Java 21 класс java.lang.Thread получил фабричные методы и билдер для создания виртуальных потоков:

import java.util.concurrent.Executors;

public class VirtualThreadBasicsDemo {
    public static void main(String[] args) throws Exception {
        // 1. Быстрый запуск через Thread.startVirtualThread
        Thread vThread1 = Thread.startVirtualThread(() -> {
            System.out.println("Запущен виртуальный поток: " + Thread.currentThread());
        });
        vThread1.join();

        // 2. Создание через Fluent Builder Thread.ofVirtual()
        Thread vThread2 = Thread.ofVirtual()
                .name("user-request-handler-", 1)
                .inheritInheritableThreadLocals(false)
                .start(() -> {
                    System.out.println("Работает именованный поток: " + Thread.currentThread());
                });
        vThread2.join();

        // 3. Стандартный способ в продакшене — ExecutorService на каждую задачу
        // Создает новый Virtual Thread для каждого переданного Runnable/Callable
        try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
            for (int i = 0; i < 10_000; i++) {
                final int taskId = i;
                executor.submit(() -> {
                    // Имитация блокирующего сетевого I/O
                    Thread.sleep(100);
                    return "Task " + taskId + " complete";
                });
            }
        } // try-with-resources автоматически ждет завершения всех задач (AutoCloseable)!
    }
}

🟡 Middle Level

Архитектура под капотом: Continuations и Carrier Threads

В основе реализации виртуальных потоков лежит низкоуровневый механизм прерываемых вычислений — Delimited Continuations (jdk.internal.vm.Continuation):

Виртуальные потоки (User Space):
[ VT 1 (Blocked I/O) ]      [ VT 2 (Running) ]      [ VT 3 (Runnable) ]
         │                           │
   (Unmounted в Heap)         (Mounted)
                                     ▼
Несущие потоки (Carrier Threads / ForkJoinPool):
                    [ Carrier Worker Thread 1 (OS Thread) ]
  1. Carrier Threads (Потоки-носители):
    • Виртуальный поток не может выполняться «в вакууме» — для выполнения машинных инструкций CPU ему необходим реальный поток ОС.
    • По умолчанию виртуальные потоки исполняются на специальном пуле ForkJoinPool (рабочие потоки которого называются Carrier Threads).
    • Размер этого пула по умолчанию равен количеству доступных ядер процессора (Runtime.getRuntime().availableProcessors()), настраивается флагом -Djdk.virtualThreadScheduler.parallelism=N.
    • В отличие от классического пула, планировщик виртуальных потоков работает в режиме FIFO, а не LIFO, предотвращая голодание задач.
  2. Монтирование (Mounting) и Размонтирование (Unmounting):
    • Mounting: Когда виртуальный поток готов к исполнению, JVM привязывает его к свободному Carrier Thread. Фреймы стека виртуального потока копируются из кучи в нативный стек несущего потока.
    • Unmounting: При вызове любой стандартной блокирующей операции в Java (например, чтение из SocketInputStream, вызов Thread.sleep(), ожидание на CompletableFuture или LockSupport.park()):
      1. JVM перехватывает блокировку в нативном слое;
      2. Текущее состояние стека (stack frames) виртуального потока сериализуется и сохраняется в куче в виде специальных объектов StackChunk;
      3. Виртуальный поток «отцепляется» (unmounts) от несущего потока;
      4. Carrier Thread становится абсолютно свободным и немедленно берет на исполнение другой виртуальный поток.
    • Wakeup & Reschedule: Когда ОС сообщает о готовности сетевых данных (через системные механизмы мультиплексирования epoll в Linux, kqueue в macOS или IOCP в Windows), внутренний системный поллер (Poller) JVM будит виртуальный поток, переводя его в статус Runnable, и планировщик монтирует его на любой свободный Carrier Thread (не обязательно тот же самый).

Поведение блокирующего I/O: Сеть vs Файлы

Практически все блокирующие операции JDK были переписаны в Java 21 под механизм размонтирования:

// Пример: чтение данных из сокета
try (Socket socket = new Socket("api.internal.service", 8080);
     InputStream in = socket.getInputStream()) {
    
    byte[] buffer = new byte[1024];
    // В ОБЫЧНОМ ПОТОКЕ: поток ОС заблокирован ядром, стек занимает 1 МБ, CPU ждет.
    // В VIRTUAL THREAD: поток размонтируется! Carrier свободен для других запросов!
    int bytesRead = in.read(buffer); 
}

[!NOTE] Блокирующий файловый ввод-вывод (FileChannel, FileInputStream):
В операционных системах Linux стандартный дисковый ввод-вывод не всегда поддерживает неблокирующий epoll. Поэтому при операциях с файлами через традиционный java.io виртуальный поток может оставаться привязанным к Carrier Thread, а ForkJoinPool компенсирует это временным спавном дополнительного несущего потока (параметр maxPoolSize). Для полностью неблокирующего I/O рекомендуется использовать java.nio.channels.AsynchronousFileChannel.


🔴 Senior Level

Under the Hood: Carrier Pinning (Пригвождение потока)

Самая опасная проблема при переходе на виртуальные потоки — Pinning (пригвождение). Это состояние, при котором виртуальный поток входит в блокирующую операцию, но не может размонтироваться и намертво блокирует подлежащий поток ОС (Carrier Thread).

2 главные причины Pinning:

  1. Исполнение блокирующей операции внутри блока/метода synchronized: Внутри JVM HotSpot механизм synchronized привязан к структуре ObjectMonitor в памяти C++, которая исторически жестко связывается с нативным дескриптором потока ОС (pthread_t). Если виртуальный поток встречает блокирующий I/O или Thread.sleep() внутри synchronized, он не может освободить стек.
  2. Исполнение нативного кода (JNI) или Foreign Function Calls (Project Panama): Нативный стек Си/Ассемблера находится вне контроля сборщика мусора и механизма Continuation JVM, поэтому размонтирование физически невозможно.
public class PinningProblemDemo {
    private final Object monitorLock = new Object();
    private final java.util.concurrent.locks.ReentrantLock reentrantLock = new java.util.concurrent.locks.ReentrantLock();

    // ❌ КАТАСТРОФА ДЛЯ VIRTUAL THREADS (Приводит к Pinning):
    public void badProcessing() {
        synchronized (monitorLock) {
            // Пригвождает Carrier Thread к этому виртуальному потоку на 5 секунд!
            // Если все 8-16 Carrier Threads будут пригвождены, всё приложение зависнет!
            callExternalHttpService(); 
        }
    }

    // ✅ ИДЕАЛЬНО ДЛЯ VIRTUAL THREADS:
    public void goodProcessing() {
        reentrantLock.lock();
        try {
            // ReentrantLock построен на LockSupport.park() и AQS.
            // Он корректно размонтирует виртуальный поток, освобождая Carrier Thread!
            callExternalHttpService();
        } finally {
            reentrantLock.unlock();
        }
    }

    private void callExternalHttpService() {
        try { Thread.sleep(5000); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
    }
}

Диагностика и мониторинг Pinning в продакшене:

Чтобы обнаружить пригвождение потоков до падения системы, используйте JVM-флаг:

# Выводит стектрейс при каждом случае пригвождения виртуального потока:
java -Djdk.tracePinnedThreads=full -jar application.jar

В production мониторинге отслеживайте JFR-событие: jdk.VirtualThreadPinned.


Проблема ThreadLocal и альтернатива: ScopedValue

Традиционно в микросервисах контекст запроса (Trace ID, Security Context, User ID) сохранялся в ThreadLocal:

public static final ThreadLocal<RequestContext> CONTEXT = new ThreadLocal<>();

Почему в эпоху Virtual Threads это проблема:

  1. Если приложение создает 1 000 000 виртуальных потоков, создается 1 000 000 хеш-таблиц ThreadLocalMap. Если объект контекста «весит» 50 КБ, это потребует 50 ГБ оперативной памяти, приведя к мгновенному OutOfMemoryError: Java heap space.
  2. В Java 21 представлен механизм Scoped Values (JEP 446):
    • Значения неизменяемы (Immutable);
    • Автоматически очищаются сборщиком мусора при выходе из скоупа;
    • Передаются дочерним виртуальным потокам по ссылке без копирования таблиц.
import java.lang.ScopedValue;

public class ScopedValueDemo {
    public static final ScopedValue<String> TRACE_ID = ScopedValue.newInstance();

    public void handleRequest() {
        // Привязываем контекст к лексическому блоку:
        ScopedValue.where(TRACE_ID, "req-xyz-9876").run(() -> {
            processBusinessLogic();
        });
        // Вне блока TRACE_ID автоматически недоступен и очищен!
    }

    private void processBusinessLogic() {
        System.out.println("Processing with trace: " + TRACE_ID.get());
    }
}

Планирование и невытесняющая многозадачность (Cooperative Scheduling)

Критически важная деталь архитектуры HotSpot: виртуальные потоки не обладают аппаратным квантованием времени (Time-Slicing Preemption).

  • Переключение между виртуальными потоками происходит кооперативно — только в точках прерывания (I/O, парковка замка, sleep, yield).
  • Если виртуальный поток выполняет бесконечный тяжелый цикл без обращений к I/O или Thread.yield():
    Thread.startVirtualThread(() -> {
        while (true) {
            // Чистый CPU-bound цикл без блокировок!
        }
    });
    

    Этот поток намертво захватит Carrier Thread и никогда не освободит его. Если запустить таких потоков по числу ядер процессора, планировщик JVM полностью перестанет обрабатывать другие виртуальные потоки.


Диагностика в Production: Отладка миллионов потоков

Классическая утилита jstack <pid> непригодна для виртуальных потоков: форматирование стеков миллиона потоков приведет к генерации гигабайтного текстового вывода и длительной паузе приложения.

Рекомендуемый инструментарий в Java 21:

# Генерация компактного JSON-дампа потоков через jcmd:
jcmd <PID> Thread.dump_to_file -format=json thread_dump.json

Дамп в формате JSON группирует виртуальные потоки по точкам ожидания, уменьшая размер файла на порядки.


🎯 Шпаргалка для интервью

1. 30-секундный эссенс (Elevator Pitch)

Virtual Threads (Project Loom, Java 21) — это управляемые JVM легковесные потоки, чей стек динамически хранится в Heap, а исполнение мультиплексируется на малый пул потоков ОС (Carrier Threads на базе ForkJoinPool). При любой блокирующей операции ввода-вывода виртуальный поток автоматически размонтируется (unmount), сохраняет стек в StackChunk и отдает Carrier Thread другим задачам. Это дает пропускную способность реактивного программирования при сохранении синхронного стиля кода. Главные опасности: пригвождение потоков (Pinning) из-за synchronized/JNI, чрезмерное потребление памяти через ThreadLocal и неприменимость для CPU-bound вычислений.


2. 4 каверзных вопроса с глубокими ответами

Вопрос 1: Почему ключевое слово synchronized вызывает пригвождение (pinning) виртуального потока, а ReentrantLock — нет?

Ответ: Механизм synchronized реализован на уровне ядра JVM HotSpot через нативную Си++ структуру ObjectMonitor. Исторически дескриптор монитора жестко привязывается к нативному идентификатору потока ОС (pthread_t), и JVM не умеет безопасно копировать состояние монитора между потоками ядра при размонтировании. В результате Carrier Thread вынужден блокироваться вместе с виртуальным потоком.
В отличие от synchronized, java.util.concurrent.locks.ReentrantLock написан на чистом Java-коде поверх AbstractQueuedSynchronizer (AQS) и примитива LockSupport.park(). При вызове park() JVM перехватывает операцию и нативно вызывает Continuation.yield(), корректно размонтируя виртуальный поток.

Вопрос 2: Что произойдет, если пул виртуальных потоков задействовать для тяжелых CPU-bound вычислений (криптография, расчет матриц)?

Ответ: Производительность упадет по сравнению с классическим Executors.newFixedThreadPool(N). Виртуальные потоки переключаются кооперативно только в точках блокировки (I/O, locks, sleep). В чисто CPU-bound задачах точек прерывания нет: виртуальный поток монополизирует Carrier Thread и работает как обычный поток. Однако накладные расходы на управление миллионом объектов в куче, работу планировщика JVM и периодические сборки мусора добавят оверхед по памяти и CPU, не дав никакого прироста пропускной способности.

Вопрос 3: Почему пул виртуальных потоков (newFixedThreadPool с фабрикой Virtual Threads) считается грубой архитектурной ошибкой?

Ответ: Пулы потоков (ThreadPoolExecutor) были придуманы для Platform Threads, потому что создание потока ОС требовало ~1 МБ памяти и тысяч микросекунд на системный вызов ядра. Виртуальный поток весит несколько сотен байт и создается за микросекунды как обычный легковесный Java-объект. Ограничивать виртуальные потоки пулом — значит терять их главное преимущество (модель Thread-Per-Task). Если необходимо ограничить конкурентный доступ к дефицитному внешнему ресурсу (например, пул из 20 соединений к реляционной БД), нужно использовать java.util.concurrent.Semaphore, а не пулинг потоков.

Вопрос 4: Как виртуальные потоки взаимодействуют с моделью памяти Java (JMM) и правилом Happens-Before?

Ответ: С точки зрения спецификации JMM (JLS Chapter 17) виртуальный поток является полноценным экземпляром java.lang.Thread. Все гарантии Happens-Before (видимость изменений через volatile, освобождение/захват блокировок Lock, вход/выход из синхронизации, запуск Thread.start() и завершение Thread.join()) действуют для виртуальных потоков абсолютно идентично. Перенос стека между разными Carrier Threads гарантирует полную синхронизацию регистров CPU и кэшей ядер через внутренние барьеры памяти JVM при монтировании и размонтировании.


3. Типичные ошибки и Red Flags

❌ “Виртуальные потоки всегда быстрее обычных потоков.”
Правильно: Виртуальный поток исполняет код с той же скоростью (или чуть медленнее из-за проверок Continuation). Они увеличивают не скорость отдельной операции (latency), а суммарную пропускную способность (throughput) системы при тысячах параллельных I/O операций.

❌ “Пулинг виртуальных потоков для оптимизации памяти.”
Правильно: Виртуальные потоки нельзя пулить. Для них применяется Executors.newVirtualThreadPerTaskExecutor(), где поток уничтожается сразу после завершения задачи.

❌ “Использование synchronized поверх сетевых или внешних HTTP-вызовов.”
Правильно: В коде с виртуальными потоками любой блокирующий ввод-вывод внутри synchronized вызывает Carrier Pinning. Следует использовать ReentrantLock.

❌ “Массивное сохранение контекста в ThreadLocal.”
Правильно: Миллион потоков приведет к утечке памяти в ThreadLocalMap. Необходимо минимизировать размер контекста или переходить на ScopedValue.


4. Связанные вопросы