Виртуальные потоки Java
Лёгкие потоки, управляемые JVM (Java 21+), для высоконагруженных конкурентных приложений: что они решают и как меняют подход к пулам.
Каждая глава этой части книги до сих пор описывала платформенный поток — Java Thread, который отображается один-к-одному на поток ОС. Платформенные потоки мощны, но дороги: каждый занимает около 1 МБ нативного стека, а ОС ограничивает процесс примерно десятками тысяч потоков. Для CPU-нагруженных задач этого достаточно. Для I/O-нагруженных задач — например, веб-сервера с одним потоком на запрос, который большую часть времени ждёт базы данных, — это жёсткий потолок, который был центральным противоречием при проектировании Java-серверов на протяжении двух десятилетий.
Java 21 ввёл виртуальные потоки, чтобы решить именно эту проблему. Виртуальный поток — это Java Thread, планируемый JVM (а не ОС) на небольшой пул потоков ОС-уровня, называемых несущими потоками. Они дёшевы — миллионы на JVM являются нормой — а блокировка на I/O паркует виртуальный поток, не блокируя несущий. Код выглядит так же, как раньше; модель стоимости — другая.
Что меняется (и что нет)
Виртуальные потоки — это java.lang.Thread. Класс тот же; методы те же; Thread.currentThread() по-прежнему работает. Отличие — в том, как они планируются и какова их стоимость:
- Платформенный поток занимает около 1 МБ нативного стека и планируется ОС.
- Виртуальный поток изначально занимает около 1 КБ (растёт по мере необходимости) и планируется JVM.
- Блокировка платформенного потока блокирует лежащий в основе поток ОС.
- Блокировка виртуального потока паркует виртуальный поток; несущий поток ОС переходит к выполнению другого виртуального потока.
Четвёртый пункт — ключевой. Когда виртуальный поток вызывает Socket.read(), Thread.sleep(), BlockingQueue.take(), Lock.lock() или практически любой блокирующий JDK API, JVM отсоединяет его от несущего потока, и несущий поток подхватывает другой виртуальный поток. Заблокированный виртуальный поток почти ничего не стоит, пока ждёт.
Создание виртуальных потоков
Три способа:
// 1. Direct
Thread t = Thread.ofVirtual().start(() -> doWork());
// 2. Builder
Thread t2 = Thread.ofVirtual().name("vt-", 0).start(this::work); // names "vt-0", "vt-1", ...
// 3. Executor — the production form
try (ExecutorService es = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 10_000; i++) {
es.submit(() -> handleRequest());
}
}Форма с executor — то, что использует практически каждый сервер. Она выделяет один виртуальный поток на каждую отправленную задачу; не нужно задавать размер пула, потому что пул несущих потоков самостоятельно подбирает размер.
Вы также можете получить платформенный поток, когда он вам действительно нужен:
Thread t = Thread.ofPlatform().name("compute").start(() -> doCpu());Полезно для по-настоящему CPU-нагруженной работы, где отображение один-к-одному на поток ОС — именно то, что вам нужно.
Когда виртуальные потоки выигрывают
Форма нагрузки, для которой виртуальные потоки оптимизированы:
- Много конкурентных задач (сотни, тысячи, миллионы).
- Каждая задача большую часть времени заблокирована на I/O, очередях или блокировках.
- Работа не доминируется CPU.
Это в точности форма веб-сервера: каждый запрос — задача, которая в основном ждёт базы данных, вышестоящего сервиса или клиента. С платформенными потоками серверу с 1000 одновременно медленными запросами нужно 1000 платформенных потоков — 1 ГБ нативного стека и значительная нагрузка на планировщик ОС. С виртуальными потоками та же нагрузка работает на 8 или около того несущих потоков; 1000 виртуальных потоков стоят всего несколько МБ.
Ментальная модель: перестаньте думать о пулах потоков для I/O-работы. Отправьте один виртуальный поток на запрос и позвольте среде выполнения разобраться с остальным.
Когда виртуальные потоки не выигрывают
Несколько случаев, когда они не помогают или активно вредят:
- CPU-нагруженная работа. Виртуальный поток, выполняющий чистые вычисления, не может быть припаркован — он должен работать на несущем потоке всё время. Вы не будете быстрее количества несущих потоков, которое равно количеству ваших CPU. Для CPU-работы платформенные потоки и fork/join остаются правильным инструментом.
- Блоки
synchronizedвокруг I/O. Виртуальный поток внутриsynchronized (obj) { blockingIO(); }прикрепляется к своему несущему — JVM не может отсоединить его во время блокирующего вызова, потому что монитор привязан к потоку ОС. Это реальная ловушка: сервер, использующийsynchronizedдля защиты вызова базы данных, не будет масштабироваться с виртуальными потоками. Решение — использоватьReentrantLock(который механизм виртуальных потоков обрабатывает корректно). - Хранилище
ThreadLocalпри большом количестве потоков. Виртуальные потоки поддерживаютThreadLocal, но их количество может взорваться — миллионы виртуальных потоков × N thread-local-переменных × размер значения = огромное количество памяти. Java 21 добавил scoped values (ScopedValue) как структурированную альтернативу. - Код, предполагающий, что потоков мало (например, создающий соединение на каждый поток). Одно соединение на виртуальный поток — это одно соединение на запрос, что не нравится базе данных. Используйте настоящий пул соединений.
Вывод: виртуальные потоки делают I/O-нагруженную конкурентность дешёвой, но не преобразуют CPU-нагруженную работу и обнажают пути кода, прикрепляющие к несущим потокам.
Прикрепление: единственное, за чем нужно следить
Прикреплённый виртуальный поток не может быть отсоединён. Две причины прикрепления:
- Блоки
synchronized, включающие блокирующий вызов. - Вызовы нативных методов, блокирующиеся в JNI.
Обнаружить прикрепление можно с помощью системного свойства:
java -Djdk.tracePinnedThreads=full ...Если виртуальный поток блокируется, будучи прикреплённым, JVM выводит трассировку стека. В production решение — заменить synchronized на ReentrantLock вокруг блокирующего региона. Будущие версии JDK работают над устранением прикрепления для synchronized (JEP 491 в процессе); пока следует рассматривать любой synchronized вокруг I/O-вызова как анти-паттерн виртуальных потоков.
А что насчёт wait, notify и join?
Все они работают — виртуальные потоки могут ждать на внутренних мониторах, получать уведомления и ожидаться через join. Среда выполнения корректно обрабатывает парковку и отсоединение. Ограничение касается только блоков synchronized: удержание монитора через блокирующий вызов внутри блока прикрепляет поток; вызов wait() для освобождения монитора и парковки — нормален.
synchronized (lock) {
lock.wait(); // OK — releases monitor, parks, no pin
}
synchronized (lock) {
socket.read(buf); // BAD — holds monitor through blocking read; pins
}Размер пула — пула нет
Концептуальный сдвиг, который открывают виртуальные потоки: перестать задавать размер. У каждого executor, настроенного в этой книге, был параметр количества потоков. С newVirtualThreadPerTaskExecutor количество равно «столько, сколько запросов в обработке». Пул несущих потоков (который вы напрямую не настраиваете) сам подбирает размер на основе количества CPU; виртуальные потоки — это просто учётные единицы.
На сервере, использующем виртуальные потоки:
- Пулы соединений по-прежнему важны. Виртуальный поток, ожидающий соединения, — нормально; порождение 10 000 таких, каждый из которых хочет пул из 5 соединений, просто делает узкое место очевидным.
- Ограничения скорости по-прежнему важны. Виртуальные потоки снимают ограничение по количеству потоков, но не ограничения нижестоящего сервиса.
- Память по-прежнему важна. Каждый виртуальный поток имеет стек и любые
ThreadLocal. Миллионы виртуальных потоков — это миллионы стеков.
Виртуальные потоки убирают потолок количества потоков; они не устраняют лежащие в основе ограничения, которые этот потолок скрывал.
Практический пример: миллион виртуальных потоков против одного платформенного
Программа ниже запускает 100 000 задач, каждая из которых спит 200 мс, параллельно. На платформенных потоках (ограниченных разумным числом) это занимает много времени и использует много RAM. На виртуальных потоках всё завершается немногим дольше, чем само время сна одной задачи.
Что можно вынести из запуска:
- 100 000 виртуальных потоков завершились примерно за секунду настенного времени — близко к единственному сну в 200 мс плюс накладные расходы на создание и планирование 100 000 потоков, а не 100 000 × 200 мс. В этом и есть весь смысл виртуальных потоков: конкурентность (сколько дел выполняется одновременно) отделена от параллелизма (сколько ядер выполняет CPU-работу). Точное число зависит от машины, но остаётся в диапазоне нескольких секунд независимо от того, насколько вы увеличиваете количество задач.
- Запуск 5000 задач в пуле платформенных потоков из 100 рабочих потоков занял примерно
5000 / 100 * 200 = ~10 секунд— задачи выстраивались в очередь, потому что пул мог выполнять только 100 одновременно. Чтобы завершить за то же настенное время, что и версия с виртуальными потоками, пулу платформенных потоков потребовалось бы 100 000 потоков, что близко к лимиту ОС или превышает его на большинстве систем. Thread.currentThread().isVirtual()различил два типа потоков во время выполнения. Имена тоже различаются — виртуальные потоки обычно имеют обобщённое представление, а не пользовательское имя, если только вы не задали его через builder. Удобно для логирования при смешивании двух типов.- Предупреждение о прикреплении — это самая важная оговорка для виртуальных потоков в production. Блок
synchronizedвокруг любого блокирующего вызова (I/O базы данных, файловый I/O, сеть) нивелирует большую часть преимущества, потому что несущий поток не может быть освобождён во время ожидания. ЗаменаsynchronizedнаReentrantLockсохраняет возможность парковки виртуального потока. - Форма
try (ExecutorService vexec = ...)правильно поступила при закрытии — выполнилаshutdown()и дождалась завершения каждой отправленной задачи. При 100 000 задач в полёте это ожидание было реальным (по 200 мс каждая, все припаркованы вместе, все завершаются почти одновременно). Без try-with-resources executor остался бы живым, удерживая не-daemon потоки, и программа зависла бы.
Конец части 15
Это последняя глава части «Многопоточность и конкурентность». Мы прошли путь от «поток — это вещь уровня ОС» через блокировки, атомики и конкурентные коллекции, которые вы используете для корректной работы с разделяемым состоянием, к фреймворку executor, скрывающему управление потоками, затем к CompletableFuture и ForkJoinPool для композиции, и наконец к виртуальным потокам для I/O-тяжёлой нагрузки, с которой реально сталкиваются современные серверы.
Паттерн во всём этом: выбирайте наименьший инструмент, решающий вашу конкретную проблему. Счётчик? AtomicInteger. Флаг? volatile. Производитель/потребитель? BlockingQueue. Много параллельных I/O-вызовов? Виртуальные потоки. Ключевое слово synchronized по-прежнему подходит там, где подходит; Lock — для случаев, когда оно не подходит; высокоуровневые executor-ы и future-ы — когда вы переросли и то, и другое. Спускайтесь вниз по стеку только тогда, когда абстракция выше не делает то, что вам нужно.
Следующая часть книги посвящена аннотациям — что на самом деле делают маркеры @, прикреплённые к классам, методам и полям, встроенные аннотации в java.lang и правила написания собственных.