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

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

Virtual Threads (виртуальные потоки) — это лёгкие потоки, управляемые JVM, а не операционной системой. Они стали стабильной фичей в Java 21 (LTS-версия), но разрабатывались в ра...

Версии по языкам: English Russian Ukrainian

Virtual Threads (виртуальные потоки) — это лёгкие потоки, управляемые JVM, а не операционной системой. Они стали стабильной фичей в Java 21 (LTS-версия), но разрабатывались в рамках Project Loom с 2017 года и проходили через несколько preview-релизов (Java 19-20).

Важная оговорка: хотя API стабилен в Java 21, некоторые связанные фичи (Scoped Values, Structured Concurrency) всё ещё в preview. Это значит, что в будущих версиях их API может измениться. Если вы используете только базовое Thread.ofVirtual(), это production-ready.


Junior уровень

Базовое понимание

Virtual Threads (виртуальные потоки) — это лёгкие потоки, управляемые JVM, а не операционной системой. Они позволяют создавать миллионы потоков вместо тысяч.

Почему обычные потоки дорогие: каждый Platform Thread резервирует ~1MB стека в памяти ОС при создании, даже если использует лишь несколько килобайт. Кроме того, переключение между потоками требует перехода в режим ядра (kernel mode), что занимает тысячи CPU-циклов.

Почему виртуальные потоки дешёвые: их стек хранится в обычной куче JVM (heap) и растёт динамически — от нескольких килобайт. Переключение происходит в user-space без системных вызовов, что в 100-1000 раз быстрее.

Простая аналогия

  • Platform Threads (обычные): как такси — каждое требует машину (1MB памяти)
  • Virtual Threads: как маршрутка — много пассажиров на одном транспорте

Создание и использование

// Platform Thread (старый способ)
Thread platform = new Thread(() -> {
    System.out.println("Platform thread");
});
platform.start();

// Virtual Thread (Java 21+)
Thread virtual = Thread.ofVirtual().start(() -> {
    System.out.println("Virtual thread");
});

// Или через билдер
Thread vThread = Thread.startVirtualThread(() -> {
    System.out.println("Virtual thread");
});

Сравнение

Характеристика Platform Threads Virtual Threads
Кем управляется ОС (Kernel) JVM (Runtime)
Размер стека ~1 MB (статический) Несколько KB (динамический)
Время создания ~1-10 ms ~микросекунды
Максимум Тысячи Миллионы

Пример: Thread-per-request

// С виртуальными потоками — простая модель снова работает!
ServerSocket server = new ServerSocket(8080);

while (true) {
    Socket client = server.accept();
    Thread.ofVirtual().start(() -> {
        handleRequest(client); // Один виртуальный поток на запрос
    });
}
// Миллионы соединений — без реактивного программирования!

Middle уровень

Архитектура: Continuations

В основе виртуальных потоков лежит концепция Continuations (продолжений):

1. Виртуальный поток выполняет код на Carrier Thread (реальный поток ОС)
2. При блокирующей операции (sleep, I/O):
   a. JVM сохраняет стек виртуального потока в кучу (Heap)
   b. Поток "отлепляется" (unmount) от Carrier Thread
   c. Carrier Thread идёт выполнять другой виртуальный поток
3. Когда I/O завершается:
   a. JVM восстанавливает стек из кучи
   b. Поток "прилепляется" (mount) к свободному Carrier Thread
   c. Продолжает выполнение с места остановки

Carrier Thread Pool

// Виртуальные потоки делят реальные потоки (Carrier Threads)
// По умолчанию: ForkJoinPool с размером = число ядер CPU

// Пример: 1,000,000 виртуальных потоков на 8 Carrier Threads
// Каждый Carrier Thread обрабатывает ~125,000 виртуальных потоков
// по очереди, когда они размонтируются

Механизм Stack Chunking

JVM хранит стек виртуального потока в виде объектов StackChunk в куче:

Виртуальный поток:
┌────────────────────────┐
│ StackChunk #1 (в куче) │ ← Текущий стек
│ StackChunk #2 (в куче) │ ← Предыдущий фрейм
│ StackChunk #3 (в куче) │ ← Ещё глубже
└────────────────────────┘

При монтировании данные копируются из кучи в реальный стек процессора, при размонтировании — обратно.

Создание пула виртуальных потоков

// ExecutorService для виртуальных потоков
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    for (int i = 0; i < 1_000_000; i++) {
        executor.submit(() -> {
            // Обрабатываем задачу
            Thread.sleep(1000); // Размонтируется — не блокирует Carrier
        });
    }
}
// Миллион задач — и JVM не упала!

Senior уровень

Under the Hood: Pinning (Пригвождение)

Это главная ловушка виртуальных потоков. Виртуальный поток может “прилипнуть” к Carrier Thread и не размонтироваться при блокировке:

Причины Pinning

Причина Описание Решение
synchronized блок/метод Виртуальный поток в synchronized НЕ размонтируется Заменить на ReentrantLock
Native методы (JNI) Вызовы нативного кода не поддерживают unmount Обернуть в отдельный поток
// ПЛОХО: synchronized блокирует Carrier Thread
synchronized(lock) {
    Thread.sleep(10000); // Виртуальный поток "прилип" к носителю на 10 секунд!
}

// ХОРОШО: ReentrantLock поддерживает unmount
ReentrantLock lock = new ReentrantLock();
lock.lock();
try {
    Thread.sleep(10000); // Размонтируется — Carrier Thread свободен
} finally {
    lock.unlock();
}

Диагностика Pinning

# Обязательный флаг при разработке с Virtual Threads
java -Djdk.tracePinnedThreads=full MyApp

# Выведет stack trace каждый раз, когда поток "прилипает"

ThreadLocal и Virtual Threads

// ОСТОРОЖНО: миллион VT = миллион копий ThreadLocal!
ThreadLocal<ExpensiveObject> local = ThreadLocal.withInitial(ExpensiveObject::new);

// Решение: Scoped Values (Java 21 Preview)
static final ScopedValue<UserContext> CONTEXT = new ScopedValue<>();

ScopedValue.runWhere(CONTEXT, new UserContext("user123"), () -> {
    // CONTEXT.get() доступен внутри
});
// После выхода — автоматически очищается, нет утечек

Производительность и Highload

Thread-per-request возвращается

До VT:
  1000 platform threads max → Reactive/CompletableFuture → Callback Hell

С VT:
  1,000,000 virtual threads → Thread-per-request → Простой блокирующий код

Spring Boot 3.2+

# application.yml
spring:
  threads:
    virtual:
      enabled: true
# Весь Tomcat/Undertow переходит на виртуальные потоки!

Carrier Thread Pool sizing

// По умолчанию: ForkJoinPool.commonPool()
// Размер = число логических ядер CPU

// Кастомизация:
System.setProperty("java.util.concurrent.ForkJoinPool.common.parallelism", "16");

Диагностика

JFR Events

java -XX:StartFlightRecording=filename=rec.jfr MyApp

События:

  • jdk.VirtualThreadStart / jdk.VirtualThreadEnd
  • jdk.VirtualThreadPinned — критично для мониторинга

jcmd для дампов

# jstack может "обалдеть" от миллиона потоков
jcmd <pid> Thread.dump_to_file -format=json threads.json
# JSON формат эффективнее для анализа

Memory Analysis

Миллион виртуальных потоков = миллион объектов в куче
Каждый VT ~ нескольких KB (стек) + объекты

Проверьте -Xmx перед запуском!
Рекомендуется: минимум 256MB для миллиона VT

Когда VT НЕ дают преимущества

Сценарий Почему Альтернатива
CPU-bound задачи VT не размонтируются, оверхед планировщика FixedThreadPool (N+1)
synchronized-heavy code Pinning — блокируют Carrier Threads ReentrantLock
Ограничение ресурсов Миллион VT положит БД Semaphore

Best Practices

  1. Используйте VT для I/O-bound — веб-серверы, API-шлюзы, прокси
  2. Заменяйте synchronized на ReentrantLock — избежание pinning
  3. Используйте Semaphore — для ограничения доступа к дефицитным ресурсам
  4. Осторожно с ThreadLocal — миллион копий = много памяти
  5. Scoped Values — для передачи контекста (Java 21 Preview)
  6. -Djdk.tracePinnedThreads=full — при разработке
  7. Не используйте VT для CPU-bound — FixedThreadPool лучше
  8. Мониторьте jdk.VirtualThreadPinned — через JFR

Когда НЕ использовать Virtual Threads

  • Java ниже 21 — VT недоступны. Для Java 17 используйте реактивный стек (WebFlux, Completable- Future)
  • CPU-bound задачи (вычисления, ML, криптография) — VT не размонтируются при вычислениях, только добавляют оверхед планировщика. Используйте Executors.newFixedThreadPool(N) где N = число ядер + 1
  • synchronized-heavy legacy-код — pinning заблокирует Carrier Threads. Сначала рефакторите на ReentrantLock
  • Ограниченные ресурсы (пул БД на 20 соединений) — миллион VT положит БД. Используйте Semaphore
  • ThreadLocal-heavy приложения — миллион VT = миллион копий ThreadLocal = OutOfMemoryError. Переходите на Scoped Values

Virtual Threads vs Platform Threads vs ExecutorService: что выбрать?

Ситуация Выбор Почему
Веб-сервер, API-шлюз (I/O-bound) Virtual Threads Миллионы одновременных соединений, простой код
Вычисления (ML, парсинг) FixedThreadPool VT не размонтируются, оверхед без пользы
Legacy с synchronized FixedThreadPool + рефакторинг Pinning VT убьёт производительность
Ограниченный ресурс (БД, API лимит) VT + Semaphore VT масштабируются, Semaphore ограничивает

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

Обязательно знать:

  • Virtual Threads (VT) — лёгкие потоки, управляемые JVM, а не ОС; стек в куче (динамически), а не нативный стек (статический 1MB)
  • При блокирующей I/O VT “размонтируется” — JVM сохраняет стек в StackChunk и отдаёт Carrier Thread другому VT
  • Carrier Thread Pool по умолчанию = ForkJoinPool с размером = число ядер CPU
  • Pinning: synchronized и native методы не позволяют VT размонтироваться — Carrier Thread заблокирован; решение: ReentrantLock
  • ThreadLocal + миллион VT = OutOfMemoryError; альтернатива: Scoped Values (Java 21 Preview)
  • VT для I/O-bound (веб-серверы, API), НЕ для CPU-bound (вычисления)
  • Spring Boot 3.2+: spring.threads.virtual.enabled=true

Частые уточняющие вопросы:

  • Почему VT не подходят для CPU-bound задач? — VT не размонтируются при вычислениях, только добавляют оверхед планировщика JVM
  • Что такое StackChunk? — Объект в куче, хранящий стек виртуального потока; при монтировании копируется в реальный стек процессора
  • Как диагностировать pinning? — Флаг -Djdk.tracePinnedThreads=full выводит stack trace при каждом случае пригвождения
  • Сколько VT можно создать? — Миллионы; ограничение — heap (рекомендуется минимум 256MB для миллиона VT)

Красные флаги (НЕ говорить):

  • ❌ “Virtual Threads — это то же самое, что threads в Go” — VT похожи на goroutines, но работают на JVM с Carrier Threads
  • ❌ “VT всегда быстрее Platform Threads” — VT быстрее только для I/O-bound; для CPU-bound VT медленнее из-за оверхеда
  • ❌ “Можно просто заменить все synchronized на VT без изменений” — synchronized вызывает pinning, нужно заменить на ReentrantLock
  • ❌ “VT несовместимы с ExecutorService” — Executors.newVirtualThreadPerTaskExecutor() — стандартный способ работы с VT

Связанные темы:

  • [[24. В чём преимущества Virtual Threads перед обычными потоками]]
  • [[25. Когда стоит использовать Virtual Threads]]
  • [[26. Что такое structured concurrency]]
  • [[27. В чём разница между Thread и Runnable]]