Виртуальные потоки (JEP 425) являются мощным инструментом для построения приложений, которые в теории могут обслуживать десятки тысяч запросов в секунду. Однако это в теории. Практика же может выглядеть совершенно иначе. Данная статья рассказывает о нескольких подводных камнях, из-за которых виртуальные потоки могут не сработать, а сделать даже хуже.

Виртуальные потоки наглядно на практике
Виртуальные потоки наглядно на практике

Для начала напомню, зачем они вообще понадобились. Они нужны для того, чтобы писать в синхронном стиле серверные приложения в модели "thread-per-request", но при этом быть по факту неблокирующими.

Рассмотрим простое приложение на Spring Boot. Пусть в нем есть три метода A,B,C. Метод C особенный, в том плане, что при вызове его происходит помимо прочего вызов другого веб-приложения. Которое имеет большое время отклика (допустим, 0.5 секунды).

Теперь вспоминаем, как работает Spring Boot по умолчанию. Под капотом у него находится старый добрый Tomcat. У которого по умолчанию 200 потоков на обслуживание запросов. Это значит, что если к нам придет 400 пользователей (200/0.5) в секунду для вызова метода C, то Tomcat больше не сможет принимать никаких других запросов, пока не обслужит эти. При этом большую часть времени приложение будет по факту простаивать. Оно ждет ответа по сети. При этом, казалось бы, ничто не мешает обслуживать клиентов с запросами к методам А и B. Более того, ничто не мешает принимать запросы и для метода C, пускай на каждый требуется 0.5 секунды, но что мешает ответить через полсекунды на тысячу запросов?

Собственно, виртуальные потоки и призваны решить эту проблему. Начиная с версии Spring Boot 3.4, где они включены по умолчанию (впрочем, включить их можно было и в более ранних версиях Spring) Tomcat больше не будет себя ограничивать в плане виртуальных потоков (но будет, естественно, в плане платформенных потоков). Когда приходит очередной запрос, на него выделяется отдельный виртуальный поток. Когда дело доходит до блокирующей операции (вызова стороннего приложения), виртуальный поток снимается с платформенного, и ставится в очередь ожидания. Он вернется на платформенный поток, когда будет получен ответ.

Т.о., мы получаем плюсы от работы в стиле Netty, но при этом пишем в стандартном синхронном стиле, без всяких реактивных тонкостей. Можно получить очень хороший прирост производительности, просто обновив Spring Boot до новой версии... казалось бы, ан нет.

На практике при миграции существующих приложений на Java 21 и стек, поддерживающий работу с виртуальными потоками, можно обнаружить, что никаких ускорений нет.

Чаще всего так бывает, когда используемые библиотеки производят действия, которые не позволяют виртуальному потоку отсоединиться от платформенного. Чаще всего это происходит, когда виртуальный поток блокируется в секции synchronized. JEP 491 призван решить эту проблему, и после обновления до Java 24 эти библиотеки должны работать нормально.

Однако на практике зачастую даже апгрейд до 24-й Java не помогает. Ниже описаны два кейса, встретившиеся на практике:

Библиотека поддерживает виртуальные потоки... морально

Ключевое правило при попытке воспользоваться виртуальными потоками - это убедиться, что все используемые компоненты тоже их поддерживают. Однако что это значит на практике?

Вернемся еще раз к методу C, который вызывает сторонний веб-сервис. Как он его вызывает? Чаще всего с помощью какого-то HTTP-клиента. Один из наиболее часто используемых - это Apache Http Client. Утверждается, что он поддерживает виртуальные потоки. Впрочем, утверждается, это сильно сказано. Это утверждается в Release Notes:

Compatibility with Java Virtual Threads and Java 21 Runtime.

Круто, но что это значит? Документация по использованию HttpClient не показывает никаких примеров, как это использовать. Все примеры крутятся на основе PoolingHttpClientConnectionManager. Само название как бы намекает на пул соединений. Это резко противоречит идеям JEP 425, секции под говорящим названием Do not pool virtual threads:

Developers will typically migrate application code to the virtual-thread-per-task ExecutorService from a traditional ExecutorService based on thread pools. Thread pools, like all resource pools, are intended to share expensive resources, but virtual threads are not expensive and there is never a need to pool them.

Так как же Apache Client поддерживает виртуальные потоки? Вот что сказано по этому поводу в релизе HttpComponents:

This is the first ALPHA release in the 5.4 release series that improves HTTP protocol support by ensuring conformance to the latest HTTP specification (RFC 9110, RFC 9111, RFC 7616, RFC 7617), ensures compatibility with Java Virtual Threads by replacing 'synchronized' keywords in critical sections with Java lock primitives. The HTTP caching protocol layer has also been redesigned and overhauled to improve cache efficiency and optimize performance.

Иными словами, переписывать ничего не стали, просто заменили секции synchronized (что стало бессмысленным с появлением Java 24) на примитивы, и на том и успокоились. Однако клиент по прежнему работает на основе пула соединений, и после его исчерпания новые виртуальные потоки должны будут ждать, пока не обслужат предыдущих (даже если пропускная способность позволяет отправить гораздо больше запросов).

Т.е. эта поддержка виртуальных потоков - это не поддержка, она чисто для галочки, что она есть, но ничего не решает. Чтобы работало как надо, придется переходить на какие-то другие решения, которые позволят использовать виртуальные потоки на полную.

Базы данных наносят ответный удар

Базы данных стоят особняком. В том плане, что соединения с ними, как правило, представляют как раз пул. Даже если бы разработчики приложения вдруг захотели в каждом потоке открывать новое соединение, вряд ли это позволит сделать DBA.

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

  • Делаем запрос к базе данных на получение дополнительной информации.

  • Отправляем дополнительные запросы другим веб-сервисам.

  • Сохраняем что-то в базу данных.

  • Отдаем ответ клиенту.

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

Например, при использовании Spring JPA стоит не забывать отключать такую опцию, как spring.jpa.open-view (по умолчанию она включена). В противном случае, после запроса на первом шаге соединение не будет возвращено в пул соединений. Оно останется висеть, пока запрос не будет выполнен до конца (детали, например, здесь). Т.е. от модели "thread-per-request" мы внезапно переходим к модели "dbconnection-per-request". Если учесть, что на приложение обычно дают не больше 10-20 соединений в пуле, производительность не впечатляет.

Выводы

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

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


  1. pkokoshnikov
    25.09.2025 08:53

    Так кейсы теже что и для реактивного стека по сути(он аналогично не везде нужен), плюс стоит отметить, что первый lts (25) с фиксом synchronized блоков вышел и можно брать виртуальные потоки в прод


  1. yrub
    25.09.2025 08:53

    автор, ты сам понял что написал? ты что правда не понимаешь разницы между пулом потоков и пулом соединений?

    Само название как бы намекает на пул соединений. Это резко противоречит идеям JEP 425, секции под говорящим названием Do not pool virtual threads

    еще раз: пул ПОТОКОВ и пул СОЕДИНЕНИЙ. что здесь чему противоречит?

    Можно получить очень хороший прирост производительности, просто обновив Spring Boot до новой версии... казалось бы, ан нет.

    нет, ты не понимаешь разницу между параллелизмом и канкаренси, вот специально для тебя авторы виртуальных потоков писали: https://inside.java/2021/11/30/on-parallelism-and-concurrency/

    Иными словами, переписывать ничего не стали, просто заменили секции synchronized (что стало бессмысленным с появлением Java 24)

    у тебя в проде и у твоих знакомых какая версия java? у меня прямо сейчас только 17 и 21 в планах.

    Базы данных наносят ответный удар

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