Виртуальные потоки в Java: эволюция, практика, подводные камни

Виртуальные потоки в Java: эволюция, практика, подводные камни, image #1

Привет! Меня зовут Денис, я руковожу группой R&D в Naumen Service Management Platform. В этой статье — разбор виртуальных потоков (VT) в Java: почему исторически обычные потоки упирались в I/O, как и зачем появились виртуальные потоки, что пошло не так в Java 21, что исправили в JDK 24–25, а также когда виртуальные потоки необходимы, а когда — лучше от них отказаться.

Зачем вообще нужны виртуальные потоки

Исторически Thread в Java — это полноценный поток операционной системы, который мапится 1 к 1. Такой поток:

  • дорого создавать и держать;
  • ограничен по количеству;
  • простаивает на долгих I/O‑операциях, например, при обращениях к базе данных, по сети: поток «ждет байтики», занимая OS‑поток и ничего не делая.
Виртуальные потоки в Java: эволюция, практика, подводные камни, image #2

С этим пытались бороться через thread pool, ExecutorService, утилитарные методы. Это помогало, но это компромисс, а не решение. Главная боль — простой на I/O — никуда не исчезает, а при исчерпании пула мы упираемся во внутреннюю очередь, и задержки начинают расти экспоненциально.

Виртуальные потоки в Java: эволюция, практика, подводные камни, image #3

Сообщество Java стало искать более легкие и быстрые подходы к параллелизму. Логичный путь — посмотреть на другие языки: какие решения там уже есть и какие из них доказали свою эффективность. В первую очередь это Go с его goroutines и Kotlin с coroutines.

Что подсмотрели у соседей: goroutines и coroutines

Go | Goroutines

Функция, выполняющаяся конкурентно с другими горутинами в том же адресном пространстве.

Как работает: достаточно написать ключевое слово go перед вызовом функции — она запустится как отдельная горутина. Здесь впервые закрепилась модель M:N: много горутин мапятся на мало OS‑потоков. Планирование — в user space, логика снята с ОС и реализована в рантайме языка. Горутин могут быть десятки и сотни тысяч; они «накатываются» на ограниченное число носителей.

Виртуальные потоки в Java: эволюция, практика, подводные камни, image #4

В коде это выглядит так: в функции main пишем go hello() — и hello работает как легкий «второстепенный» поток.

package main
import (
"fmt"
"time"
)
func hello() {
fmt.Println("Привет из горутины!")
}
func main() {
go hello()
fmt.Println("Привет из main")
time.Sleep(time.Second)
}

Плюсы

  • Простое API: go f() — и готово.
  • Масштабируемость по умолчанию.
  • Автоматическая работа с блокировками I/O.

Минусы

  • Нет контроля над временем жизни — нет structured concurrency.
  • Потенциальные утечки: забыли завершить — висят.
  • Возможны блокировки, если не соблюдать правила, например, mutex.

Kotlin | Coroutines

Блоки кода, которые позволяют приостанавливать и возобновлять выполнение в различных точках, не блокируя поток выполнения.

Как работает: тут иная точка входа — suspend‑функции. Это точки в программе, где можно приостанавливать выполнение. Работает все за счет «магии» state‑machine компилятора. Никаких встроенных нативных низкоуровневых механизмов — только конечный автомат и компиляторная трансформация. Так получаем отсутствие тяжеловесных OS‑потоков + можно создавать огромное количество «легковесных» задач.

Виртуальные потоки в Java: эволюция, практика, подводные камни, image #5

В коде — тоже просто: запускаем, и все работает.

import kotlinx.coroutines.*
fun main() = runBlocking {
launch {
println("Привет из корутины!")
}
println("Привет из main")
delay(1000L)
}

Плюсы

  • Очень экономичны по ресурсам.
  • Structured concurrency, runBlocking, coroutineScope.
  • Совместимы с существующим синхронным кодом.

Минусы

  • Сложная внутренняя реализация — CPS, state‑machine.
  • Нужно уметь работать с context«ами, scope, Job, Dispatcher.
  • Возможны утечки при неправильном использовании.

Project Loom: от идеи к стабильному релизу

Именно эта логика — много легких короткоживущих «потоков» положить на уже существующую в Java модель нативных потоков — и легла в основу Java‑подхода. Так появился Project Loom.

  • Сначала — JEP 425, который долго был в preview: формулирует, что хотим получить от виртуальных потоков.
  • Затем — JEP 444: фактически «финальный коммит», вошедший в Java 21, где виртуальные потоки стали stable.
Виртуальные потоки в Java: эволюция, практика, подводные камни, image #6

Что такое виртуальные потоки в JVM

Виртуальные потоки — это легковесные потоки, которыми управляет JVM, а не ОС. «Один поток Java = один OS‑поток» больше не аксиома: можно создавать очень много виртуальных потоков, которые мапятся на ограниченное число OS‑потоков.

  • Создаются быстро.
  • Удешевляют конкурентность: теперь не нужно думать, как пулить — на каждую независимую операцию можно завести свой виртуальный поток.
Виртуальные потоки в Java: эволюция, практика, подводные камни, image #7

M:N-планирование и как работает virtual thread под капотом

M:N‑планирование — это когда M виртуальных потоков ложатся на N реальных OS‑потоков. За то, как прикреплять/откреплять, отвечает планировщик внутри JVM. Вращаются они на всем известном ForkJoinPool с доработками и оптимизациями.

Как это работает под капотом:

  • Виртуальный поток — это по сути Runnable‑задача, которая ставится в очередь ForkJoinPool.
  • Во время блокировки, например, sleep(), I/O, виртуальный поток открепляется от carrier‑потока — в это время carrier может взять другой виртуальный поток.
  • Планировщик периодически чекает и возвращает управление, позволяя выполнять множество операций, не умирая на долгих I/O.
Виртуальные потоки в Java: эволюция, практика, подводные камни, image #8

Почему это важно

Java 21 — не просто очередной релиз, а большой шаг для Java Concurrency. Разработчику больше не нужно «писать многопоточность» в классическом смысле: достаточно использовать виртуальные потоки там, где это уместно. На практике, правда, все оказалось сложнее.

Java 21: релиз виртуальных потоков и первые кейсы на практике

Как включить виртуальные потоки и какие есть API

Первые API появились в Java 19 — фича была в preview, включалась флагом. К моменту релиза в Java 21 API почти не изменились.

Рабочие способы запуска

Прямой запуск виртуального потока

Thread.startVirtualThread(() -> System.out.println("Виртуальный поток работает"));
  • Простой способ, подходит для одноразовых задач.
  • Thread.startVirtualThread(...) — практическая замена обычного Thread.start(), только гораздо быстрее. Ребята из OpenJDK замеряли — примерно в 5 раз быстрее создания обычного треда, что логично: без нативных вызовов и без тяжелого стек-аллоцирования.

Использование фабрики Thread.ofVirtual()

Thread thread = Thread.ofVirtual().start(() -> System.out.println("Создан через ofVirtual"));
  • Позволяет настраивать поток: name и другие.
  • Ближе по семантике к Thread.Builder.

ExecutorService с виртуальными потоками

try (var executor = Executors.newVirtualThreadPerTaskExecutor())
{
executor.submit(() -> System.out.println("Задача через VirtualThreadPerTaskExecutor"));|
}
  • Подходит для большого количества задач.
  • Совместим с ExecutorService, Callable, Future.
  • Автоматически завершает все потоки при закрытии.

Получаете executor и сабмитите в него задачи — без размышлений про размер пула и таймауты. Это самый удобный путь миграции: заменяется только объявление executor'a, остальной код, включая invokeAll, остается прежним.

Параллельный запуск задач

List<Callable<String>> tasks = List.of(
() -> "task 1",
() -> "task 2",
() -> "task 3"
);
try (var executor = Executors.newVirtualThreadPerTaskExecutor())
{
var results = executor.invokeAll(tasks);
for (var result : results)
{
System.out.println(result.get());
}
}
  • Показывает, как масштабируется invokeAll() с миллионами задач.

Что обещали и что получилось

Ожидания: миллионы легких потоков, «магическое» устранение простоев на I/O, совместимость со старым кодом, простота reasoning, минимум усилий со стороны разработчика.

Реальность в целом подтвердилась — если не задевать проблемные места. Там, где нет synchronized, нет повсюду ThreadLocal и не нужно дебажить потоки, все работает, как задумано. Но в продакшене быстро проявились нюансы.

Виртуальные потоки в Java: эволюция, практика, подводные камни, image #9

История № 1 — кейс Netflix

На Medium есть отличная статья о том, как Netflix экспериментировал с Java. Как только Java 21 стала стабильной, они начали переводить сервисы и решили попробовать виртуальные потоки. Система на Spring Boot, внутренняя инфраструктура, к сервису идут внешние запросы.

Включили виртуальные потоки — сначала все нормально, затем сервис перестал отвечать при живой JVM. Увидели, что число сокетов в CLOSE_WAIT улетает в небеса и бьется в потолок — приложение замирает.

Виртуальные потоки в Java: эволюция, практика, подводные камни, image #10

Пытались снимать обычные стеки (jstack) — там все idle. Потом сняли полный thread dump и увидели огромное количество тредов вида VirtualThread‑...: виртуальные потоки созданы, но ничего не делают — не прикрепились, не получили задачу, просто висят в своем внутреннем состоянии. Их было примерно столько же, сколько соединений в CLOSE_WAIT.

Виртуальные потоки в Java: эволюция, практика, подводные камни, image #11

Ковыряли дальше и увидели в стеке: некий виртуальный поток зашел в секцию BraveSpan, где операция finish() выполнялась внутри synchronized.

Виртуальные потоки в Java: эволюция, практика, подводные камни, image #12

Как только виртуальный поток входит в synchronized, он намертво прикрепляется к OS‑потоку и сможет выйти только после выхода из синхронизированного блока. Они увидели пять виртуальных потоков, ждущих освобождения ReentrantLock, и один обычный платформенный поток, тоже ждущий.

Виртуальные потоки в Java: эволюция, практика, подводные камни, image #13

Фактическая картина: было четыре carrier‑потока. Четыре VT зашли в synchronized, взяли блокировки и ждут. Следующий VT стоит в очереди, один обычный платформенный поток тоже ждет освобождения Lock, чтобы передать работу дальше, но не может — все carrier заняты «прибитыми» виртуальными потоками. Получается классический дедлок: система «готова», но ресурса нет, все ждут друг друга.

Виртуальные потоки в Java: эволюция, практика, подводные камни, image #14

Кратко, что в итоге произошло:

  • В Zipkin использовался ReentrantLock для синхронизации при логировании трейсинг‑данных, который вызывался из synchronized блока.
  • После перехода на виртуальные потоки началась деградация производительности.
  • Причина — VT входит в блокирующую операцию внутри синхронизированного блога или метода, где закрепляется за потоком ОС и не может быть «размонтирован» = дедлок.

Какие были симптомы:

  • Рост latency во всем сервисе.
  • Пропускная способность ниже, чем на обычных потоках.
  • Проблема проявлялась только под нагрузкой.

Какие выводы:

  • Synchronized небезопасен с виртуальными потоками до Java 24.
  • Виртуальные потоки могут приводить к дедлоку и занимать все свободные ОС‑потоки.

История № 2 — наш опыт с Tomcat

После перехода на Java 21 и обновления Tomcat до версии с поддержкой VT переключение оказалось действительно простым. Включили — ожидали прирост производительности, но получили обратный эффект: система хаотично «замирала» и переставала отвечать — симптоматика очень похожа на описанную выше. Но о кейсе Netflix мы узнали позже, поэтому шли своим путем.

<Executor
name="virtualThreadExecutor"
namePrefix="vt-"
className="org.apache.catalina.concurrent.VirtualThreadExecutor" />
<Connector

port="8080"
protocol="HTTP/1.1"
executor="virtualThreadExecutor"
connectionTimeout="20000"
redirectPort="8443" />

Мы наблюдали следующее: при каждом «замирании» наш платформенный поток (JavaMelody) висел на попытке взять Lock. Скорее всего, сценарий был аналогичный: как тот самый «поток у Netflix», он стоял ниже в очереди и честно ждал, пока освободится цепочка виртуальных потоков впереди.

В итоге отключили виртуальные потоки в Tomcat, и проблемы исчезли.

Что произошло:

  • После включения виртуальных потоков — резкий рост latency и залипание запросов.
  • Висящие потоки с JavaMelody на попытке взять лок в ReentrantReadWriteLock.
  • В стеках «тишина».

Какие были симптомы:

  • Tomcat начинал обрабатывать меньше запросов в секунду.
  • Сложно было отдебажить — метрики не отражали проблему.
  • После отключения виртуальных потоков в Tomcat все вернулось в норму.

Выводы по Java 21

  • Работает «из коробки». Виртуальные потоки действительно легкие: можно запускать десятки тысяч.
  • Совместимость со старым кодом — в целом «да».
  • Узкое место — synchronized. Множество библиотек, заточенных под старую модель, оказались не готовы.
  • Дебажить сложно. Проблема проявляется под нагрузкой, а еще когда инструменты для анализа не совсем готовы.

Java 23 и 24: починка самых больных мест

Structured Concurrency — пока в preview, но «по делу»

В линейке 23–24 появилась Structured Concurrency. Это инструмент, который позволяет управлять группой виртуальных потоков как единым блоком: создаем, ждем завершения, обрабатываем ошибки. А еще нет утечек и «зомби‑потоков»: все потоки завершаются структурировано и предсказуемо. Structured Concurrency стабильно работает с VT и рекомендуется для любого продвинутого многопоточного кода.

Важно: пока еще в preview, даже в Java 25. В Java 24 четвертая версия (JEP 499) улучшены отмена, обработка ошибок и совместимость с дебаггерами.

Scoped Values вместо ThreadLocal — безопасная передача контекста

Дальше — Scoped Values. Они стали stable в Java 25, поэтому рекомендую избавляться от ThreadLocal и идти к Scoped Values.

Почему так:

  • ThreadLocal плохо подходит для Loom, так как увеличивает накладные расходы при тысячах потоков и чреват утечками памяти, если не очищать вручную.
  • Scoped Values дают безопасную альтернативу — не сохраняются между задачами, работают в рамках scope. Ничего не нужно очищать вручную, задачи передаются явно. Подходят для structured concurrency, работает с StructuredTaskScope.

Для удобства собрал маленькую табличку, где отмечено, где и как стоит использовать Scoped Values вместо ThreadLocal.

Виртуальные потоки в Java: эволюция, практика, подводные камни, image #15

JEP 491 (Java 24): снят пиннинг внутри synchronized

Виртуальные потоки в Java: эволюция, практика, подводные камни, image #16

Самое важное изменение для VT — JEP 491 в Java 24. Он убирает ключевую причину замираний на Java 21: виртуальный поток больше не остается «пристегнутым» к OS‑потоку внутри synchronized.

В Java 25 это поведение становится stable. Практический эффект: можно безбоязненно пользоваться synchronized.

Улучшения стеков, дебага и JVM introspection

  • JEP 425: в Java 21–22 инструменты (jstack, профайлеры) почти не видели виртуальные потоки.
  • JEP 491 (Java 24): стек‑трейсы VT теперь полноценно отображаются в jstack, jcmd и IDE.
  • Поддержка JFR‑событий jdk.VirtualThreadPinned в VisualVM, IntelliJ и JFR.
  • Thread.getStackTrace() возвращает корректный стек даже для «размонтированного» VT.

Что (вроде бы) остается опасным даже в 24–25

На границе 24–25 Java виртуальные потоки в почти идеальном состоянии, но важно помнить про «наследие» до Java 21:

  • Библиотеки, полагающиеся на ThreadLocal, например, старые реализации MDC в логировании, становятся узким местом при миллионах виртуальных потоков.
  • Вызовы через JNI по‑прежнему блокируют carrier thread.
  • Долгоживущие VT, например, бесконечные воркеры, = неоптимальное использование.

Когда виртуальные потоки — must have, а когда лучше не надо

Хорошие кейсы

  • Web-сервисы с пулами потоков и независимыми запросами, например, Tomcat, Jetty. После Java 25, где JEP 491 стабилен, можно смело включать — обработка тысяч параллельных запросов без издержек на thread pool.
  • HTTP-клиенты — практически «бесплатный» прирост производительности, можно запускать тысячи исходящих запросов без перегрузки потоков.
  • Параллельная обработка задач внутри бизнес-логики — каждый шаг в своем виртуальном потоке, без shared state.
  • Data-pipelines и ETL другая сложная ветвистая многопоточность — также можно смело переводить = упрощение структур многопоточности и дебага.

Плохие кейсы

  • Долгоживущие потоки/воркеры. Не используйте VT так же, как использовали старые: их не нужно пулить и держать в бесконечном цикле весь runtime.
  • Неоптимизированные библиотеки с ThreadLocal и большим числом блокирующих нативных вызовов — прироста не будет, скорее наоборот.
  • Вызовы через JNI могут навсегда занять carrier thread = потеря масштабируемости.

Простые советы, если вы уже перешли на свежую Java или только готовитесь

  • Мониторьте pinned-потоки. Несмотря на прогресс, проблемы с виртуальными потоками возможны, например, из-за JNI. Следите, чтобы не поймать кейсы «зависаний», как в нашем примере и у Netflix.
  • После того как Scoped Values стали stable, переходите на них. Избегайте бесконечных циклов и обновляйте библиотеки — это закрывает уязвимости, улучшает перформанс и адаптирует код под современные возможности языка.

Что можно писать сейчас и с какой версии Java

Делюсь своей табличкой‑шпаргалкой: тип задачи → применять ли виртуальные потоки → с какой версии Java → на что обратить внимание. Можете зафиксировать ее как напоминалку и использовать при планировании миграций.

Виртуальные потоки в Java: эволюция, практика, подводные камни, image #17

Источник

220 views·13 shares