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

Денис Абрамов

Руководитель группы R&D

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

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

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

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

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

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

  • дорого создавать и держать;

  • ограничен по количеству;

  • простаивает на долгих I/O‑операциях, например, при обращениях к базе данных, по сети: поток «ждет байтики», занимая OS‑поток и ничего не делая.

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

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

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

Go | Goroutines

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

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

В коде это выглядит так: в функции 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‑потоков + можно создавать огромное количество «легковесных» задач.

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

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.

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

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

  • Создаются быстро.

  • Удешевляют конкурентность: теперь не нужно думать, как пулить — на каждую независимую операцию можно завести свой виртуальный поток.

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

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

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

  • Виртуальный поток — это по сути Runnable‑задача, которая ставится в очередь ForkJoinPool.

  • Во время блокировки, например, sleep(), I/O, виртуальный поток открепляется от carrier‑потока — в это время carrier может взять другой виртуальный поток.

  • Планировщик периодически чекает и возвращает управление, позволяя выполнять множество операций, не умирая на долгих I/O.

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

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 и не нужно дебажить потоки, все работает, как задумано. Но в продакшене быстро проявились нюансы.

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

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

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

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

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

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

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

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

  • В 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.

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

Самое важное изменение для 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 → на что обратить внимание. Можете зафиксировать ее как напоминалку и использовать при планировании миграций.

Комментарии (0)