🧵 Розділ 9 · Питання #24

Коли варто використовувати Virtual Threads

Віртуальні потоки НЕ слід використовувати для:


🟢 Junior Level

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

Virtual Threads (віртуальні потоки) слід використовувати тоді й тільки тоді, коли робоче навантаження є переважно I/O-bound (обмежене операціями введення-виведення) і характеризується високим рівнем паралелізму (High Concurrency) — сотні, тисячі або мільйони одночасних завдань. Типові сценарії: вебсервери, REST/gRPC API-шлюзи, мікросервіси-оркестратори, вебкраулери та інтеграційні сервіси, що проводять більшу частину часу в очікуванні відповідей від мережі, бази даних чи черг повідомлень.

Віртуальні потоки НЕ слід використовувати для:

  1. CPU-bound завдань (криптографія, відеокодування, машинне навчання, обчислення матриць) — тут віртуальний потік не розмонтовується і створює лише паразитні накладні витрати для планувальника JVM;
  2. Систем із низьким рівнем паралелізму (десятки запитів на хвилину), де різниця непомітна;
  3. Legacy-систем із повсюдним використанням synchronized навколо блокуючого введення-виведення (через ефект Pinning).

Матриця застосовності Virtual Threads

Тип робочого навантаження / Сценарій Використовувати Virtual Threads? Рекомендоване рішення Чому
Мікросервіс / REST API (I/O-bound) ✅ Так (Ідеально) Thread-per-request (Java 21) Потоки дешеві, код простий і лінійний
API Gateway / Проксіювання HTTP ✅ Так (Ідеально) VirtualThreadPerTaskExecutor Величезний throughput при мінімальному RAM
Парсинг вебсайтів (Web Scraping) ✅ Так (Ідеально) Virtual Threads + HTTP Client Тисячі одночасних мережевих з’єднань
Розрахунок криптографії / Гешування ❌ Ні Executors.newFixedThreadPool(N) CPU-bound: ядра процесора завантажені на 100%
Рендеринг відео / Стиснення ZIP ❌ Ні ForkJoinPool / FixedThreadPool Немає точок блокування для unmount
GUI Event Loop (JavaFX, Swing) ❌ Ні Єдиний Dedicated Event Thread Порушує архітектуру графічної черги
Оркестрація паралельних запитів ⚠️ Так, з лімітом Virtual Threads + Semaphore Потрібен захист зовнішніх API та БД від перевантаження

Приклад у Java 21: Паралельний мікросервісний агрегатор

import java.util.concurrent.Executors;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.net.URI;
import java.time.Duration;

public class MicroserviceAggregator {

    private final HttpClient httpClient = HttpClient.newBuilder()
            .connectTimeout(Duration.ofSeconds(2))
            .build();

    // Ідеальний кейс для Virtual Threads: віялове опитування 1 000 постачальників
    public void aggregateSuppliersData() {
        try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
            for (int i = 1; i <= 1_000; i++) {
                final int supplierId = i;
                executor.submit(() -> {
                    fetchSupplierCatalog(supplierId);
                });
            }
        } // Блок try автоматично очікує завершення всіх 1 000 віртуальних потоків!
    }

    private void fetchSupplierCatalog(int supplierId) {
        try {
            var request = HttpRequest.newBuilder()
                    .uri(URI.create("https://api.supplier" + supplierId + ".internal/catalog"))
                    .GET()
                    .build();

            // Блокуючий виклик сокета: віртуальний потік розмонтовується!
            HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
            System.out.println("Каталог " + supplierId + " отримано, статус: " + response.statusCode());
        } catch (Exception e) {
            System.err.println("Помилка опитування постачальника " + supplierId + ": " + e.getMessage());
        }
    }
}

🟡 Middle Level

Інтеграція в Spring Boot 3.2+: Магія одного рядка

Починаючи зі Spring Boot 3.2 та Spring Framework 6.1, підтримка віртуальних потоків вбудована в ядро:

# application.yml
spring:
  threads:
    virtual:
      enabled: true

Що відбувається під капотом під час увімкнення прапорця:

  1. Вбудований вебсервер (Tomcat / Jetty): стандартний пул потоків (ThreadPoolExecutor на 200 потоків) замінюється на Executors.newVirtualThreadPerTaskExecutor(). Кожен вхідний HTTP-запит тепер виконується в ізольованому віртуальному потоці.
  2. Асинхронні методи (@Async): автоматично використовують планувальник віртуальних потоків.
  3. Планувальник завдань (@Scheduled): запускає завдання на віртуальних потоках.
  4. Spring MVC: традиційні блокуючі контролери набувають продуктивності реактивного WebFlux без необхідності зміни сигнатур методів.

Захист дефіцитних ресурсів: Throttling через Semaphore

Раніше пул потоків ненавмисно виконував роль «запобіжника»: якщо в Tomcat було 200 потоків, сервер фізично не міг відправити до бази даних більше ніж 200 одночасних запитів.

З віртуальними потоками цей обмежувач зникає. Якщо на сервер надійде 50 000 запитів, вони створять 50 000 віртуальних потоків, які одночасно звернуться до пулу HikariCP (розміром, наприклад, 30 з’єднань). Це миттєво призведе до ConnectionTimeoutException та відмови бази даних.

import java.util.concurrent.Semaphore;

public class DatabaseThrottlingService {

    // Пул БД вміщує 20 з'єднань. Обмежуємо конкурентний доступ 20 дозволами:
    private final Semaphore dbSemaphore = new Semaphore(20);

    public String queryOrderDetails(long orderId) {
        try {
            // Чекаємо вільного слота. Виклик acquire() розмонтовує віртуальний потік!
            dbSemaphore.acquire();
            try {
                return executeJdbcCall(orderId);
            } finally {
                dbSemaphore.release();
            }
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            throw new RuntimeException("Запит перервано", e);
        }
    }

    private String executeJdbcCall(long orderId) {
        // Блокуючий виклик через HikariCP + PostgreSQL JDBC Driver
        return "OrderDetails_" + orderId;
    }
}

Аудит залежностей на сумісність із Project Loom

Перед увімкненням віртуальних потоків у production Senior-розробник зобов’язаний провести аудит сторонніх бібліотек на предмет Carrier Pinning:

# Прапорець JVM для трасування пришпилення потоків у процесі тестування:
java -Djdk.tracePinnedThreads=full -jar app.jar

Зони ризику в застарілих бібліотеках:

  1. Старі JDBC драйвери: версії MySQL Connector/J до 8.0.33 містили методи synchronized на рівні сокетів. Сучасні версії (MySQL 8.3+, PostgreSQL 42.6+) очищені від проблемних моніторів.
  2. Класичний SimpleDateFormat: є потоконебезпечним і часто огортався розробниками в synchronized(lock). Слід перейти на потокобезпечний java.time.format.DateTimeFormatter.
  3. In-Memory кеші та пули: старі реалізації пулів об’єктів (наприклад, Apache Commons Pool версій 1.x) блокували монітори під час очікування ресурсів.

🔴 Senior Level

Гібридна модель багатозадачності (Hybrid Concurrency Architecture)

У реальних Enterprise-застосунках чистий I/O часто чергується з інтенсивними обчисленнями. Призначення віртуальних потоків на CPU-bound розрахунки призводить до голодування потоків-носіїв (Carrier Starvation):

                          [ Вхідний HTTP-запит ]
                                     │
                      (Обробка на Virtual Thread)
                                     │
              ┌──────────────────────┴──────────────────────┐
              ▼                                             ▼
        [ Мережевий I/O ]                          [ CPU-bound Завдання ]
   (БД, зовнішній мікросервіс)                  (Генерація PDF, крипта)
              │                                             │
    (Unmount VT в купу,                             (Делегування у
    Carrier потік вільний)                       Dedicated FixedThreadPool)
                                                            │
                                              (Очікування Future.get() на VT,
                                                 VT знову розмонтовується!)

Патерн делегування важких обчислень:

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class HybridOrderService {

    // Пул для CPU-bound завдань суворо за кількістю фізичних ядер:
    private static final ExecutorService CPU_INTENSIVE_POOL = 
            Executors.newFixedThreadPool(Runtime.getRuntime().availableProcessors());

    public void processClientRequest() throws Exception {
        // 1. Швидкий мережевий I/O — виконується прямо на Virtual Thread:
        String data = fetchRemoteData();

        // 2. Важкий криптографічний підпис (CPU-bound) — делегується в CPU-пул:
        var future = CPU_INTENSIVE_POOL.submit(() -> computeCryptographicSignature(data));

        // 3. Очікування результату future.get() на Virtual Thread РОЗМОНТОВУЄ потік!
        // Carrier Thread звільняється, а CPU-ядра безпечно завантажуються виділеним пулом:
        byte[] signature = future.get();

        // 4. Запис у БД (I/O) — знову на віртуальному потоці:
        saveToDatabase(signature);
    }

    private String fetchRemoteData() { return "Data"; }
    private byte[] computeCryptographicSignature(String data) { return new byte[0]; }
    private void saveToDatabase(byte[] sig) {}
}

Аналіз тиску на збирач сміття (GC Footprint Analysis)

Хоча стек віртуального потоку дешевий, створення 100 000 віртуальних потоків на секунду генерує величезний потік короткоживучих об’єктів StackChunk та внутрішніх дескрипторів завдань:

  1. Young Generation TLAB: кожен Carrier Thread алокує фрейми віртуальних потоків у своєму Thread-Local Allocation Buffer (TLAB). Висока частота створення/знищення потоків призводить до швидкого заповнення Eden-простору.
  2. Вибір GC: для застосунків із масивним використанням віртуальних потоків настійно рекомендується використовувати Generational ZGC (Java 21+: -XX:+UseZGC -XX:+ZGenerational) або сучасний G1 GC. Вони мінімізують паузи Stop-The-World (STW) до субмілісекундного діапазону навіть за гігабайтних швидкостей алокації стеків.

🎯 Шпаргалка для інтерв’ю

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

Віртуальні потоки в Java 21 призначені для висококонкурентних I/O-bound систем (вебсервери, API-шлюзи, мікросервіси, інтеграційні платформи), де потоки проводять левову частку часу в очікуванні зовнішніх відповідей. Вони дозволяють повернутися до моделі Thread-per-Task замість складної реактивності. Віртуальні потоки протипоказані для CPU-bound обчислень (де вони створюють оверхед без розмонтування), систем із низьким навантаженням та legacy-коду з повсюдним synchronized навколо блокуючих сокетів. Для захисту баз даних та зовнішніх API від лавини віртуальних потоків обов’язково використовується Semaphore.


2. 4 каверзних питання з глибокими відповідями

Питання 1: Що станеться з пулом з’єднань HikariCP (20 з’єднань), якщо увімкнути spring.threads.virtual.enabled=true і на сервіс прийде сплеск зі 100 000 RPS?

Відповідь: Якщо не вжити захисних заходів, Tomcat створить 100 000 віртуальних потоків, і всі вони одночасно запитають з’єднання в HikariDataSource.getConnection(). Пул HikariCP моментально вичерпає 20 доступних коннектів, а решта 99 980 потоків вишикуються в чергу очікування таймауту (connectionTimeout, за замовчуванням 30 сек). Через 30 секунд сервіс захлинеться сотнями тисяч винятків SQLTransientConnectionException: Connection is not available, request timed out.
Архітектурне рішення: обмежити паралельний вхід у секцію звернення до БД через Semaphore(20) або налаштувати зовнішній Rate Limiter на рівні Ingress/API Gateway.

Питання 2: Чому для завдань машинного навчання, шифрування чи стиснення даних використання віртуальних потоків погіршує продуктивність порівняно з FixedThreadPool?

Відповідь: Віртуальний потік звільняє процесорне ядро лише в точках переривання (кооперативна багатозадачність при I/O або явних блокуваннях). У CPU-bound алгоритмах точок розмонтування немає: потік займає Carrier Thread на 100% часу. Якщо запустити тисячі таких завдань на віртуальних потоках, середовище виконання JVM витрачатиме пам’ять на алокацію сотень тисяч об’єктів Thread та StackChunk у купі, перевантажуючи збирач сміття та чергу планувальника ForkJoinPool, не маючи змоги виконати більше обчислень, ніж дозволяє фізична кількість процесорних ядер.

Питання 3: Як організувати гібридну архітектуру сервісу, якщо один запит вимагає і тривалого мережевого виклику, і важкої криптографічної обробки?

Відповідь: Застосовується розділення відповідальності (Hybrid Model): вхідний запит обробляється у віртуальному потоці, який виконує введення-виведення (читання даних по мережі, звернення до БД). Коли настає фаза важких обчислень, завдання передається у виділений пул потоків платформи Executors.newFixedThreadPool(N) (де $N$ дорівнює числу ядер CPU). Віртуальний потік викликає future.get() на завданні з CPU-пулу. Під час виклику get() віртуальний потік коректно розмонтовується, звільняючи Carrier Thread доти, доки обчислення в CPU-пулі не завершиться.

Питання 4: Як прапорець -Djdk.tracePinnedThreads та JFR-події допомагають виявити приховане блокування Carrier Threads у сторонніх бібліотеках?

Відповідь: Під час передачі аргументу -Djdk.tracePinnedThreads=full JVM виводить у stderr повний стек-трейс щоразу, коли віртуальний потік блокується всередині нативного методу або synchronized-блоку, вказуючи точний клас і номер рядка коду бібліотеки.
У production-середовищі для цього використовується Java Flight Recorder (JFR) без деградації продуктивності: фільтрація події jdk.VirtualThreadPinned з порогом тривалості (threshold=20ms) дозволяє в реальному часі моніторити проблемні місця через Prometheus/Grafana або JDK Mission Control.


3. Типові помилки та Red Flags

❌ «Увімкнення віртуальних потоків вирішить проблеми повільної бази даних.»
Правильно: Віртуальні потоки розвантажують сервер від утримання пам’яті потоків ОС, але якщо SQL-запит виконується повільно, віртуальний потік просто довше чекатиме. Без оптимізації індексів і запитів лавина віртуальних потоків лише швидше перевантажить СУБД.

❌ «Використання пулу віртуальних потоків замість створення нових.»
Правильно: Віртуальні потоки не повинні пулитися. Їхній життєвий цикл короткий: створення $\to$ виконання завдання $\to$ видалення збирачем сміття.

❌ «Заміна всіх пулів потоків у застосунку на віртуальні потоки без розбору.»
Правильно: Пули для CPU-bound завдань зобов’язані залишатися FixedThreadPool на потоках платформи.


4. Пов’язані питання