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

В чём преимущества Virtual Threads перед обычными потоками?

В этом файле мы разбираем почему виртуальные потоки эффективнее обычных, а не просто констатируем факт. Ключевое различие: Platform Threads — это обёртка над потоком ОС (дорогой...

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

В этом файле мы разбираем почему виртуальные потоки эффективнее обычных, а не просто констатируем факт. Ключевое различие: Platform Threads — это обёртка над потоком ОС (дорогой, фиксированный размер), а Virtual Threads — объект в куче JVM (дешёвый, динамический размер).


Junior уровень

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

Виртуальные потоки (VT) превосходят обычные (Platform Threads) по нескольким ключевым параметрам.

1. Масштабируемость

Характеристика Platform Threads Virtual Threads
Максимум потоков ~2,000-5,000 ~1,000,000+
Ограничение Память ОС (1MB на поток) Heap JVM
Пример 1000 потоков = ~1GB 1,000,000 потоков = ~256MB

2. Экономия памяти

Platform Thread:  ~1MB стека (резервируется сразу)
Virtual Thread:   ~несколько KB (динамически в куче)

3. Простота кода

До VT (реактивный стиль):

// Callback Hell — сложно читать и отлаживать
httpClient.get(url)
    .thenApply(this::parse)
    .thenCompose(data -> db.save(data))
    .thenAccept(result -> respond(result))
    .exceptionally(ex -> handleError(ex));

С VT (простой блокирующий стиль):

// Простой последовательный код
String response = httpClient.get(url);  // "Блокируется" (размонтируется)
Data data = parse(response);
Result result = db.save(data);
respond(result);

Middle уровень

Теорема Литтла (Little’s Law)

L = λ × W

L = количество активных задач (потоков)
λ = пропускная способность (requests per second)
W = время обработки одного запроса (latency)

Проблема Platform Threads:

  • L ограничено ~2000-5000 потоками
  • Если W (латентность) растёт → λ (throughput) неизбежно падает

Решение Virtual Threads:

  • L может быть миллионами
  • Даже при медленных I/O ответах throughput остаётся высоким

Эффективность CPU

Platform Thread Context Switch

1. Переход в режим ядра (Kernel Mode) — syscall
2. Сохранение регистров процессора в TSS (Task State Segment)
3. Смена таблиц страниц памяти (TLB flush)
4. Загрузка нового потока
5. Возврат в пользовательский режим

Время: ~1-10 микросекунд (~1000-10000 CPU cycles)
Почему дорого: каждый шаг требует участия ОС и CPU

Virtual Thread Context Switch

1. Сохранение стека в StackChunk (куча) — обычная memcpy
2. Смена указателя на другой VT в очереди JVM
3. Восстановление стека из кучи — обычная memcpy

Время: ~10-50 CPU cycles (наносекунды)
Почему дёшево: всё в user-space, без системных вызовов

Почему VT переключение в 100-1000 раз быстрее: Platform Thread switch требует перехода в kernel mode (syscall), что включает TLB flush и переключение таблиц страниц. VT switch — это просто копирование данных в куче, что CPU делает на скорости работы с памятью.

Упрощение архитектуры

Подход Сложность Отладка VT нужен?
Thread-per-request Низкая Простая Да — VT возвращают этот подход
Thread Pool Средняя Средняя Нет — пулы работают и без VT
Reactive (WebFlux) Высокая Сложная Нет — VT заменяют реактивность
CompletableFuture Средняя Средняя Нет — но VT упрощают

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

Сценарий Почему Решение
CPU-bound задачи VT не размонтируются, оверхед планировщика FixedThreadPool
synchronized-heavy Pinning — блокируют Carrier Threads ReentrantLock
Старые JDBC драйверы Внутри synchronized Обновить драйвер

Senior уровень

Under the Hood: Минимизация переключений контекста ОС

Platform Thread Switch

Thread A (User Mode)
    ↓ syscall
Kernel Mode:
    - Save A's registers to TSS
    - Update page tables
    - Load B's registers from TSS
    - Update GDT/LDT
    ↓ iret
Thread B (User Mode)

~1000+ CPU cycles

Virtual Thread Switch

VT A on Carrier Thread 1
    ↓ yield (I/O блокировка)
    - Сохранить стек в StackChunk (куча)
    - Освободить Carrier Thread 1
    ↓
Carrier Thread 1 берёт VT B из очереди
    - Восстановить стек VT B из StackChunk
    ↓
VT B продолжает выполнение

~10-50 CPU cycles

Memory: Heap vs Native Stack

Platform Threads:
┌─────────────────────┐
│  JVM Heap           │
│  (objects, etc.)    │
├─────────────────────┤
│  Native Memory      │
│  Thread Stack: 1MB  │ ← Вне кучи (RSS)
│  Thread Stack: 1MB  │
│  Thread Stack: 1MB  │
│  ...                │
└─────────────────────┘

Virtual Threads:
┌─────────────────────┐
│  JVM Heap           │
│  StackChunk #1: 4KB │
│  StackChunk #2: 8KB │
│  StackChunk #3: 2KB │
│  ... (динамически)  │
├─────────────────────┤
│  Native Memory      │
│  8 Carrier Threads  │ ← Только 8 стеков по 1MB
└─────────────────────┘

Латентность и P99

Platform Threads при перегрузке:
  P50: 10ms
  P99: 500ms  ← Очередь на захват системных потоков

Virtual Threads:
  P50: 10ms
  P99: 15ms   ← Стабильно, нет очереди на потоки

Pinning — главная проблема

// Виртуальный поток "прилипает" к Carrier Thread при:

// 1. synchronized блок/метод
synchronized(lock) {
    blockingIO(); // Carrier Thread заблокирован на время I/O!
}

// 2. Native методы (JNI)
nativeMethod(); // Нельзя размонтировать

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

java -Djdk.tracePinnedThreads=full MyApp

Решение:

// Заменить synchronized на ReentrantLock
ReentrantLock lock = new ReentrantLock();
lock.lock();
try {
    blockingIO(); // Размонтируется — Carrier Thread свободен
} finally {
    lock.unlock();
}

ThreadLocal → Scoped Values

// ThreadLocal — проблема с VT
ThreadLocal<User> user = new ThreadLocal<>();
// Миллион VT = миллион копий User = Memory Problem!

// Scoped Values (Java 21 Preview) — решение
static final ScopedValue<User> CURRENT_USER = new ScopedValue<>();

ScopedValue.runWhere(CURRENT_USER, user, () -> {
    process(); // CURRENT_USER.get() доступен
});
// Автоматически очищается, нет утечек

Диагностика

Memory Dumps

Виртуальные потоки выглядят как обычные объекты в heap:

jmap -dump:file=heap.hprof <pid>
# VT = обычные объекты — MAT, YourKit работают

Latency Monitoring

// При переходе на VT — P99 стабилизируется
long start = System.nanoTime();
handleRequest();
long elapsed = System.nanoTime() - start;

// VT: P99 ~ стабильный
// Platform: P99 ~ растёт при нагрузке

-XX:+PrintAssembly

# Точки перехода (yield points) в VT:
call Continuation.yield
# JVM оптимизирует эти переходы

Best Practices

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

Когда НЕ стоит переходить на Virtual Threads

  • Ваше приложение уже работает стабильно с пулом потоков — не чините то, что не сломано. VT дают преимущество при I/O-bound нагрузке, не при CPU-bound
  • Команда не готова аудитить зависимости на synchronized — старые JDBC, HTTP-клиенты, SimpleDateFormat внутри synchronized. Без аудита VT будут “прилипать” и работать хуже
  • Java ниже 21 — VT недоступны. Для Java 17- используйте реактивный подход
  • Низкая конкуренция (< 100 RPS) — при малой нагрузке разница незаметна, а риски миграции реальны
  • ThreadLocal-heavy приложение без альтернатив — миллион VT = миллион копий ThreadLocal. Scoped Values ещё в preview

VT vs Platform Threads: когда разница заметна

Метрика Platform Threads Virtual Threads Когда разница видна
Пропускная способность (I/O) 100-500 RPS 10,000-100,000 RPS При высокой I/O нагрузке
P99 латентность Растёт с нагрузкой Стабильна При перегрузке (очередь на потоки)
Память на 10K задач ~10 GB ~50-100 MB При масштабировании
CPU-bound задачи Быстрее Медленнее (оверхед) Никогда не используйте VT для вычислений

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

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

  • VT переключение контекста = ~10-50 CPU cycles (user-space, memcpy), Platform Thread = ~1000-10000 cycles (kernel mode, TLB flush)
  • Закон Литтла: L = λ × W — VT увеличивают L (кол-во параллельных задач) без падения throughput
  • Память: Platform Thread ~1MB нативный стек, VT ~несколько KB динамически в куче (StackChunk)
  • VT возвращают модель Thread-per-Request — простой блокирующий код вместо Reactive/Callback Hell
  • P99 латентность: Platform Threads растёт с нагрузкой, VT остаётся стабильным
  • Pinning — главная проблема: synchronized в VT блокирует Carrier Thread

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

  • Почему Platform Thread context switch такой дорогой? — Требует перехода в kernel mode (syscall), TLB flush, переключение таблиц страниц
  • Что такое эффект VT на P99? — Platform Threads при перегрузке: P99 растёт из-за очереди на потоки; VT: P99 стабилен, так как нет очереди на создание потоков
  • Почему VT не дают выигрыш при низкой конкуренции? — При < 100 RPS разница незаметна, а риски миграции (аудит synchronized, обновление драйверов) реальны
  • Можно ли использовать VT и Platform Threads вместе? — Да: VT для I/O-bound, Platform Threads (FixedThreadPool) для CPU-bound

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

  • ❌ “VT используют меньше памяти потому что они проще” — VT дешевле потому что стек в куче (динамический), а не потому что “проще”
  • ❌ “VT заменяют ExecutorService” — VT — это другой тип исполнителя, ExecutorService всё ещё используется (Executors.newVirtualThreadPerTaskExecutor())
  • ❌ “Reactive программирование больше не нужно” — VT заменяют реактивность для I/O-bound, но реактивный подход всё ещё актуален для streaming, backpressure
  • ❌ “VT автоматически решают все проблемы производительности” — VT помогают только при I/O-bound; при CPU-bound или pinning могут ухудшить

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

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