Питання 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
Обмежені ресурси (БД) Потрібен throttling Semaphore
ThreadLocal-heavy Пам’ять 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-сумісні версії
  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-сумісна? — Запустити з -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]]