Віртуальні потоки обіцяють те, чого 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.
Безпечний план впровадження
- Оновіться до Java 24+ (ідеально — 25 LTS) і Spring Boot 3.2+.
- Проведіть навантажувальний тест поточного стану і зафіксуйте пропускну здатність, затримки, метрики пулу і пам'ять.
- Увімкніть
spring.threads.virtual.enabledіspring.main.keep-aliveна одному інстансі. - Свідомо задайте розмір пулу з'єднань; додайте ліміти для кожної зовнішньої залежності.
- Повторіть навантажувальний тест, порівняйте результати і пошукайте події
jdk.VirtualThreadPinned. - Розгортайте поступово, стежачи за рівнем помилок зовнішніх сервісів.
Як чесно виміряти ефект переходу
Порівняння платформних і віртуальних потоків легко зробити неправильно. Метод, якому можна довіряти:
- Використовуйте реалістичну затримку зовнішніх залежностей. Віртуальні потоки виграють, коли запити чекають на ввід-вивід; бенчмарк проти заглушки в пам'яті, що відповідає за мікросекунди, покаже мало різниці.
- Решта має бути ідентичною: той самий JDK, heap, ліміти контейнера, розмір пулу з'єднань і набір даних.
- Нарощуйте навантаження поступово і фіксуйте пропускну здатність, p50/p95/p99-затримку і частку помилок на кожному кроці, а не лише пік.
- Стежте, куди переміщується вузьке місце. З віртуальними потоками насичення зміщується з пулу потоків на пул з'єднань чи зовнішній сервіс — записуйте і ці метрики.
- Запускайте достатньо довго — щонайменше 15–30 хвилин на крок, — щоб побачити поведінку збирання сміття і пам'яті, а не лише фазу прогрівання.
У наших тестах найбільший виграш з'являється в сервісах, що викликають кілька повільних залежностей на кожен запит; сервіси, обмежені CPU, практично не змінюються.
FAQ
Чи прискорять віртуальні потоки мій API? Вони збільшують кількість одночасних запитів, які може обробити сервіс, що здебільшого чекає на ввід-вивід. Затримка окремого запиту залишається такою ж або трохи покращується під навантаженням.
Чи потрібно змінювати код? Зазвичай ні — окрім додавання лімітів конкурентності і заміни кешів у ThreadLocal. Це головна перевага над реактивним програмуванням.
Чи готові віртуальні потоки до продакшену?
Так, особливо на Java 24 і новіших, де проблему pinning у synchronized розв'язано.
Чи варто переводити сервіси на WebFlux на віртуальні потоки? Лише якщо реактивний код ускладнює підтримку, а сервіс не покладається на стрімінг чи backpressure. Спершу виміряйте.
Джерела
- OpenJDK. JEP 444: Virtual Threads і JEP 491: Synchronize Virtual Threads without Pinning.
- OpenJDK. JEP 506: Scoped Values.
- Spring Boot. Довідник: virtual threads і Task execution and scheduling.
- Oracle. Virtual threads, Java SE 25 core libraries guide.
- HikariCP. About pool sizing.