Java: Виртуальные потоки

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

Потоки платформы

Прежде, чем переходить к обсуждению виртуальных потоков, логично было бы вспомнить, как работают потоки платформы, и какие нюансы их применения являются предпосылками к появлению виртуальных потоков.

Когда мы создаём новый экземпляр класса Thread и вызываем его метод start(), виртуальная машина Java запрашивает у операционной системы новый поток, получает его и выполняет в нём код, написанный в методе run().

Процесс выполнения потока выглядит примерно следующим образом:

  1. Операционная система создаёт поток
  2. Планировщик начинает выполнять поток
  3. В процессе выполнения потока появляется блокирующая операция
  4. Планировщик снимает поток с выполнения (и переключается на выполнение другого потока)
  5. Планировщик периодически проверяет возможность продолжения выполнения потока
  6. Если ожидание потока закончилось, то планировщик при возможности продолжает его выполнять до завершения или до следующей блокирующей операции

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

Если мы создаём новый поток на каждую задачу (TCP-соединение, HTTP-запрос и т.д.), то такой подход может оказаться неэффективным, особенно когда задач много, и они выполняются быстро.

И проблемой это является сразу по нескольким причинам:

  • Системе может потребоваться больше ресурсов на создание потока, чем на выполнение задачи.
  • Количество потоков, которое может быть создано, конечно и зависит от операционной системы, количества процессоров и объёма оперативной памяти, доступных системе. Так, например, на виртуальной машине под управлением Linux с 4 процессорами и 4 Гб оперативной памяти можно создать чуть более 15 000 потоков.
  • Большое количество потоков приводит к снижению производительности, так как планировщику операционной системы становится сложнее переключаться между потоками, что в итоге может привести к голоданию потоков (thread starvation).

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

В итоге имеем следующие недостатки использования потоков платформы:

  • Создание потоков требует ресурсов
  • Количество потоков, которое можно создать, ограничено
  • Большое потоков приводит к снижению производительности системы
  • Ожидающие потоки бездействуют, но требуют ресурсов

Асинхронный и реактивный код

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

Асинхронные API работают эффективно за счёт отсутствия в коде блокирующих операций. Если требуется обратиться к БД, то это нужно делать в асинхронном стиле (R2DBC, реактивные драйверы), если требуется обратиться к стороннему REST API, то веб-клиент должен быть тоже асинхронным и т.д. Как только в асинхронном коде появляется блокирующая операция, его эффективность снижается до обычного синхронного кода. Да, современные фреймворки предоставляют инструменты, позволяющие выносить выполнение блокирующих операций в отдельный пул потоков, но этот пул конечен и не решает проблемы неэффективного использования потока.

Кроме этого асинхронный код существенно отличается от синхронного, его сложнее читать, понимать, отлаживать и тестировать.

В итоге асинхронный и реактивный код имеют следующие недостатки:

  • Блокирующие операции недопустимы
  • Код становится сложнее

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

Разработчики платформы Java при реализации поддержки виртуальных потоков преследовали целью повышение эффективности использования ресурсов системы за счёт более эффективного использования потоков платформы.

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

  1. Мы создаём в коде виртуальный поток и запускаем его выполнение
  2. Планировщик виртуальной машины Java выбирает из пула потоков-носителей свободный и передаёт ему на выполнение виртуальный поток
  3. Виртуальный поток исполняется внутри потока носителя до завершения, либо до появления блокирующей операции
  4. При появлении блокирующей операции планировщик виртуальной машины Java снимает виртуальный поток с выполнения и передаёт освободившемуся потоку-носителю другой виртуальный поток, который может быть выполнен в данный момент
  5. Планировщик JVM периодически проверяет, может ли ожидающий виртуальный поток продолжить своё выполнение
  6. Если ожидание потока завершилось, то планировщик JVM передаёт виртуальный поток свободному потоку-носителю, и виртуальный поток продолжает выполнение до конца или до следующей блокирующей операции

Согласитесь, что процесс выполнения виртуального потока похож на выполнение потока операционной системой. Эффективное использование потоков платформы достигается возможностью потока-носителя переключаться между виртуальными потоками.

Создать виртуальный поток можно тремя способами: при помощи строителя и фабрики потоков, а так же при помощи исполнителя (Executor).

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

Если нам требуется создавать большое количество виртуальных потоков, которые будут иметь одинаковые параметры, то для этого мы можем воспользоваться фабрикой потоков. Фабрику потоков мы можем получить при помощи метода OfVirtual.factory():

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

Как уже было сказано выше, виртуальные потоки выполняются в пуле потоков-носителей. Данный пул мы можем настраивать при помощи двух параметров:

  • jdk.virtualThreadScheduler.maxPoolSize — максимальный размер пул потоков-носителей (по умолчанию — 256)
  • jdk.virtualThreadScheduler.parallelism — количество потоков-носителей, которые могут исполняться одновременно (по умолчанию равно количеству процессоров, доступных системе)

Передать эти параметры можно в командной строке: java -DvirtualThreadScheduler.parallelism=1 -Djdk.virtualThreadScheduler.maxPoolSize=1 ... .

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

Вывод при этом будет примерно таким:

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

Вывод будет примерно следующим:

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

Однако применение виртуальных потоков имеет свои нюансы, и первый из них — прикрепление потоков (Thread pinning). Чтобы увидеть, в чём выражается данная проблема, предлагаю выполнить Thread.sleep(1000) внутри блока синхронизации:

Вывод теперь будет выглядеть следующим образом:

Как можно видеть, виртуальные потоки теперь выполняются последовательно. Происходит этого из-за особенностей работы блоков синхронизации. Монитор, создаваемый при компиляции блока синхронизации в байт-код Java, в процессе исполнения программы захватывается потоком-носителем, а в состояние ожидания переходит исполняемый виртуальный поток. И в этом месте поток-носитель должен снять с выполнения ожидающий виртуальный поток, но не может этого сделать, так как для этого придётся освободить монитор, что в свою очередь может привести к гонке данных (race condition).

И данная проблема возможна в двух ситуациях:

  • Использование synchronized-блоков и методов, внутри которых вызывается блокирующая операция
  • Использование synchronized-блоков и методов, внутри которых вызывается нативный код, содержащий блокирующую операцию

В обоих случаях проблема может быть решена при помощи использования каких-то других механизмов синхронизации, например, ReentrantLock:

Первую ситуацию так же должно исправить обновление до JDK 24+, так как JEP 491: Synchronize Virtual Threads without Pinning её решает.

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

Полезные ссылки