Віртуальні потоки обіцяють те, чого Java-команди хотіли десятиліття: масштабованість реактивного програмування зі звичайним, блокувальним, зрозумілим кодом. У Spring Boot це буквально одна властивість. Але за «однією властивістю» ховаються реальні зміни в поведінці сервісу під навантаженням, і команди, що вмикають її, не розібравшись, стикаються з вичерпаними пулами з'єднань, перевантаженими зовнішніми сервісами і заплутаними дампами потоків. У гайді пояснюємо, як працюють віртуальні потоки, як безпечно увімкнути їх у Spring Boot, і ділимося уроками з продакшену.

Що таке віртуальні потоки

Класичний Java-потік — тонка обгортка над потоком операційної системи. Потоки ОС дорогі: кожен резервує пам'ять під стек, а перемикання контексту коштує CPU, тож сервери використовують пули з кількох сотень потоків. Коли кожен потік заблокований в очікуванні бази даних чи HTTP-виклику, нові запити стають у чергу, хоча CPU простоює.

Віртуальні потоки, фінальні з Java 21, — це легкі потоки, якими керує JVM (JEP 444). Коли віртуальний потік блокується на вводі-виводі, JVM знімає його з потоку-носія ОС і запускає там інший віртуальний потік. Їх можуть бути мільйони. Код залишається синхронним: repository.findById() блокує віртуальний потік, а не потік ОС.

Головне: віртуальні потоки підвищують пропускну здатність для навантажень, обмежених вводом-виводом. Окремі запити вони не прискорюють і нічого не дають для роботи, обмеженої CPU.

Як увімкнути їх у Spring Boot

Починаючи зі Spring Boot 3.2, одна властивість переводить вбудований вебсервер, виконання @Async, планувальник і низку інтеграцій на віртуальні потоки (довідник Spring Boot: virtual threads):

spring.threads.virtual.enabled=true
# Віртуальні потоки — daemon-потоки; тримаємо JVM живою для планувальників тощо
spring.main.keep-alive=true

Дві примітки з документації Spring Boot:

  • Віртуальні потоки потребують Java 21, але Java 24 чи новіша наполегливо рекомендується.
  • Після увімкнення властивості, що налаштовують пули потоків, — як-от server.tomcat.threads.max у Tomcat, — більше не діють, бо віртуальні потоки працюють на планувальнику рівня всієї JVM.

Чому важлива Java 24+: проблема pinning

У Java 21 віртуальний потік, що блокувався всередині synchronized-блоку чи методу, залишався прикріпленим (pinned) до потоку-носія ОС. З невеликою кількістю носіїв (за замовчуванням — по одному на ядро CPU) кілька прикріплених потоків могли зупинити весь застосунок. Багато популярних бібліотек використовували synchronized усередині, тож це була реальна продакшен-проблема.

JDK 24 це виправив: тепер віртуальні потоки можуть блокуватися всередині synchronized, не прикріплюючи носія (JEP 491). Pinning досі трапляється, коли нативний код викликає Java-код, що блокується, і для таких випадків JDK Flight Recorder записує подію jdk.VirtualThreadPinned. Якщо впроваджуєте віртуальні потоки — працюйте на Java 25 LTS; див. Java 25 LTS: що нового.

Пастка 1: вузьким місцем стають пули з'єднань

З пулом із 200 платформних потоків сервіс ніколи не виконував би більше 200 одночасних запитів до бази. З віртуальними потоками 5 000 одночасних запитів можуть водночас спробувати отримати з'єднання з БД. Тепер пропускну здатність обмежує пул з'єднань — у HikariCP за замовчуванням 10 з'єднань, — і запити чекають на ньому.

Насправді це здорово: саме пул, а не пул потоків, має обмежувати конкурентність до бази, а база даних найкраще працює з помірною кількістю з'єднань (HikariCP: розмір пулу). Але розмір потрібно задати свідомо і встановити розумний connectionTimeout, щоб запити швидко падали, а не накопичувалися.

Пастка 2: тепер можна перевантажити зовнішні сервіси

Пули потоків були випадковим обмежувачем швидкості. Приберіть їх — і сплеск трафіку пройде прямо до платіжного провайдера, API ERP чи legacy SOAP-сервісу, що падає на 50 запитах на секунду. Додайте явні ліміти:

@Component
class ErpClient {
    // Не більше 20 одночасних викликів ERP незалежно від кількості віртуальних потоків
    private final Semaphore permits = new Semaphore(20);
    private final RestClient rest;

    ErpClient(RestClient.Builder builder) {
        this.rest = builder.baseUrl("https://erp.example.com").build();
    }

    Order fetchOrder(String id) throws InterruptedException {
        if (!permits.tryAcquire(2, TimeUnit.SECONDS)) {
            throw new ServiceUnavailableException("ERP busy, try again");
        }
        try {
            return rest.get().uri("/orders/{id}", id).retrieve().body(Order.class);
        } finally {
            permits.release();
        }
    }
}

Bulkhead'и і rate limiter'и з Resilience4j або @ConcurrencyLimit зі Spring Framework 7 роблять те саме декларативно.

Пастка 3: припущення щодо ThreadLocal

Код, що кешує дорогі об'єкти в ThreadLocal — форматери, буфери, з'єднання, — розраховував на кілька сотень довгоживучих потоків. З новим віртуальним потоком на кожен запит ці кеші постійно створюються і викидаються і можуть збільшувати споживання пам'яті. Замініть їх на спільні потокобезпечні об'єкти чи пули, а для контексту запиту в новому коді використовуйте scoped values (JEP 506).

Пастка 4: робота, обмежена CPU

Віртуальні потоки не додають CPU. Обробка зображень, генерація PDF, важкі перетворення JSON чи криптографія і далі конкурують за ті самі ядра. Тримайте важкі для CPU задачі на обмеженому executor'і з платформними потоками, щоб вони не «голодували» обробку запитів.

Моніторинг віртуальних потоків

  • Дампи потоків: jcmd <pid> Thread.dump_to_file -format=json dump.json включає віртуальні потоки, які класичний вивід jstack корисно не показує (Oracle: гайд з віртуальних потоків).
  • Події JFR: jdk.VirtualThreadPinned і jdk.VirtualThreadSubmitFailed показують проблеми планування.
  • Метрики пулу: стежте за кількістю очікувань з'єднань і часом їх отримання в HikariCP — тепер це ваш головний сигнал насичення.
  • Трейсинг: розподілені трейси показують, де запити насправді чекають; див. Observability з OpenTelemetry.

Віртуальні потоки, реактивний підхід і корутини

Підхід Стиль коду Найкраще для
Віртуальні потоки Звичайний блокувальний код, Spring MVC, JDBC Більшість CRUD та інтеграційних сервісів
Реактивний (WebFlux, Reactor) Оператори і потоки даних Стрімінг, пайплайни з активним backpressure
Корутини Kotlin Послідовний код із suspend Kotlin-команди, яким потрібна структурована конкурентність

Для нових сервісів на Spring MVC з JDBC віртуальні потоки — наш вибір за замовчуванням. Реактивні стеки залишаємо там, де backpressure і стрімінг — ключові вимоги, а в Kotlin-сервісах використовуємо корутини, як описано у статті Spring Boot + Kotlin у 2026. Як структурувати такий сервіс усередині — у статті Модульний моноліт зі Spring Modulith.

Безпечний план впровадження

  1. Оновіться до Java 24+ (ідеально — 25 LTS) і Spring Boot 3.2+.
  2. Проведіть навантажувальний тест поточного стану і зафіксуйте пропускну здатність, затримки, метрики пулу і пам'ять.
  3. Увімкніть spring.threads.virtual.enabled і spring.main.keep-alive на одному інстансі.
  4. Свідомо задайте розмір пулу з'єднань; додайте ліміти для кожної зовнішньої залежності.
  5. Повторіть навантажувальний тест, порівняйте результати і пошукайте події jdk.VirtualThreadPinned.
  6. Розгортайте поступово, стежачи за рівнем помилок зовнішніх сервісів.

Як чесно виміряти ефект переходу

Порівняння платформних і віртуальних потоків легко зробити неправильно. Метод, якому можна довіряти:

  1. Використовуйте реалістичну затримку зовнішніх залежностей. Віртуальні потоки виграють, коли запити чекають на ввід-вивід; бенчмарк проти заглушки в пам'яті, що відповідає за мікросекунди, покаже мало різниці.
  2. Решта має бути ідентичною: той самий JDK, heap, ліміти контейнера, розмір пулу з'єднань і набір даних.
  3. Нарощуйте навантаження поступово і фіксуйте пропускну здатність, p50/p95/p99-затримку і частку помилок на кожному кроці, а не лише пік.
  4. Стежте, куди переміщується вузьке місце. З віртуальними потоками насичення зміщується з пулу потоків на пул з'єднань чи зовнішній сервіс — записуйте і ці метрики.
  5. Запускайте достатньо довго — щонайменше 15–30 хвилин на крок, — щоб побачити поведінку збирання сміття і пам'яті, а не лише фазу прогрівання.

У наших тестах найбільший виграш з'являється в сервісах, що викликають кілька повільних залежностей на кожен запит; сервіси, обмежені CPU, практично не змінюються.

FAQ

Чи прискорять віртуальні потоки мій API? Вони збільшують кількість одночасних запитів, які може обробити сервіс, що здебільшого чекає на ввід-вивід. Затримка окремого запиту залишається такою ж або трохи покращується під навантаженням.

Чи потрібно змінювати код? Зазвичай ні — окрім додавання лімітів конкурентності і заміни кешів у ThreadLocal. Це головна перевага над реактивним програмуванням.

Чи готові віртуальні потоки до продакшену? Так, особливо на Java 24 і новіших, де проблему pinning у synchronized розв'язано.

Чи варто переводити сервіси на WebFlux на віртуальні потоки? Лише якщо реактивний код ускладнює підтримку, а сервіс не покладається на стрімінг чи backpressure. Спершу виміряйте.

Джерела

  1. OpenJDK. JEP 444: Virtual Threads і JEP 491: Synchronize Virtual Threads without Pinning.
  2. OpenJDK. JEP 506: Scoped Values.
  3. Spring Boot. Довідник: virtual threads і Task execution and scheduling.
  4. Oracle. Virtual threads, Java SE 25 core libraries guide.
  5. HikariCP. About pool sizing.