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

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

Виртуальные потоки (VT) — это не замена обычным потокам, а специализированный инструмент для конкретных задач. Они дают преимущество когда приложение проводит большую часть врем...

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

Junior уровень

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

Виртуальные потоки (VT) — это не замена обычным потокам, а специализированный инструмент для конкретных задач. Они дают преимущество когда приложение проводит большую часть времени в ожидании (I/O): ответы от БД, микросервисов, чтение из S3, HTTP-запросы.

Почему VT помогают именно при I/O: когда обычный поток вызывает блокирующую операцию (например, socket.read()), он засыпает и занимает 1MB памяти ОС. Виртуальный поток при той же операции “размонтируется” — JVM сохраняет его стек в кучу (несколько KB) и отдаёт Carrier Thread другому виртуальному потоку. Когда I/O завершается, VT “примонтируется” обратно и продолжит работу.

Идеальные сценарии для VT

1. I/O-bound нагрузки (ожидание)

Если приложение тратит 90% времени на ожидание (ответ от БД, микросервиса, чтение из S3):

// VT — лучший выбор
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    for (Request request : requests) {
        executor.submit(() -> {
            String data = httpClient.get(url);    // Ждём сеть
            Result result = db.query(data);       // Ждём БД
            s3.upload(result);                     // Ждём S3
            return result;
        });
    }
}
// Пока один VT ждёт — Carrier Thread обрабатывает другие VT
// Carrier Thread — это реальный поток ОС, на котором "сидят" десятки тысяч VT

2. Thread-per-request модель

// Простой и понятный код вместо реактивного
public void handleRequest(HttpServletRequest req, HttpServletResponse resp) {
    String data = database.query(req.getParameter("id")); // Блокирующий вызов
    String processed = externalApi.call(data);            // Блокирующий вызов
    resp.getWriter().write(processed);                    // Блокирующий вызов
}
// С VT: миллион таких запросов обрабатываются параллельно!

Когда НЕЛЬЗЯ использовать VT

1. CPU-bound задачи (вычисления)

// ПЛОХО: VT для вычислений
Thread.ofVirtual().start(() -> {
    calculatePi(1_000_000); // Нет блокировок — VT не размонтируется
});
// Оверхед планировщика JVM без пользы

// ХОРОШО: FixedThreadPool
ExecutorService cpuPool = Executors.newFixedThreadPool(
    Runtime.getRuntime().availableProcessors() + 1
);
cpuPool.submit(() -> calculatePi(1_000_000));

2. Ограничение ресурсов (Throttling)

// ПЛОХО: VT без ограничения к БД
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    for (int i = 0; i < 1_000_000; i++) {
        executor.submit(() -> {
            database.execute("UPDATE ..."); // Миллион запросов к БД одновременно!
        });
    }
}
// БД упадёт!

// ХОРОШО: Semaphore для ограничения
Semaphore dbSemaphore = new Semaphore(20); // Макс 20 подключений к БД

try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    for (int i = 0; i < 1_000_000; i++) {
        executor.submit(() -> {
            dbSemaphore.acquire();
            try {
                database.execute("UPDATE ...");
            } finally {
                dbSemaphore.release();
            }
        });
    }
}

Middle уровень

Spring Boot 3.2+ с VT

# application.yml
spring:
  threads:
    virtual:
      enabled: true
// Весь Tomcat/Undertow переходит на VT автоматически
@RestController
public class MyController {
    @GetMapping("/user/{id}")
    public User getUser(@PathVariable Long id) {
        // Каждый запрос — отдельный VT
        return userService.findById(id); // Блокирующий вызов — OK!
    }
}

Проблема Pinning (Пригвождение)

Виртуальный поток “прилипает” к Carrier Thread и не размонтируется:

Причина Влияние Решение
synchronized Carrier Thread заблокирован ReentrantLock
Native методы (JNI) Carrier Thread заблокирован Отдельный поток
// ПЛОХО: synchronized в VT
public synchronized Data loadData() {
    return httpClient.get(url); // VT "прилип" — Carrier Thread занят
}

// ХОРОШО: ReentrantLock
private final ReentrantLock lock = new ReentrantLock();

public Data loadData() {
    lock.lock();
    try {
        return httpClient.get(url); // VT размонтируется — Carrier свободен
    } finally {
        lock.unlock();
    }
}

ThreadLocal проблема

// Миллион VT = миллион копий ThreadLocal = Memory Problem!
ThreadLocal<Context> context = ThreadLocal.withInitial(Context::new);

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

ScopedValue.runWhere(CONTEXT, new Context(), () -> {
    process(); // CONTEXT.get() доступен
});

Аудит зависимостей

Перед переходом на VT:

# Проверьте библиотеки на synchronized блоки
java -Djdk.tracePinnedThreads=full -jar app.jar

# Ищите в логах:
# "VirtualThread pinning" — указывает на проблемные места

Типичные проблемные библиотеки:

  • Старые JDBC драйверы (используют synchronized)
  • java.text.SimpleDateFormat (внутри synchronized)
  • Некоторые старые HTTP-клиенты

Senior уровень

Under the Hood: Почему Pinning происходит

synchronized не поддерживает unmount

VT входит в synchronized:
  1. Захват монитора (как обычно)
  2. Блокирующая операция внутри
  3. JVM НЕ может размонтировать — монитор нужно держать
  4. VT "прилипает" к Carrier Thread
  5. Carrier Thread занят → другие VT ждут

Результат: commonPool забит → все parallel streams тормозят

Native методы

VT вызывает native method:
  1. Переход в нативный код (JNI)
  2. JVM не знает, когда нативный код завершится
  3. Нельзя размонтировать — стек в нативном коде
  4. VT "прилипает" до возврата из native

Throttling через Semaphore

public class ThrottledExecutor {
    private final Semaphore semaphore;
    private final ExecutorService virtualExecutor;

    public ThrottledExecutor(int maxConcurrent) {
        this.semaphore = new Semaphore(maxConcurrent);
        this.virtualExecutor = Executors.newVirtualThreadPerTaskExecutor();
    }

    public Future<?> submit(Runnable task) {
        return virtualExecutor.submit(() -> {
            semaphore.acquire();
            try {
                task.run();
            } finally {
                semaphore.release();
            }
        });
    }
}

// Использование:
ThrottledExecutor dbExecutor = new ThrottledExecutor(20); // Макс 20 к БД
ThrottledExecutor apiExecutor = new ThrottledExecutor(100); // Макс 100 к API

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

Сценарий: 10,000 HTTP запросов (latency 100ms каждый)

Platform Threads (100 pool):
  - Время: ~10 секунд
  - Потоков: 100
  - Память: ~100MB

Virtual Threads:
  - Время: ~1 секунда
  - Потоков: 10,000
  - Память: ~50MB
  - Carrier Threads: 8

Диагностика

-Djdk.tracePinnedThreads=full

# Выводит полный stack trace при каждом pinning
java -Djdk.tracePinnedThreads=full MyApp

# Вывод:
# VirtualThread pinning detected at:
#   at java.net.SocketInputStream.socketRead0(Native Method)
#   at com.example.MyClass.synchronizedMethod(MyClass.java:42)

JMX Monitoring

// Метрика: количество пригвождённых потоков
MBeanServer mbs = ManagementFactory.getPlatformMBeanServer();
ObjectName name = new ObjectName("jdk:type=VirtualThread");
Long pinned = (Long) mbs.getAttribute(name, "VirtualThreadPinned");

if (pinned > 0) {
    log.warn("Pinned virtual threads detected: {}", pinned);
}

Thread Dumps

# jstack не эффективен для миллиона потоков
jstack <pid> # Может занять минуты

# Используйте:
jcmd <pid> Thread.dump_to_file -format=json threads.json
# JSON формат — быстрее и эффективнее

Когда VT дают максимальный выигрыш

Сценарий Выигрыш
API Gateway (проксирование) 10x-100x throughput
Web Scraper (много HTTP запросов) 5x-50x throughput
Микросервис с БД + HTTP вызовами 3x-10x throughput
Файловый процессор (I/O) 5x-20x throughput

Когда VT бесполезны или вредны

Сценарий Проблема Решение
Вычисления (ML, крипто) Нет блокировок FixedThreadPool
synchronized-heavy legacy Pinning ReentrantLock
Ограниченные ресурсы (БД) Need throttling Semaphore
ThreadLocal-heavy Memory Scoped Values

Best Practices

  1. VT для I/O-bound — API-шлюз, веб-сервер, прокси
  2. ReentrantLock вместо synchronized — избежание pinning
  3. Semaphore для ограничения — вместо ограничения пула
  4. Scoped Values вместо ThreadLocal — для контекста
  5. Аудит зависимостей — проверьте библиотеки на synchronized
  6. -Djdk.tracePinnedThreads=full — при разработке
  7. Обновите JDBC/HTTP драйверы — VT-compatible версии
  8. Не для CPU-bound — используйте FixedThreadPool для вычислений
  9. Мониторьте VirtualThreadPinned — через JMX/JFR
  10. Spring Boot 3.2+spring.threads.virtual.enabled=true

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

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

  • VT идеальны для I/O-bound: веб-серверы, API-шлюз, прокси, микросервисы с вызовами БД + HTTP
  • VT НЕ подходят для CPU-bound (вычисления, ML, криптография) — используйте FixedThreadPool(N+1)
  • При работе с ограниченными ресурсами (пул БД на 20 соединений) — VT + Semaphore
  • Pinning: synchronized и JNI не позволяют VT размонтироваться; заменяйте synchronized на ReentrantLock
  • ThreadLocal + миллион VT = Memory Problem; альтернатива — Scoped Values (Java 21 Preview)
  • Аудит зависимостей обязателен: старые JDBC драйверы, SimpleDateFormat используют synchronized
  • Выигрыш: API Gateway 10-100x throughput, Web Scraper 5-50x, микросервис 3-10x

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

  • Как ограничить количество одновременных запросов к БД при использовании VT? — Semaphore с лимитом, обёрнутый в VT executor
  • Почему Spring Boot 3.2+ упрощает миграцию на VT?spring.threads.virtual.enabled=true переводит весь Tomcat на VT автоматически, без изменения кода контроллеров
  • Как проверить, что библиотека VT-compatible? — Запустить с -Djdk.tracePinnedThreads=full и проверить логи на “VirtualThread pinning”
  • Что делать если dependency использует synchronized внутри? — Обернуть вызов в отдельный Platform Thread или заменить библиотеку

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

  • ❌ “VT всегда быстрее — нужно везде использовать” — VT медленнее для CPU-bound и добавляют оверхед при низкой конкуренции
  • ❌ “Миллион VT к БД без ограничений — нормально” — БД упадёт, нужен Semaphore
  • ❌ “Миграция на VT бесплатна” — требуется аудит зависимостей на synchronized, обновление JDBC/HTTP драйверов
  • ❌ “VT решают проблему ThreadLocal” — VT усугубляют проблему: миллион VT = миллион копий ThreadLocal

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

  • [[23. Что такое Virtual Threads в Java 21]]
  • [[24. В чём преимущества Virtual Threads перед обычными потоками]]
  • [[26. Что такое structured concurrency]]
  • [[20. Как предотвратить deadlock]]