Java Virtual Threads: углублённый разбор
Подробный разбор виртуальных потоков Java: pinning, планирование и миграция кода с платформенных потоков.
Платформенные потоки — единственный вид потоков в Java вплоть до JDK 21 — отображаются один к одному на потоки операционной системы. Они дорогостоящи: каждый резервирует около мегабайта стека, а планировщик ОС начинает захлёбываться переключением контекста уже при нескольких тысячах потоков. Виртуальные потоки, созданные в рамках Project Loom, снимают это ограничение. Это лёгкие потоки, управляемые JVM, а не ОС, поэтому одна программа может запускать миллионы из них. В этой главе мы идём дальше введения: разбираем, как они планируются, что такое pinning, как структурированный параллелизм связывает их жизненные циклы, и где они помогают (а где нет).
Если вы только знакомитесь с темой, сначала прочитайте введение в виртуальные потоки; эта глава предполагает, что вы уже знаете, как запустить виртуальный поток. Также пригодятся знания о многопоточности в Java и фреймворке executor.
Платформенные потоки vs. виртуальные потоки
Виртуальный поток — это всё тот же java.lang.Thread с тем же API и тем же Runnable. Разница в том, что за ним стоит. Платформенный поток является потоком ОС на протяжении всей своей жизни. Виртуальный поток выполняется на небольшом пуле платформенных потоков, называемых носителями: когда он блокируется на операции I/O, JVM снимает его с носителя, освобождая носитель для другого виртуального потока, и повторно монтирует виртуальный поток, когда I/O завершается. Заблокировать виртуальный поток дёшево; заблокировать платформенный поток — значит впустую расходовать дефицитный ресурс.
| Аспект | Платформенный поток | Виртуальный поток |
|---|---|---|
| На основе | Одного потока ОС | Пула потоков-носителей |
| Затраты памяти | ~1 МБ фиксированного стека | Несколько сотен байт, растёт по мере необходимости |
| Практическое количество | Тысячи | Миллионы |
| Лучше всего подходит для | CPU-задач | I/O-задач с высокой конкурентностью |
| При блокировке | Тратит впустую поток ОС | Снимается; носитель переиспользуется |
| Жизненный цикл | Пул и переиспользование | Создаётся на задачу, одноразовый |
Ментальная модель переворачивается. С платформенными потоками вы тщательно задаёте размер пула и переиспользуете потоки. С виртуальными потоками вы создаёте один на задачу и позволяете ему завершиться — они достаточно дёшевы, чтобы быть одноразовыми.
Создание виртуальных потоков
Существует три идиоматических способа. Для одной задачи используйте строитель Thread.ofVirtual() или сокращение Thread.startVirtualThread; для многих задач — executor с одним виртуальным потоком на задачу.
// One-off, started immediately.
Thread t = Thread.startVirtualThread(() ->
System.out.println("hi from " + Thread.currentThread()));
t.join();
// Builder: configure before starting.
Thread named = Thread.ofVirtual().name("worker-", 0).unstarted(() -> doWork());
named.start();
// Many tasks: the executor creates a fresh virtual thread per submitted task.
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 1_000; i++) {
executor.submit(() -> handleRequest());
}
} // close() waits for every task to finishНикогда не объединяйте виртуальные потоки в пул. Традиционный пул фиксированного размера намеренно ограничивает параллелизм; оборачивание виртуальных потоков в Executors.newFixedThreadPool(...) лишает их всего преимущества. Правильный инструмент — newVirtualThreadPerTaskExecutor(), который не накладывает ограничений на размер.
Планирование, носители и pinning
Виртуальные потоки планируются выделенным ForkJoinPool, количество рабочих потоков которого по умолчанию равно числу ядер CPU. Эти рабочие потоки и есть носители. Когда виртуальный поток попадает в блокирующий вызов JDK — Thread.sleep, чтение из сокета, BlockingQueue.take — среда выполнения снимает его, чтобы носитель мог запустить что-то другое.
Иногда виртуальный поток не может быть снят и остаётся прикреплённым к своему носителю. Это называется pinning, и именно он обесценивает преимущество: заблокированный, но «приколотый» виртуальный поток удерживает носитель. Такое происходит в двух ситуациях:
| Причина pinning | Почему это происходит | Решение |
|---|---|---|
Внутри блока/метода synchronized | Монитор привязан к носителю | Заменить на ReentrantLock |
| Внутри нативного вызова (JNI) | Среда выполнения не может захватить нативный стек | Избегать блокировки в нативном коде |
// Pins the carrier while sleeping — bad.
synchronized (lock) {
Thread.sleep(1000); // the virtual thread cannot unmount here
}
// Does not pin — good.
lock.lock();
try {
Thread.sleep(1000); // the virtual thread unmounts freely
} finally {
lock.unlock();
}Диагностировать pinning можно, запустив приложение с параметром -Djdk.tracePinnedThreads=full — при каждом прикреплении виртуального потока к носителю будет выводиться трассировка стека.
Структурированный параллелизм
Бессистемное порождение потоков приводит к утечкам: если одна подзадача падает, её «соседи» продолжают работать, и вам нужно не забыть отменить их вручную. Структурированный параллелизм (StructuredTaskScope, API предварительного просмотра) заставляет группу подзадач вести себя как единая единица работы — они ветвятся вместе, ожидаются вместе и отменяются вместе. Когда родительская область выходит, все дочерние потоки гарантированно завершены.
import java.util.concurrent.StructuredTaskScope;
Response handle() throws Exception {
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
var user = scope.fork(() -> fetchUser()); // subtask 1
var order = scope.fork(() -> fetchOrder()); // subtask 2
scope.join(); // wait for both
scope.throwIfFailed(); // propagate the first failure, cancel the rest
return new Response(user.get(), order.get());
} // both subtasks are guaranteed finished or cancelled here
}ShutdownOnFailure отменяет оставшиеся подзадачи, как только одна из них бросает исключение; ShutdownOnSuccess возвращает результат, как только первая подзадача завершается успешно (удобно для гонки дублирующих запросов). В любом случае потоков-сирот не остаётся. Полный API и дополнительные паттерны описаны в главе о структурированном параллелизме.
Практический пример: десять тысяч конкурентных задач
Программа ниже отправляет 10 000 I/O-задач — каждая просто спит 50 мс, имитируя сетевой вызов — в executor с одним виртуальным потоком на задачу. Она считает, сколько различных потоков-носителей фактически выполняло работу, и сравнивает реальное время выполнения с последовательным запуском тех же задач.
Что можно вынести из результатов выполнения:
- 10 000 задач завершаются, а всё выполнение укладывается значительно меньше чем в секунду — далеко от ~500 000 мс, которые потребовались бы при последовательном выполнении тех же задержек, потому что всё ожидание перекрывается.
- Количество потоков-носителей равно числу ядер CPU (
Carrier threadsсовпадает сAvailable cores): тысячи виртуальных потоков мультиплексируются на эту горстку платформенных потоков. Thread.sleepснимает виртуальный поток с носителя — именно поэтому столь малое количество носителей может обслуживать столько задач одновременно: носитель никогда не простаивает в ожидании.- Закрытие
newVirtualThreadPerTaskExecutor()в блоке try-with-resources блокируется до завершения каждой отправленной задачи, поэтому счётчик завершённых задач всегда достигает 10 000 до вывода времени. isVirtual()возвращаетtrue, аisDaemon()возвращаетtrue— виртуальные потоки всегда являются потоками-демонами, поэтому они никогда самостоятельно не удерживают JVM от завершения.
Когда использовать виртуальные потоки (а когда нет)
Виртуальные потоки выгодны, когда ваши задачи проводят большую часть времени в ожидании — сети, базы данных, файловой системы или внешнего сервиса. Это типичная форма серверной работы, поэтому стандартный совет прост: используйте один виртуальный поток на запрос и пишите обычный блокирующий код.
Они не ускоряют CPU-задачи. Задача, которая непрерывно вычисляет, никогда не блокируется и никогда не снимается; запуск миллиона таких задач лишь добавляет накладные расходы на планирование. Для чистых вычислений вместо этого задайте размер пула равным числу ядер. Ещё две вещи, которые стоит учитывать:
- Проверьте горячие пути на наличие блоков
synchronized, оборачивающих блокирующие вызовы, и перейдите наReentrantLock, чтобы избежать pinning. - Не кешируйте и не объединяйте виртуальные потоки в пул, и не полагайтесь на thread-local состояние для ограничения конкурентности — если нужно дросселировать, используйте семафор или другой явный ограничитель.