
Одним из наиболее значимых нововведений в Java 21 стала стабильная поддержка виртуальных потоков. Предлагаю в этой статье разобраться с тем, почему нам нужны виртуальные потоки, как их использовать, и какие подводные камни могут нас ожидать при их применении.
Потоки платформы
Прежде, чем переходить к обсуждению виртуальных потоков, логично было бы вспомнить, как работают потоки платформы, и какие нюансы их применения являются предпосылками к появлению виртуальных потоков.
Когда мы создаём новый экземпляр класса Thread и вызываем его метод start()
, виртуальная машина Java запрашивает у операционной системы новый поток, получает его и выполняет в нём код, написанный в методе run()
.
Процесс выполнения потока выглядит примерно следующим образом:
- Операционная система создаёт поток
- Планировщик начинает выполнять поток
- В процессе выполнения потока появляется блокирующая операция
- Планировщик снимает поток с выполнения (и переключается на выполнение другого потока)
- Планировщик периодически проверяет возможность продолжения выполнения потока
- Если ожидание потока закончилось, то планировщик при возможности продолжает его выполнять до завершения или до следующей блокирующей операции
Первый нюанс, который следует учитывать, заключается в том, что само создание потоков требует ресурсов системы, как процессорного времени, так и оперативной памяти. И если мы создаём потоки для задач, которые выполняются продолжительный промежуток времени, то такой подход вполне приемлем, особенно, если мы используем созданные потоки повторно. Да, мы можем заранее выделить пул потоков для решения определённых задач, но всех проблем пулы не решают.
Если мы создаём новый поток на каждую задачу (TCP-соединение, HTTP-запрос и т.д.), то такой подход может оказаться неэффективным, особенно когда задач много, и они выполняются быстро.
1 2 3 4 5 6 |
ServerSocket serverSocket = new ServerSocket(8080); while (true) { Socket clientSocket = serverSocket.accept(); new Thread(() -> handleRequest(clientSocket)) .start(); } |
И проблемой это является сразу по нескольким причинам:
- Системе может потребоваться больше ресурсов на создание потока, чем на выполнение задачи.
- Количество потоков, которое может быть создано, конечно и зависит от операционной системы, количества процессоров и объёма оперативной памяти, доступных системе. Так, например, на виртуальной машине под управлением Linux с 4 процессорами и 4 Гб оперативной памяти можно создать чуть более 15 000 потоков.
- Большое количество потоков приводит к снижению производительности, так как планировщику операционной системы становится сложнее переключаться между потоками, что в итоге может привести к голоданию потоков (thread starvation).
Кроме этого стоит помнить и о том, что блокирующие операции переводят потоки в состояние ожидания (WAITING
) или временного ожидания (TIMED_WAITING
), а потоки в этом состоянии, хоть и бездействуют, но всё равно требуют на себя внимания планировщика операционной системы.
В итоге имеем следующие недостатки использования потоков платформы:
- Создание потоков требует ресурсов
- Количество потоков, которое можно создать, ограничено
- Большое потоков приводит к снижению производительности системы
- Ожидающие потоки бездействуют, но требуют ресурсов
Асинхронный и реактивный код
Наверняка, многие вспомнят про асинхронные и реактивные фреймворки и библиотеки, вроде Project Reactor, RxJava и другие, ведь они позволяют выполнять параллельно большое количество задач, более эффективно используя ресурсы системы. Но и тут есть свои нюансы.
Асинхронные API работают эффективно за счёт отсутствия в коде блокирующих операций. Если требуется обратиться к БД, то это нужно делать в асинхронном стиле (R2DBC, реактивные драйверы), если требуется обратиться к стороннему REST API, то веб-клиент должен быть тоже асинхронным и т.д. Как только в асинхронном коде появляется блокирующая операция, его эффективность снижается до обычного синхронного кода. Да, современные фреймворки предоставляют инструменты, позволяющие выносить выполнение блокирующих операций в отдельный пул потоков, но этот пул конечен и не решает проблемы неэффективного использования потока.
Кроме этого асинхронный код существенно отличается от синхронного, его сложнее читать, понимать, отлаживать и тестировать.
В итоге асинхронный и реактивный код имеют следующие недостатки:
- Блокирующие операции недопустимы
- Код становится сложнее
Виртуальные потоки
Разработчики платформы Java при реализации поддержки виртуальных потоков преследовали целью повышение эффективности использования ресурсов системы за счёт более эффективного использования потоков платформы.
Виртуальные потоки в виртуальной машине Java не выполняются сами по себе, для этого нужны потоки-носители (CarrierThread), которые являются обычными потоками платформы. Для выполнения виртуальных потоков резервируется пул потоков-носителей, размер которого по умолчанию равен 256. При этом по умолчанию одновременно может выполняться количество виртуальных потоков, равное количеству процессоров, доступных системе. Выполнение виртуального потока выглядит следующим образом:
- Мы создаём в коде виртуальный поток и запускаем его выполнение
- Планировщик виртуальной машины Java выбирает из пула потоков-носителей свободный и передаёт ему на выполнение виртуальный поток
- Виртуальный поток исполняется внутри потока носителя до завершения, либо до появления блокирующей операции
- При появлении блокирующей операции планировщик виртуальной машины Java снимает виртуальный поток с выполнения и передаёт освободившемуся потоку-носителю другой виртуальный поток, который может быть выполнен в данный момент
- Планировщик JVM периодически проверяет, может ли ожидающий виртуальный поток продолжить своё выполнение
- Если ожидание потока завершилось, то планировщик JVM передаёт виртуальный поток свободному потоку-носителю, и виртуальный поток продолжает выполнение до конца или до следующей блокирующей операции
Согласитесь, что процесс выполнения виртуального потока похож на выполнение потока операционной системой. Эффективное использование потоков платформы достигается возможностью потока-носителя переключаться между виртуальными потоками.
Создать виртуальный поток можно тремя способами: при помощи строителя и фабрики потоков, а так же при помощи исполнителя (Executor).
1 2 3 4 |
// Получение строителя Thread.Builder.OfVirtual builder = Thread.ofVirtual(); // Запуск виртуального потока builder.start(() -> System.out.println("Hello from virtual thread!")); |
Если требуется произвести какие-то дополнительные действия перед запуском потока, то мы можем создать его, но не запускать при помощи метода OfVirtual.unstarted()
:
1 2 3 4 5 6 |
// Получение строителя Thread.Builder.OfVirtual builder = Thread.ofVirtual(); // Создание виртуального потока Thread unstarted = builder.unstarted(() -> System.out.println("Hello from virtual thread")); unstarted.setName("My virtual thread"); unstarted.start(); |
Если нам требуется создавать большое количество виртуальных потоков, которые будут иметь одинаковые параметры, то для этого мы можем воспользоваться фабрикой потоков. Фабрику потоков мы можем получить при помощи метода OfVirtual.factory()
:
1 2 3 4 5 6 7 8 |
// Получение строителя Thread.Builder.OfVirtual builder = Thread.ofVirtual(); // Получение фабрики потоков ThreadFactory threadFactory = builder.factory(); // Создание виртуального потока Thread thread = threadFactory.newThread(() -> System.out.println("Hello from virtual thread!")); // Запуск потока thread.start(); |
Так же мы можем использовать Executor для работы с виртуальными потоками, в том числе и для виртуальных потоков, возвращающих результат.
1 2 3 4 5 6 7 8 9 10 |
// Получение исполнителя виртуальных потоков try (ExecutorService executorService = Executors.newVirtualThreadPerTaskExecutor()) { // Выполнение виртуального потока executorService.execute(() -> System.out.println("Hello from virtual thread")); // Выполнение виртуального потока, возвращающего результат Future<String> future = executorService.submit(() -> { System.out.println("Hello from virtual thread"); return "какой-то результат"; }); } |
Как уже было сказано выше, виртуальные потоки выполняются в пуле потоков-носителей. Данный пул мы можем настраивать при помощи двух параметров:
jdk.virtualThreadScheduler.maxPoolSize
— максимальный размер пул потоков-носителей (по умолчанию — 256)jdk.virtualThreadScheduler.parallelism
— количество потоков-носителей, которые могут исполняться одновременно (по умолчанию равно количеству процессоров, доступных системе)
Передать эти параметры можно в командной строке: java -DvirtualThreadScheduler.parallelism=1 -Djdk.virtualThreadScheduler.maxPoolSize=1 ...
.
Если мы сконфигурируем пул указанным выше способом и попробуем выполнить следующий код, то увидим, что виртуальные потоки выполняются последовательно, так как внутри отсутствуют блокирующие операции:
1 2 3 4 5 6 7 8 9 |
CountDownLatch countDownLatch = new CountDownLatch(3); for(var i = 0; i < 3; i++) { var j = i; Thread.ofVirtual().start(() -> { System.out.printf("%s Executing %d%n", Instant.now(), j); countDownLatch.countDown(); }); } countDownLatch.await(); |
Вывод при этом будет примерно таким:
1 2 3 |
2025-04-28T10:04:56.231014112Z Executing 0 2025-04-28T10:04:56.238200826Z Executing 1 2025-04-28T10:04:56.238342547Z Executing 2 |
Если мы добавим в виртуальный поток блокирующую операцию, то увидим, что в лог достаточно быстро будут выведены первые три сообщения, а после секундной паузы — остальные три:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
CountDownLatch countDownLatch = new CountDownLatch(3); for(var i = 0; i < 3; i++) { var j = i; Thread.ofVirtual().start(() -> { System.out.printf("%s Starting %d%n", Instant.now(), j); try { Thread.sleep(1000); } catch (InterruptedException e) { } finally { countDownLatch.countDown(); } System.out.printf("%s Completing %d%n", Instant.now(), j); }); } countDownLatch.await(); |
Вывод будет примерно следующим:
1 2 3 4 5 6 |
2025-04-28T10:00:21.013798127Z Starting 0 2025-04-28T10:00:21.021557207Z Starting 1 2025-04-28T10:00:21.021705018Z Starting 2 2025-04-28T10:00:22.021646800Z Completing 0 2025-04-28T10:00:22.021978193Z Completing 1 2025-04-28T10:00:22.022123875Z Completing 2 |
Этот пример демонстрирует, что даже при пуле потоков-носителей, содержащим всего один поток, виртуальные потоки выполняются очень эффективно, так как в случае перехода виртуального потока в состояние ожидания поток-носитель переключается на выполнение других потоков.
Однако применение виртуальных потоков имеет свои нюансы, и первый из них — прикрепление потоков (Thread pinning). Чтобы увидеть, в чём выражается данная проблема, предлагаю выполнить Thread.sleep(1000)
внутри блока синхронизации:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
CountDownLatch countDownLatch = new CountDownLatch(3); Object lock = new Object(); for (var i = 0; i < 3; i++) { var j = i; Thread.ofVirtual().start(() -> { System.out.printf("%s Starting %d%n", Instant.now(), j); try { synchronized (lock) { Thread.sleep(1000); } } catch (InterruptedException e) { } finally { countDownLatch.countDown(); } System.out.printf("%s Completing %d%n", Instant.now(), j); }); } countDownLatch.await(); |
Вывод теперь будет выглядеть следующим образом:
1 2 3 4 5 6 |
2025-04-28T10:10:45.984813389Z Starting 0 2025-04-28T10:10:46.993622801Z Completing 0 2025-04-28T10:10:46.993995526Z Starting 1 2025-04-28T10:10:47.994201504Z Completing 1 2025-04-28T10:10:47.994463857Z Starting 2 2025-04-28T10:10:48.994688750Z Completing 2 |
Как можно видеть, виртуальные потоки теперь выполняются последовательно. Происходит этого из-за особенностей работы блоков синхронизации. Монитор, создаваемый при компиляции блока синхронизации в байт-код Java, в процессе исполнения программы захватывается потоком-носителем, а в состояние ожидания переходит исполняемый виртуальный поток. И в этом месте поток-носитель должен снять с выполнения ожидающий виртуальный поток, но не может этого сделать, так как для этого придётся освободить монитор, что в свою очередь может привести к гонке данных (race condition).
И данная проблема возможна в двух ситуациях:
- Использование synchronized-блоков и методов, внутри которых вызывается блокирующая операция
- Использование synchronized-блоков и методов, внутри которых вызывается нативный код, содержащий блокирующую операцию
В обоих случаях проблема может быть решена при помощи использования каких-то других механизмов синхронизации, например, ReentrantLock
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
CountDownLatch countDownLatch = new CountDownLatch(3); ReentrantLock lock = new ReentrantLock(); Condition condition = lock.newCondition(); for (var i = 0; i < 3; i++) { var j = i; Thread.ofVirtual().start(() -> { System.out.printf("%s Starting %d%n", Instant.now(), j); lock.lock(); try { condition.await(1000, TimeUnit.MILLISECONDS); } catch (InterruptedException e) { } finally { lock.unlock(); countDownLatch.countDown(); } System.out.printf("%s Completing %d%n", Instant.now(), j); }); } countDownLatch.await(); |
Первую ситуацию так же должно исправить обновление до JDK 24+, так как JEP 491: Synchronize Virtual Threads without Pinning её решает.
Кроме этого стоит помнить и о том, что виртуальные потоки выполняются в одном общем пуле потоков-носителей. И это может тоже стать проблемой, если ваш сервис выполняет множество разнородных задач: обработка HTTP-запросов, выполнение заданий по расписанию, обработка очередей сообщений и т.д. При высокой нагрузке возможна ситуация, когда обработка большого количества HTTP-запросов будет приводить к голоданию потоков и мешать выполнению других задач. Поэтому переносить все задачи на виртуальные потоки на мой взгляд не стоит.