W3docs

Виртуальные потоки 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-нагруженную работу и обнажают пути кода, прикрепляющие к несущим потокам.

Прикрепление: единственное, за чем нужно следить

Прикреплённый виртуальный поток не может быть отсоединён. Две причины прикрепления:

  1. Блоки synchronized, включающие блокирующий вызов.
  2. Вызовы нативных методов, блокирующиеся в 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. На виртуальных потоках всё завершается немногим дольше, чем само время сна одной задачи.

java— editable, runs on the server

Что можно вынести из запуска:

  • 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 и правила написания собственных.

Практика

Практика
На сервере, использующем виртуальные потоки, вы оборачиваете вызов JDBC в `synchronized (this) { jdbc.execute(sql); }`. Каков результат?
На сервере, использующем виртуальные потоки, вы оборачиваете вызов JDBC в `synchronized (this) { jdbc.execute(sql); }`. Каков результат?
Was this page helpful?