Привет, Хабр! Я Иван Попов, ведущий инженер ЦК платформенных и интеграционных решений РСХБ-Интех. Java — мой самый любимый язык программирования, я всю жизнь работал только на нём. Сейчас я работаю в банке и хочу разрушить стереотип  о том, что в банках все работают на Vegas. На java мы очень много работаем, тем более если видим, что новая технология позволяет нам оптимизировать процессы разработки (а количество интеграций огромное). 

Расскажу о новой фиче виртуальных потоков в Java 21, которая призвана повысить эффективность многопоточного кода.

Почему виртуальные потоки так важны для разработки микросервисов и почему современные веб-фреймворки начали под капотом использовать виртуальные потоки?

Давайте для начала начнем с самого простого примера — посмотрим на синтаксис, когда мы создаем потоки, при этом у нас есть некий Runnable через executor-сервиc, и в коде у нас использовался fixed threadpool или cache threadpool или различные другие пулы, которые уже давно есть в java. Появилась альтернатива, реализующая тот же самый интерфейс, но под капотом, когда мы вызываем Submit и передаем наш Runnable, у нас это все дело будет выполняться в виртуальных потоках. Видно, что разработчики проделали большую работу: способы создания платформенных потоков и виртуальных очень похожи. Они стремились к минимуму переписывания кода.

Посмотрим, как это все работает под капотом на трех разных уровнях — JVM, операционная система и железо. Раньше, когда мы создавали объект Thread Jail, через него мы посылали команду на старт операционной системе, и операционная система имела свой планировщик и планировала выполнение этого потока на одном из наших ядер. Так же у нас происходило резервирование места в стеке. Была такая картина: обычный платформенный поток был по сути такой тонкой оберткой над потоком операционной системы. То есть практически это было одно и то же, можно было поставить знак равенства.

Но вот появились виртуальные потоки. В чем их отличие? В первую очередь, в том, что они напрямую не связаны с потоком операционной системы и со стеком. Они гораздо более легкие. Их можно создать в гигантских количествах, здесь нет каких-то жестких ограничений. Если бы мы создавали обычный Thread, то столкнулись бы с ограничением операционной системы, сотню тысяч мы бы уже не создали, потому что появились бы ограничения со стороны операционной системы из-за количества памяти. А виртуальные потоки — это по сути обычные Java-объекты,  которые разработчики Java сделали так, чтобы эффективно распределять по платформенным потокам.

Таким образом мы создаем гигантское количество виртуальных потоков.  И Java за счет своего внутреннего пула потоков эффективно распределяет эти виртуальные потоки по платформам. Давайте посмотрим детальнее.   Круги — это потоки, круги В — это виртуальные потоки, которые добавились в java 21, а P — это старые потоки.

В терминах новой Java старые называют Carrier потоки, потому что они обеспечивают выполнение виртуальных потоков, также их еще называют воркеры. Получается, что мы создаем в коде большое количество виртуальных потоков, здесь нас java не ограничивает. Они распределяются по очередям наших обычных потоков. Распределение идёт равномерное, по сути распределение обеспечивается fork join pool, который у нас был еще с 8 версии java. Здесь они ничего нового не изобрели. 

Наши виртуальные потоки оседают в очередях платформенных потоков или воркеров, и в процессе этого у нас происходит такая операция как маунд. Это значит,  что наш воркер берет виртуальный поток — виртуальную задачу — и начинает исполнять. 

Остальные воркеры тоже не простаивают. Разработчики Java обеспечили работу так, чтобы наши воркеры P1, P2, P3 постоянно были заняты работой. Когда виртуальная задача выполнилась, воркер берет следующую задачу, все работает эффективно.

В определенный момент у нас вызывается блокирующая операция в одном из виртуальных потоков.  Раньше в реактивном стеке это было проблемой. Был такой отдельный инструмент Block Hound, который позволял обнаруживать такие моменты.

В виртуальных потоках мы можем блокировать себя безопасно, потому что используется такой трюк, который называется park and mount: в этот момент у нас виртуальная задача с блокирующей операцией снимается с воркера. И наш воркер спокойно продолжает заниматься другими задачами. У нас также есть фоновый процесс Unparker: он ждет сигнала о том, что блокирующая операция завершилась, и что можно эту задачу продолжать выполнять с того места, на котором мы остановились. 

Кстати, это обеспечивается за счет новой классной штуки, которую добавили в Java,  — Continuation. Она позволяет приостановить выполнение нашего кода в любом месте, снять данные со стека и затем продолжить с этого места позднее, после того как уже получили данные.  Получается,  наши воркеры никогда не простаивают, продолжают работать. И самое главное, в виртуальных потоках мы можем сколь угодно много делать блокирующих операций. При этом они могут быть весьма долгими. Это эффективно, ведь наши воркеры не будут спать, они все время будут заниматься работой. 

Проблемы Java

Но есть с новой Java некоторые нюансы. Когда Unparker получил сигнал о том, что блокирующая операция завершилась и её можно вернуть обратно в наш Fork Joint Pool, задача может попасть в очередь уже к другому воркеру. Происходит кража работы, и с этим связан ряд ограничений. У нас поменялись воркер и стек, и с этим есть одна проблема. Обсудим, что может пойти не так.

После смены воркера адрес стека будет уже другой, потому что виртуальный поток, когда снимается с воркера,  он по сути забирает свои данные со стека, а потом, когда он маунтится на другой воркер, он кладет свои данные из кучи в стек другого воркера. Это небезопасно, поэтому разработчики решили эту проблему следующим образом. 

Они сделали так, что по сути наши виртуальные потоки просто перестают работать в таком кейсе: если вызвать блокирующую операцию, то у нас воркер заблокируется. Если блокирующая операция предполагает ожидание данных, то воркер будет ждать. Он не сможет выполнять следующие задачи своей очереди.

В java 21 есть еще одна проблема: если блокирующая операция выполнилась, в этом случае это ограничение называется пининг. Виртуальный поток пинится на своем воркере, они становятся единым целым, и когда у нас встречается блокирующая операция все еще внутри этого блока, воркеры временно выходят из игры — на столько, на сколько длится блокирующая операция. Конечно, другие воркеры могут из очереди уснувшего воркера забрать его задачи, но если у нас вот этот вот пининг происходит массово, то мы можем усыпить большое количество наших воркеров, и пропускная способность упадёт.

Воркеров не так много, и уснувший воркер — это плохо.  Нам обещают, что в следующей Java синхрониза блоков можно будет не бояться. Одна из ключевых метрик, которую позволит нам улучшить виртуальные потоки — это по сути пропускная способность. Улучшать процессную способность — это значит увеличивать количество потоков. Виртуальных потоков мы создаем большое количество, но под капотом потоков все так же мало. По сути выигрыш здесь происходит за счет того, что у нас сокращается среднее время обработки запросов, ведь мы не ждем завершения блокирующей операции, а наши ворки продолжают работать.

У нас, конечно, есть и альтернативные способы — переписать на React вполне себе вариант, но не все любят разбираться в огромном количестве операторов на Mono и Flux. Способ вполне себе годный, но виртуальные потоки — это как альтернатива, причем если хорошо владеть и реактивными программируемыми виртуальными потоками, то можно обнаружить, что их можно использовать вместе, они прекрасно дополняют друг друга.

Посмотрим на конкретном примере. У нас есть инстанс микросервиса, рест-контроллер, а количество платформенных потоков ограничено. Традиционно мы держали в пуле небольшое количество платформенных потоков, которые обрабатывали большие запросы. Теперь же с виртуальными потоками мы можем создавать виртуальный поток на каждый запрос. Пусть даже к нам сотни тысяч запросов в инстанс микросервиса прилетят, мы можем использовать виртуальный поток на запрос. И Java сама позаботится о том, чтобы эффективно распределить виртуальные потоки по воркерам. А мы можем делать в этих виртуальных потоках блокирующие операции, например, писать сообщения в другой микросервис, ожидать ответ от базы данных.

Можно не бояться писать простой блокирующий код, который не возвращает CompatibleFuture. А если есть CompatibleFuture, то можно делать get и join. Да, мы заблокировались, но за счёт вот этих трюков в Java это всё не страшно.

А на уровне операционной системы у нас все по-старому. Операционная система не знает про эти виртуальные потоки, она видит только обычные потоки, как обычно.  Планировщик операционной системы планирует их выполнение на том или ином ядре.  

Максимальный выигрыш от виртуальных потоков мы получаем в  I/O bound задачах, в микросервисах, которые много общаются с другими микросервисами. Идеальный пример — это какой-нибудь микросервис-агрегатор данных из других микросервисов, который делает много запросов. При этом его код можно оставить очень простым, делать блокирующие операции при условии, что мы убедились, что все в виртуальных потоках.

Для I/O bound задач придумали такие решения, как реактивное программирование и асинхронное программирование, но виртуальные потоки являются прекрасным третьим компонентом, который тоже может побороть задачу. И у нас I/O операций может быть очень много,  это обмен данными с базой данных, брокером сообщений, чтение данных с жесткого диска.

Формула виртуальных потоков очень проста. Это fork-join-pool и continuation. Continuation – это такой специальный класс,  который позволяет приостановить поток в нужном месте, снять данные и перекинуть из стека в кучу, а затем возобновить его работу спустя некоторое время. 

Мы можем использовать простые блокирующие операции, которые не возвращают нам CompatibleFuture, при этом у нас по-прежнему код останется таким же эффективным.  Виртуальные потоки – прикольная тема. Проблема в том, что вся магия спрятана, но вот в Java 21 она работает не всегда из-за опасности синхронизации с блоками. Я считаю, что все равно имеет смысл попробовать. 

Главный принцип, когда речь идет о перформансе, – это измерять и не гадать. Надо провести нагрузочное тестирование и убедиться, что действительно будет хороший рост производительности. Основные бенефиты — это высоконагруженный микросервис с сотнями тысяч запросов, которые надо обрабатывать одновременно. Чем больше эта цифра, тем больше бенефитов. 

Напишите в комментариях, стоила ли игра свеч и стоило ли разработчикам java проделывать колоссальную работу по добавлению новой фичи в продукт.  

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


  1. Sabirman
    11.06.2025 13:24

    А есть оценка реальной пользы от "виртуальных потоков". Декларируется, что выигрыш получается за счет уменьшения переключения контекста. Но засыпание и обычного, и "виртуального" потоков обычно связаны с какой-либо файловой операцией (в т.ч. сетевой операцией). А файловая операция обычно предполагает переключение контекста. Т.е. переключение контекста в итоге все равно происходит. И тогда какой смысл в виртуальных потоках..


    1. Uint32
      11.06.2025 13:24

      Переключение между thread ОС - штука дорогая (связана с походом в ядро), и в реальной жизни, само переключение между несколькими десятками тысяч потоков займёт всё доступное время CPU.

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

      Что касается операций io - то там другие методы борьбы с суровой реальностью (посмотрите epoll и родственные)


      1. Sabirman
        11.06.2025 13:24

        epoll (который сейчас обычно используется) как раз предполагает переключение контекста. Т.е. от переключения контекста "виртуальные потоки" не избавляют. Вот и вопрос, какой от них реальный толк ?


        1. Uint32
          11.06.2025 13:24

          Epoll живёт сбоку, в планировщике, который и поставит заинтересованный виртпоток на выполнение, когда соответствующее io завершится.


        1. dyadyaSerezha
          11.06.2025 13:24

          Толк один - не создается куча дорогих реальных потоков.


      1. SimSonic
        11.06.2025 13:24

        Кроме того, если их реально много, не стоит и про размер стека забывать, 1000 потоков уже 1 Гб.


    1. JavaChampion Автор
      11.06.2025 13:24

      Если код исполняется в виртуальном потоке, Java под капотом вызовет вместо блокирующего API операционной системы, более оптимальный неблокирующий аналог, такой как epoll. Виртуальные потоки - это по сути задачи, которые исполняет отдельный ForkJoinPool. Когда исполнение дошло до блокирующей операции, Java заменяет её на неблокирующий аналог, на результат подписывается отдельный специальный поток-unparker и как только от ОС поступает сигнал, что данные получены, этот unparker отправляет задачу обратно в ForkJoinPool, чтобы она продолжила исполняться.

      Важно, что в ForkJoinPool не должно происходить никаких блокировок, иначе получаем pinning-проблему, вероятность который всё меньше и меньше с каждой новой версией джавы )

      Чем больше блокирующих операций, или чем они дольше, тем больше бенефит от такого подхода. Но кажется, тут каждый отдельный кейс уникален и без нагрузочного тестирования не обойтись. Благо, это не сложно написать код так, чтобы виртуальные потоки можно было легко выключить, если нагрузочное тестирование покажет, что выигрыша от них нет. Благо контракты не меняются: VirtualThread extends Thread и есть реализация ExecutorService, которая использует виртуальные потоки. Также есть флаг в spring boot и многих других фреймворках, чтобы легко включить или выключить виртуальные потоки:

      spring.threads.virtual.enabled: true


  1. SimSonic
    11.06.2025 13:24

    Пожалуйста, не путайте Reactor (то, что вы имели ввиду) и React (JS SPA-фреймворк).

    Несмотря на то, что виртуальные потоки в Java 21 релизнулись, всё-таки их очень сложно использовать вне контекста, как вы сказали, микросервиса http-аггрегатора. Далеко не каждый микросервис такой, ну только если частично внедрять, но там уже риски случайно использовать не тот пул и попасть на пиннинг из-за synchronized.

    Я пытался эту проблему зарешать в общем виде с помощью агента, но потерпел фиаско :) Даже внутри ConcurrentHashMap есть synchronized... https://github.com/SimSonic/reentrantlock-java21agent

    Стоило бы упомянуть, что наконец-то в Java 24 проблему пиннинга на нём победили, и если вам интересны виртуальные потоки, то для вас скорее 24 — уже необходимый минимум.


    1. JavaChampion Автор
      11.06.2025 13:24

      Да, имелся в виду Project Reactor.

      Согласен, что внедрять надо аккуратно. В случае http-аггрегатора польза будет максимальная, но возможны другие кейсы. Например, если сервис получает обновления в реальном времени с биржи по веб-сокетам и грузит в какую-то внутреннюю систему: здесь пропускная способность очень важна и можно рассмотреть использование виртуальных потоков.

      Что касается synchronized - действительно часто встречается внутри ThreadSafe структур данных. Но важно помнить, что с проблемой столкнёмся только в том случае, если внутри synchonized блока есть блокирующая операция, или вызов wait метода. В Java 21 есть стандартный способ проверить, что нет проблемы с pinning: есть флаг -Djdk.tracePinnedThreads=full. И, чтобы проверить, что мониторинг пиннинга реально работает, можно специально, при старте сервиса запустить код, который вызовет пиннинг на секунду, при старте сервиса:

      BlockingOperation.java:

      import static java.lang.Thread.currentThread;
      import static java.lang.Thread.sleep;

      import java.time.Duration;
      import lombok.RequiredArgsConstructor;

      @RequiredArgsConstructor
      public class BlockingOperation implements Runnable {
      private final Duration duration;

      @Override
      public void run() {
      try {
      sleep(duration.toMillis());
      } catch (InterruptedException e) {
      currentThread().interrupt();
      }
      }

      }

      SyncronizedBlockingOperation.java:

      import static java.util.concurrent.Executors.newVirtualThreadPerTaskExecutor;

      import java.time.Duration;

      public class SynchronizedBlockingOperation extends BlockingOperation {

      public SynchronizedBlockingOperation(Duration duration) {
      super(duration);
      }

      public static void createAndRun(Duration duration) {
      try (final var executor = newVirtualThreadPerTaskExecutor()) {
      executor.execute(new SynchronizedBlockingOperation(duration));
      }
      }

      @Override
      public synchronized void run() {
      super.run();
      }

      }

      В main методе:

      package ru.rshb.fixadapter;

      import java.time.Duration;
      import org.springframework.boot.SpringApplication;
      import org.springframework.boot.autoconfigure.SpringBootApplication;

      @SpringBootApplication
      public class FixAdapterApplication {

      public static void main(String[] args) {
      SpringApplication.run(FixAdapterApplication.class, args);
      SynchronizedBlockingOperation.createAndRun(Duration.ofSeconds(1));
      }

      }


      В Java 24 проблему действительно решили. Там пиннинг практически невозможно вызвать. Но теоретически всё ещё возможно, при вызове блокирующей операции из нативного кода.

      Мы у себя в компании часть сервисов обновим до Java 25 LTS сразу, спустя несколько месяцев после того как она выйдет осенью этого года.


  1. dyadyaSerezha
    11.06.2025 13:24

    Не говоря про ужасный русский, замечу по сути: сначала говорится, что виртуальный поток с блокирубщей операцией просто снимается с реального потока:

    трюк, который называется park and mount: в этот момент у нас виртуальная задача с блокирующей операцией снимается с воркера. И наш воркер спокойно продолжает заниматься другими задачами.

    Потом говорится ровно обратное - что реальный поток тоже блокируется:

    наши виртуальные потоки просто перестают работать в таком кейсе: если вызвать блокирующую операцию, то у нас воркер заблокируется.

    Причём, там нет ещё ни слова про synchronized.

    Как-то надо тщательнее.

    В целом, очень сумбурные текст.


  1. kmatveev
    11.06.2025 13:24

    Мда. Во-первых, надо поработать над языком, написано ужасно. Во-вторых, надо поработать над оформлением, набор красно-чёрных картинок занимает 70% вертикального пространства статьи, нафиг не надо. В третьих, надо поработать над структурой.


  1. Kelbon
    11.06.2025 13:24

    Перевод плохой, но вкратце что я прочитал:

    1. в Java добавили стекфул корутины и заменили ими старые треды. Назвали их опять по новому, потому что надо придумать базворд менеджеру джавы

    2. добавили плохо, с очень плохим сломом обратной совместимости, так что неявные мьютексы создаваемые в sync блоках могут анлочиться на другом потоке и это undefined behavior

    3. тредпул с кражей (если точнее - написали тредпул как вышло, долго не думая), ошибка новичка, перфоманс скорее всего хуже чем было до


    1. NadyaRumak
      11.06.2025 13:24

      Это статья написана по итогам выступления Ивана на конференции. Перевода никакого не было.


    1. NightBlade74
      11.06.2025 13:24

      Сказ про то, как в Java наконец добавили TPL, но до конца не сдюжили. На самом деле в 24-й допилили кое-что.

      Ваш п.3 - общая проблема, любая смесь тасков и классических потоков внутри одного флоу может привести к проблемам блокировки. К счастью, есть механизмы, которые могут заставить отдельные таски выполняться в рамках только одного рабочего потока, но это может привести уже к неэффективности.


  1. Krokochik
    11.06.2025 13:24

    Полотно из изображений на 20 экранов вызвало истерический смех


  1. Gabenskiy
    11.06.2025 13:24

    А ничего, что уже 25 джава скоро и про эти потоки уже писали на Хабре?


  1. ermadmi78
    11.06.2025 13:24

    С терминологией всё как то совсем плохо:

    Нам обещают, что в следующей Java синхрониза блоков можно будет не бояться.

    Может "synchronized блоки" имелись ввиду? Термин synchronized в русскоязычной технической литературе не принято переводить на русский. И, даже если и переводить, то как "синхронизированный блок" а не как "синхрониза блока".

    Можно не бояться писать простой блокирующий код, который не возвращает CompatibleFuture.

    Во первых, что такое "CompatibleFuture" в Java? Где вы такое увидели? Может вы хотели сказать "CompletableFuture"?

    Во, вторых, где вы видели "блокирующий код", возвращающий "CompletableFuture"? Это же масло маслянное получается. Весь смысл "CompletableFuture" состоит в том, что её возвращает "неблокирующий код", запуская асинхронную операцию в другом потоке, которая оповещает о своём завершении и результате выполнения операции с помощью "CompletableFuture". А блокироваться на Future или нет - это как вызывающий поток решит.

    И почему я должен бояться "блокирующего кода", который зачем то вернёт мне "CompletableFuture", и не бояться "простого блокирующего кода" который возможно начнёт выполнять тяжелые вычисления, или, хуже того, I/O операции?

    PS

    Стойкое ощущение, что у автора просто каша в голове. Читайте лучше спецификацию, а не такие статьи. Там простым понятным языком написано:

    The operating system schedules when a platform thread is run. However, the Java runtime schedules when a virtual thread is run. When the Java runtime schedules a virtual thread, it assigns or mounts the virtual thread on a platform thread, then the operating system schedules that platform thread as usual. This platform thread is called a carrier. After running some code, the virtual thread can unmount from its carrier. This usually happens when the virtual thread performs a blocking I/O operation. After a virtual thread unmounts from its carrier, the carrier is free, which means that the Java runtime scheduler can mount a different virtual thread on it.

    A virtual thread cannot be unmounted during blocking operations when it is pinned to its carrier. A virtual thread is pinned in the following situations:

    • The virtual thread runs code inside a synchronized block or method

    • The virtual thread runs a native method or a foreign function (see Foreign Function and Memory API)

    Pinning does not make an application incorrect, but it might hinder its scalability. Try avoiding frequent and long-lived pinning by revising synchronized blocks or methods that run frequently and guarding potentially long I/O operations with java.util.concurrent.locks.ReentrantLock.