W3docs

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 с одним виртуальным потоком на задачу. Она считает, сколько различных потоков-носителей фактически выполняло работу, и сравнивает реальное время выполнения с последовательным запуском тех же задач.

java— editable, runs on the server

Что можно вынести из результатов выполнения:

  • 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 состояние для ограничения конкурентности — если нужно дросселировать, используйте семафор или другой явный ограничитель.

Практика

Практика
Вы оборачиваете виртуальные потоки в Executors.newFixedThreadPool(200) для выполнения 10 000 I/O-задач. Почему это ошибка?
Вы оборачиваете виртуальные потоки в Executors.newFixedThreadPool(200) для выполнения 10 000 I/O-задач. Почему это ошибка?
Was this page helpful?