openjdk.ruOpenJDK на русском

JEP 436: Virtual Threads (Second Preview)

Virtual Threads (виртуальные потоки), вторая версия Preview (предварительная версия)

AuthorsRon Pressler, Alan Bateman
ОтветственныйAlan Bateman
ТипFeature
ОбластьSE
СтатусClosed / Delivered
Выпуск20
Компонентcore-libs
Обсуждениеloom dash dev at openjdk dot org
Связан сJEP 425: Virtual Threads (Preview)
JEP 444: Virtual Threads
РецензентыAlex Buckley
ОдобренBrian Goetz
Создан2022/10/23 15:18
Обновлён2023/06/08 16:18
Задача8295817

Аннотация

Добавить в платформу Java Virtual Threads. Virtual Threads — это облегчённые потоки, которые радикально сокращают усилия на написание, сопровождение и наблюдение за конкурентными приложениями с высокой пропускной способностью. Это API в статусе Preview.

История

Virtual Threads были предложены как Preview-возможность в JEP 425 и вошли в JDK 19. Этот JEP предлагает вторую версию Preview, чтобы получить больше отзывов и накопить больше опыта работы с этой возможностью.

Небольшие изменения по сравнению с первой версией Preview:

  • Небольшое число изменений API, описанных в JEP 425, стали постоянными в JDK 19, поэтому здесь они не предлагаются в статусе Preview. Эти изменения сделаны постоянными, потому что касаются функциональности, которая полезна в широком круге случаев и не относится именно к Virtual Threads. Это новые методы в Thread (join(Duration), sleep(Duration) и threadId()), новые методы в Future (для проверки состояния и результата задачи), а также изменение, по которому ExecutorService расширяет AutoCloseable.

  • Описанные в JEP 425 ухудшения в работе ThreadGroup стали постоянными в JDK 19.

Цели

  • Дать серверным приложениям, написанным в простом стиле «поток на запрос», масштабироваться с почти оптимальным использованием оборудования.

  • Дать существующему коду, использующему API java.lang.Thread, перейти на Virtual Threads с минимальными изменениями.

  • Обеспечить простую диагностику, отладку и профилирование Virtual Threads с помощью существующих инструментов JDK.

Что не является целью

  • Цель не в том, чтобы удалить традиционную реализацию потоков или незаметно перевести существующие приложения на Virtual Threads.

  • Цель не в том, чтобы изменить базовую модель конкурентности Java.

  • Цель не в том, чтобы предложить новую конструкцию для параллелизма по данным ни в языке Java, ни в библиотеках Java. Stream API остаётся предпочтительным способом параллельной обработки больших наборов данных.

Мотивация

Уже почти три десятилетия Java-разработчики используют потоки как строительный блок конкурентных серверных приложений. Каждая инструкция в каждом методе выполняется внутри потока, и, поскольку Java — многопоточный язык, одновременно выполняется несколько потоков. Поток — это единица конкурентности в Java: фрагмент последовательного кода, который выполняется конкурентно с другими такими единицами и в значительной степени независимо от них. Каждый поток предоставляет стек для хранения локальных переменных и координации вызовов методов, а также контекст на случай, когда что-то идёт не так: исключения выбрасываются и перехватываются методами в одном и том же потоке, поэтому разработчики могут по трассировке стека потока выяснить, что произошло. Потоки — также центральное понятие для инструментов: отладчики пошагово проходят инструкции в методах потока, а профилировщики визуализируют поведение нескольких потоков, чтобы помочь разобраться в их производительности.

Стиль «поток на запрос»

Серверные приложения, как правило, обрабатывают конкурентные пользовательские запросы, которые не зависят друг от друга, поэтому приложению имеет смысл обрабатывать запрос, выделяя ему поток на всё время его обработки. Этот стиль «поток на запрос» легко понять, на нём легко программировать, а код в нём легко отлаживать и профилировать, потому что он использует единицу конкурентности платформы для представления единицы конкурентности приложения.

Масштабируемость серверных приложений определяется законом Литтла, который связывает задержку, конкурентность и пропускную способность: при заданной длительности обработки запроса (то есть задержке) число запросов, которые приложение обрабатывает одновременно (то есть конкурентность), должно расти пропорционально частоте их поступления (то есть пропускной способности). Например, пусть приложение со средней задержкой 50 мс достигает пропускной способности 200 запросов в секунду, обрабатывая 10 запросов конкурентно. Чтобы это приложение масштабировалось до пропускной способности 2000 запросов в секунду, ему нужно будет обрабатывать 100 запросов конкурентно. Если каждый запрос обрабатывается в потоке всё время его выполнения, то, чтобы приложение справлялось, число потоков должно расти вместе с пропускной способностью.

К сожалению, число доступных потоков ограничено, потому что JDK реализует потоки как обёртки над потоками операционной системы (ОС). Потоки ОС дороги, поэтому их не может быть слишком много, и из-за этого такая реализация плохо подходит для стиля «поток на запрос». Если каждый запрос занимает поток, а значит, и поток ОС, на всё время своего выполнения, то число потоков часто становится ограничивающим фактором задолго до того, как будут исчерпаны другие ресурсы, например процессор или сетевые соединения. Текущая реализация потоков в JDK ограничивает пропускную способность приложения уровнем значительно ниже того, что может обеспечить оборудование. Так происходит даже при использовании пулов потоков, поскольку пулы помогают избежать высокой стоимости запуска нового потока, но не увеличивают общее число потоков.

Улучшение масштабируемости с помощью асинхронного стиля

Некоторые разработчики, желающие использовать оборудование в полной мере, отказались от стиля «поток на запрос» в пользу стиля с совместным использованием потоков. Вместо того чтобы обрабатывать запрос в одном потоке от начала до конца, код обработки запроса возвращает свой поток в пул, пока ожидает завершения операции ввода-вывода, чтобы поток мог обслуживать другие запросы. Такое мелкозернистое совместное использование потоков — при котором код удерживает поток только во время вычислений, а не во время ожидания ввода-вывода, — позволяет выполнять большое число конкурентных операций, не занимая большого числа потоков. Хотя это снимает ограничение пропускной способности, вызванное нехваткой потоков ОС, цена высока: требуется так называемый асинхронный стиль программирования, в котором используется отдельный набор методов ввода-вывода, которые не ждут завершения операций ввода-вывода, а позже сообщают об их завершении через обратный вызов. Без выделенного потока разработчики вынуждены разбивать логику обработки запроса на небольшие этапы, обычно записываемые как лямбда-выражения, а затем с помощью API объединять их в последовательный конвейер (см., например, CompletableFuture или так называемые «реактивные» фреймворки). Тем самым они отказываются от базовых операторов последовательной композиции языка, таких как циклы и блоки try/catch.

В асинхронном стиле каждый этап запроса может выполняться в другом потоке, а каждый поток выполняет вперемежку этапы, относящиеся к разным запросам. Это серьёзно влияет на понимание поведения программы: трассировки стека не дают полезного контекста, отладчики не могут пошагово пройти логику обработки запроса, а профилировщики не могут связать стоимость операции с вызывающим кодом. Композиция лямбда-выражений вполне приемлема при использовании Stream API Java для обработки данных в коротком конвейере, но создаёт проблемы, когда весь код обработки запросов в приложении приходится писать таким образом. Этот стиль программирования противоречит платформе Java, потому что единица конкурентности приложения — асинхронный конвейер — больше не совпадает с единицей конкурентности платформы.

Сохранение стиля «поток на запрос» с помощью Virtual Threads

Чтобы приложения могли масштабироваться, оставаясь в согласии с платформой, нам следует стремиться сохранить стиль «поток на запрос», реализовав потоки эффективнее, чтобы их могло быть больше. Операционные системы не могут реализовать потоки ОС эффективнее, потому что разные языки и среды выполнения используют стек потока по-разному. Однако среда выполнения Java может реализовать потоки Java так, чтобы разорвать их взаимно однозначное соответствие потокам ОС. Подобно тому как операционные системы создают иллюзию обильной памяти, отображая большое виртуальное адресное пространство на ограниченный объём физической оперативной памяти, среда выполнения Java может создать иллюзию обилия потоков, отображая большое число потоков Virtual Threads на небольшое число потоков ОС.

Поток Virtual Threads — это экземпляр java.lang.Thread, не привязанный к определённому потоку ОС. Платформенный поток, напротив, — это экземпляр java.lang.Thread, реализованный традиционным способом, как тонкая обёртка над потоком ОС.

Код приложения в стиле «поток на запрос» может выполняться в потоке Virtual Threads всё время обработки запроса, но этот поток занимает поток ОС только во время вычислений на процессоре. В результате достигается та же масштабируемость, что и в асинхронном стиле, но прозрачно: когда код, выполняющийся в потоке Virtual Threads, вызывает блокирующую операцию ввода-вывода в API java.*, среда выполнения выполняет неблокирующий вызов ОС и автоматически приостанавливает поток Virtual Threads, пока его нельзя будет возобновить. Для Java-разработчиков Virtual Threads — это просто потоки, которые дёшево создавать и которых может быть почти бесконечно много. Использование оборудования близко к оптимальному, что обеспечивает высокий уровень конкурентности и, как следствие, высокую пропускную способность, а приложение при этом остаётся в согласии с многопоточным устройством платформы Java и её инструментов.

Следствия Virtual Threads

Virtual Threads дёшевы и многочисленны, поэтому их никогда не следует объединять в пулы: для каждой задачи приложения нужно создавать новый поток Virtual Threads. Поэтому большинство потоков Virtual Threads будут недолговечными и будут иметь неглубокие стеки вызовов, выполняя всего лишь один вызов HTTP-клиента или один запрос JDBC. Платформенные потоки, напротив, тяжеловесны и дороги, поэтому их часто приходится объединять в пулы. Они, как правило, долговечны, имеют глубокие стеки вызовов и используются совместно многими задачами.

Итак, Virtual Threads сохраняют надёжный стиль «поток на запрос», согласующийся с устройством платформы Java, и при этом оптимально используют оборудование. Использование Virtual Threads не требует изучения новых понятий, хотя может потребовать отказа от привычек, выработанных, чтобы справляться с нынешней высокой стоимостью потоков. Virtual Threads помогут не только разработчикам приложений — они также помогут разработчикам фреймворков предоставлять простые в использовании API, совместимые с устройством платформы, без ущерба для масштабируемости.

Описание

Сегодня каждый экземпляр java.lang.Thread в JDK — это платформенный поток. Платформенный поток выполняет Java-код в нижележащем потоке ОС и занимает этот поток ОС на всё время жизни кода. Число платформенных потоков ограничено числом потоков ОС.

Поток Virtual Threads — это экземпляр java.lang.Thread, который выполняет Java-код в нижележащем потоке ОС, но не занимает этот поток ОС на всё время жизни кода. Это означает, что множество потоков Virtual Threads может выполнять свой Java-код в одном и том же потоке ОС, фактически используя его совместно. Платформенный поток монополизирует ценный поток ОС, а поток Virtual Threads — нет. Число потоков Virtual Threads может быть намного больше числа потоков ОС.

Virtual Threads — это облегчённая реализация потоков, которую предоставляет JDK, а не ОС. Это разновидность потоков пользовательского режима, которые успешно применяются в других многопоточных языках (например, горутины в Go и процессы в Erlang). Потоки пользовательского режима даже присутствовали в ранних версиях Java под названием «зелёные потоки», когда потоки ОС были ещё незрелыми и не получили широкого распространения. Однако все зелёные потоки Java использовали один поток ОС (планирование M:1), и со временем их превзошли по производительности платформенные потоки, реализованные как обёртки над потоками ОС (планирование 1:1). Virtual Threads используют планирование M:N, при котором большое число (M) потоков Virtual Threads распределяется для выполнения на меньшее число (N) потоков ОС.

Использование Virtual Threads в сравнении с платформенными потоками

Разработчики могут выбирать, использовать ли Virtual Threads или платформенные потоки. Вот пример программы, которая создаёт большое количество потоков Virtual Threads. Сначала программа получает ExecutorService, который будет создавать новый поток Virtual Threads для каждой переданной задачи. Затем она передаёт 10 000 задач и ждёт завершения их всех:

try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    IntStream.range(0, 10_000).forEach(i -> {
        executor.submit(() -> {
            Thread.sleep(Duration.ofSeconds(1));
            return i;
        });
    });
}  // executor.close() is called implicitly, and waits

Задача в этом примере — простой код (заснуть на одну секунду), и современное оборудование легко выдержит 10 000 потоков Virtual Threads, которые выполняют такой код конкурентно. За кулисами JDK выполняет этот код на небольшом числе потоков ОС, возможно даже на одном.

Всё было бы совсем иначе, если бы эта программа использовала ExecutorService, который создаёт новый платформенный поток для каждой задачи, например Executors.newCachedThreadPool(). ExecutorService попытался бы создать 10 000 платформенных потоков, а значит, и 10 000 потоков ОС, и программа могла бы аварийно завершиться в зависимости от машины и операционной системы.

Ненамного лучше было бы, если бы программа вместо этого использовала ExecutorService, который берёт платформенные потоки из пула, например Executors.newFixedThreadPool(200). ExecutorService создал бы 200 платформенных потоков, общих для всех 10 000 задач, поэтому многие задачи выполнялись бы последовательно, а не конкурентно, и программа работала бы долго. Для этой программы пул из 200 платформенных потоков может обеспечить пропускную способность только 200 задач в секунду, тогда как Virtual Threads обеспечивают пропускную способность около 10 000 задач в секунду (после достаточного прогрева). Более того, если в примере программы заменить 10_000 на 1_000_000, то программа передаст 1 000 000 задач, создаст 1 000 000 потоков Virtual Threads, работающих конкурентно, и (после достаточного прогрева) достигнет пропускной способности около 1 000 000 задач в секунду.

Если бы задачи в этой программе не просто спали, а в течение одной секунды выполняли вычисления (например, сортировали огромный массив), то увеличение числа потоков сверх числа процессорных ядер не помогло бы, будь то Virtual Threads или платформенные потоки. Virtual Threads — это не более быстрые потоки: они выполняют код ничуть не быстрее, чем платформенные потоки. Они существуют, чтобы обеспечить масштаб (более высокую пропускную способность), а не скорость (более низкую задержку). Их может быть гораздо больше, чем платформенных потоков, поэтому они делают возможной более высокую конкурентность, которая по закону Литтла нужна для более высокой пропускной способности.

Иначе говоря, Virtual Threads могут значительно повысить пропускную способность приложения, когда

  • число конкурентных задач велико (больше нескольких тысяч) и
  • нагрузка не ограничена процессором, поскольку в этом случае наличие потоков в гораздо большем количестве, чем процессорных ядер, не может повысить пропускную способность.

Virtual Threads помогают повысить пропускную способность типичных серверных приложений именно потому, что такие приложения состоят из огромного числа конкурентных задач, которые большую часть времени проводят в ожидании.

Поток Virtual Threads может выполнять любой код, который может выполнять платформенный поток. В частности, Virtual Threads поддерживают локальные переменные потока и прерывание потоков точно так же, как платформенные потоки. Это значит, что существующий код на Java, обрабатывающий запросы, без труда будет работать в потоке Virtual Threads. Многие серверные фреймворки будут делать это автоматически: запускать новый поток Virtual Threads для каждого входящего запроса и выполнять в нём бизнес-логику приложения.

Вот пример серверного приложения, которое объединяет результаты двух других сервисов. Гипотетический серверный фреймворк (не показан) создаёт новый поток Virtual Threads для каждого запроса и выполняет код handle приложения в этом потоке Virtual Threads. Код приложения, в свою очередь, создаёт два новых потока Virtual Threads, чтобы конкурентно получить ресурсы через тот же ExecutorService, что и в первом примере:

void handle(Request request, Response response) {
    var url1 = ...
    var url2 = ...
 
    try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
        var future1 = executor.submit(() -> fetchURL(url1));
        var future2 = executor.submit(() -> fetchURL(url2));
        response.send(future1.get() + future2.get());
    } catch (ExecutionException | InterruptedException e) {
        response.fail(e);
    }
}
 
String fetchURL(URL url) throws IOException {
    try (var in = url.openStream()) {
        return new String(in.readAllBytes(), StandardCharsets.UTF_8);
    }
}

Такое серверное приложение с простым блокирующим кодом хорошо масштабируется, потому что может использовать большое количество потоков Virtual Threads.

Executor.newVirtualThreadPerTaskExecutor() — не единственный способ создавать Virtual Threads. Новый API java.lang.Thread.Builder, рассмотренный ниже, может создавать и запускать Virtual Threads. Кроме того, Structured Concurrency (структурированная конкурентность) предлагает более мощный API для создания Virtual Threads и управления ими, особенно в коде, похожем на этот серверный пример, благодаря чему отношения между потоками становятся известны платформе и её инструментам.

Virtual Threads — это Preview API, по умолчанию отключённый

Приведённые выше программы используют метод Executors.newVirtualThreadPerTaskExecutor(), поэтому, чтобы запустить их на JDK 20, необходимо включить Preview API следующим образом:

  • Скомпилируйте программу с javac --release 20 --enable-preview Main.java и запускайте её с java --enable-preview Main; или

  • При использовании средства запуска исходного кода запускайте программу с java --source 20 --enable-preview Main.java; или

  • При использовании jshell запускайте его с jshell --enable-preview.

Не объединяйте Virtual Threads в пулы

Обычно разработчики будут переводить код приложений с традиционного ExecutorService на основе пулов потоков на ExecutorService, создающий поток Virtual Threads на каждую задачу. Пулы потоков, как и любые пулы ресурсов, предназначены для совместного использования дорогих ресурсов, но Virtual Threads недороги, и объединять их в пулы никогда не требуется.

Иногда разработчики используют пулы потоков, чтобы ограничить конкурентный доступ к ограниченному ресурсу. Например, если сервис не может обрабатывать больше 20 конкурентных запросов, то это гарантирует выполнение всех обращений к сервису через задачи, передаваемые в пул размером 20. Поскольку из-за высокой стоимости платформенных потоков пулы потоков стали повсеместными, эта идиома тоже стала повсеместной, но разработчикам не следует поддаваться соблазну объединять Virtual Threads в пулы, чтобы ограничить конкурентность. Для защиты доступа к ограниченному ресурсу следует использовать конструкцию, специально предназначенную для этой цели, например семафоры. Это эффективнее и удобнее, чем пулы потоков, а также безопаснее, поскольку нет риска, что локальные данные потока случайно утекут из одной задачи в другую.

Наблюдение за Virtual Threads

Написать понятный код — это ещё не всё. Понятное представление состояния работающей программы также необходимо для диагностики неполадок, сопровождения и оптимизации, и JDK давно предоставляет механизмы для отладки, профилирования и мониторинга потоков. Такие инструменты должны делать то же самое и для Virtual Threads (возможно, с некоторой поправкой на их большое количество), поскольку они в конце концов являются экземплярами java.lang.Thread.

Отладчики Java могут пошагово проходить потоки Virtual Threads, показывать стеки вызовов и просматривать переменные во фреймах стека. JDK Flight Recorder (JFR), механизм профилирования и мониторинга JDK с низкими накладными расходами, может связывать события из кода приложения (например, выделение памяти под объекты и операции ввода-вывода) с правильным потоком Virtual Threads. Эти инструменты не могут делать этого для приложений, написанных в асинхронном стиле. В этом стиле задачи не связаны с потоками, поэтому отладчики не могут отображать или изменять состояние задачи, а профилировщики не могут определить, сколько времени задача проводит в ожидании ввода-вывода.

Дамп потоков — ещё один популярный инструмент для диагностики приложений, написанных в стиле «поток на запрос». К сожалению, традиционный дамп потоков JDK, получаемый с помощью jstack или jcmd, представляет собой плоский список потоков. Это подходит для десятков или сотен платформенных потоков, но не подходит для тысяч или миллионов потоков Virtual Threads. Поэтому мы не будем расширять традиционные дампы потоков, чтобы включить в них Virtual Threads, а вместо этого введём в jcmd новый вид дампа потоков, в котором Virtual Threads будут представлены вместе с платформенными потоками, и все они будут сгруппированы осмысленным образом. Когда программы используют Structured Concurrency, можно показать более богатые отношения между потоками.

Поскольку для визуализации и анализа очень большого числа потоков полезны инструменты, jcmd может выводить новый дамп потоков не только в виде простого текста, но и в формате JSON:

$ jcmd <pid> Thread.dump_to_file -format=json <file>

Новый формат дампа потоков перечисляет Virtual Threads, заблокированные в сетевых операциях ввода-вывода, и Virtual Threads, созданные показанным выше ExecutorService, который создаёт новый поток на каждую задачу. Он не включает адреса объектов, блокировки, статистику JNI, статистику кучи и другую информацию, которая есть в традиционных дампах потоков. Более того, поскольку может потребоваться перечислить очень много потоков, создание нового дампа потоков не приостанавливает приложение.

Вот пример такого дампа потоков, полученного из приложения, похожего на второй пример выше, и отображённого в средстве просмотра JSON (нажмите, чтобы увеличить):

Поскольку Virtual Threads реализованы в JDK и не привязаны ни к какому конкретному потоку ОС, они невидимы для ОС, которая не знает об их существовании. Мониторинг на уровне ОС покажет, что процесс JDK использует меньше потоков ОС, чем существует потоков Virtual Threads.

Планирование Virtual Threads

Чтобы выполнять полезную работу, поток должен быть запланирован, то есть назначен на выполнение на процессорном ядре. Для платформенных потоков, реализованных как потоки ОС, JDK полагается на планировщик ОС. Для Virtual Threads, напротив, у JDK есть собственный планировщик. Вместо того чтобы назначать Virtual Threads на процессоры напрямую, планировщик JDK назначает Virtual Threads на платформенные потоки (это упомянутое ранее планирование Virtual Threads по схеме M:N). Затем платформенные потоки, как обычно, планирует ОС.

Планировщик Virtual Threads в JDK — это ForkJoinPool с перехватом работы (work-stealing), работающий в режиме FIFO. Параллелизм планировщика — это число платформенных потоков, доступных для планирования Virtual Threads. По умолчанию он равен числу доступных процессоров, но его можно настроить системным свойством jdk.virtualThreadScheduler.parallelism. Обратите внимание, что этот ForkJoinPool отличается от общего пула, который используется, например, в реализации параллельных стримов и работает в режиме LIFO.

Платформенный поток, на который планировщик назначает поток Virtual Threads, называется носителем этого потока Virtual Threads. За время своего существования поток Virtual Threads может планироваться на разные носители; другими словами, планировщик не поддерживает привязку между потоком Virtual Threads и каким-либо конкретным платформенным потоком. С точки зрения кода на Java работающий поток Virtual Threads логически независим от своего текущего носителя:

  • Потоку Virtual Threads недоступны сведения о том, какой поток является его носителем. Значение, возвращаемое Thread.currentThread(), — это всегда сам поток Virtual Threads.

  • Трассировки стека носителя и потока Virtual Threads раздельны. Исключение, выброшенное в потоке Virtual Threads, не будет содержать фреймов стека носителя. Дампы потоков не будут показывать фреймы стека носителя в стеке потока Virtual Threads, и наоборот.

  • Локальные переменные потока-носителя недоступны потоку Virtual Threads, и наоборот.

Кроме того, с точки зрения кода на Java то, что поток Virtual Threads и его носитель временно разделяют один поток ОС, незаметно. С точки зрения нативного кода, напротив, и поток Virtual Threads, и его носитель работают на одном и том же нативном потоке. Поэтому нативный код, который вызывается несколько раз в одном и том же потоке Virtual Threads, может при каждом вызове видеть другой идентификатор потока ОС.

В настоящее время планировщик не реализует для Virtual Threads разделение времени. Разделение времени — это принудительное вытеснение потока, который израсходовал выделенную ему долю процессорного времени. Разделение времени может эффективно снижать задержку некоторых задач, когда платформенных потоков сравнительно немного, а загрузка процессора составляет 100 %, но неясно, будет ли оно так же эффективно при миллионе потоков Virtual Threads.

Выполнение Virtual Threads

Чтобы воспользоваться Virtual Threads, переписывать программу не нужно. Virtual Threads не требуют и не ожидают, что код приложения будет явно возвращать управление планировщику; иными словами, Virtual Threads не являются кооперативными. Пользовательский код не должен делать предположений о том, как и когда потоки Virtual Threads назначаются платформенным потокам, точно так же, как он не делает предположений о том, как и когда платформенные потоки назначаются ядрам процессора.

Чтобы выполнить код в потоке Virtual Threads, планировщик Virtual Threads в JDK назначает поток Virtual Threads на выполнение в платформенном потоке, выполняя монтирование потока Virtual Threads на платформенный поток. После этого платформенный поток становится носителем потока Virtual Threads. Позднее, выполнив какой-то код, поток Virtual Threads может отмонтироваться от своего носителя. В этот момент платформенный поток свободен, и планировщик может смонтировать на него другой поток Virtual Threads, снова сделав его носителем.

Как правило, поток Virtual Threads отмонтируется, когда блокируется на вводе-выводе или на другой блокирующей операции в JDK, например BlockingQueue.take(). Когда блокирующая операция готова завершиться (например, на сокет пришли байты), она снова передаёт поток Virtual Threads планировщику, который смонтирует поток Virtual Threads на носитель, чтобы продолжить выполнение.

Монтирование и отмонтирование потоков Virtual Threads происходит часто и незаметно, без блокировки каких-либо потоков ОС. Например, серверное приложение, показанное ранее, содержало следующую строку кода с вызовами блокирующих операций:

response.send(future1.get() + future2.get());

Эти операции приведут к тому, что поток Virtual Threads будет монтироваться и отмонтироваться несколько раз: как правило, по одному разу на каждый вызов get() и, возможно, несколько раз в ходе ввода-вывода в send(...).

Подавляющее большинство блокирующих операций в JDK отмонтирует поток Virtual Threads, освобождая его носитель и нижележащий поток ОС для новой работы. Однако некоторые блокирующие операции в JDK не отмонтируют поток Virtual Threads и потому блокируют и его носитель, и нижележащий поток ОС. Причина — ограничения либо на уровне ОС (например, многие операции файловой системы), либо на уровне JDK, например Object.wait(). Реализация этих блокирующих операций компенсирует захват потока ОС, временно увеличивая параллелизм планировщика. Поэтому число платформенных потоков в ForkJoinPool планировщика может временно превышать число доступных процессоров. Максимальное число платформенных потоков, доступных планировщику, можно настроить системным свойством jdk.virtualThreadScheduler.maxPoolSize.

Есть два сценария, в которых поток Virtual Threads не может быть отмонтирован во время блокирующих операций, потому что для него действует Pinning (привязка виртуального потока к платформенному) — он закреплён на своём носителе:

  1. когда он выполняет код внутри блока или метода synchronized или
  2. когда он выполняет метод native или внешнюю функцию.

Pinning не делает приложение некорректным, но может мешать его масштабируемости. Если поток Virtual Threads, будучи закреплённым, выполняет блокирующую операцию, например ввод-вывод или BlockingQueue.take(), то его носитель и нижележащий поток ОС заблокированы на всё время операции. Частый и продолжительный Pinning может ухудшить масштабируемость приложения, захватывая носители.

Планировщик не компенсирует Pinning увеличением параллелизма. Вместо этого избегайте частого и продолжительного Pinning: переделайте блоки или методы synchronized, которые выполняются часто и защищают потенциально долгие операции ввода-вывода, так, чтобы они использовали java.util.concurrent.locks.ReentrantLock. Заменять блоки и методы synchronized, которые используются редко (например, выполняются только при запуске) или защищают операции в памяти, не нужно. Как всегда, стремитесь к тому, чтобы политики блокировок были простыми и понятными.

Новые средства диагностики помогают переводить код на Virtual Threads и оценивать, стоит ли заменить конкретное использование synchronized на блокировку java.util.concurrent:

  • Когда поток блокируется, будучи закреплённым, генерируется событие JDK Flight Recorder (JFR) (см. JDK Flight Recorder).

  • Системное свойство jdk.tracePinnedThreads вызывает вывод трассировки стека, когда поток блокируется, будучи закреплённым. При запуске с -Djdk.tracePinnedThreads=full выводится полная трассировка стека, когда поток блокируется, будучи закреплённым, причём нативные фреймы и фреймы, удерживающие мониторы, выделяются. При запуске с -Djdk.tracePinnedThreads=short вывод ограничивается только проблемными фреймами.

В одном из будущих выпусков мы, возможно, сможем снять первое из указанных выше ограничений (Pinning внутри synchronized). Второе ограничение необходимо для корректного взаимодействия с нативным кодом.

Использование памяти и взаимодействие со сборкой мусора

Стеки потоков Virtual Threads хранятся в куче Java, управляемой сборщиком мусора, в виде объектов stack chunk (фрагмент стека). Стеки растут и сокращаются по ходу работы приложения — как ради экономии памяти, так и для того, чтобы вмещать стеки произвольной глубины (вплоть до настроенного в JVM размера стека платформенного потока). Именно эта эффективность позволяет создавать большое число потоков Virtual Threads и тем самым сохраняет жизнеспособность стиля «поток на запрос» в серверных приложениях.

Вспомним, что во втором примере выше гипотетический фреймворк обрабатывает каждый запрос, создавая новый поток Virtual Threads и вызывая метод handle; даже если он вызывает handle в конце глубокого стека вызовов (после аутентификации, транзакций и т. д.), handle сам порождает несколько потоков Virtual Threads, которые выполняют только короткоживущие задачи. Поэтому на каждый поток Virtual Threads с глубоким стеком вызовов будет приходиться несколько потоков Virtual Threads с неглубокими стеками вызовов, потребляющих мало памяти.

Объём памяти кучи и активность сборщика мусора, которые требуются потокам Virtual Threads, в общем случае трудно сравнить с тем, что требует асинхронный код. Миллиону потоков Virtual Threads нужен как минимум миллион объектов, но то же самое нужно и миллиону задач, разделяющих пул платформенных потоков. Кроме того, код приложения, обрабатывающий запросы, обычно сохраняет данные между операциями ввода-вывода. Код в стиле «поток на запрос» может хранить эти данные в локальных переменных, которые находятся в стеках потоков Virtual Threads в куче, тогда как асинхронный код должен хранить те же данные в объектах кучи, которые передаются от одного этапа конвейера к следующему. С одной стороны, раскладка фреймов стека, необходимая потокам Virtual Threads, расходует память менее экономно, чем компактный объект; с другой стороны, потоки Virtual Threads во многих ситуациях могут изменять и повторно использовать свои стеки (в зависимости от низкоуровневого взаимодействия с GC), тогда как асинхронным конвейерам всегда нужно выделять новые объекты, поэтому потокам Virtual Threads может потребоваться меньше выделений памяти. В целом потребление кучи и активность сборщика мусора у кода в стиле «поток на запрос» и у асинхронного кода должны быть примерно одинаковыми. Со временем мы рассчитываем сделать внутреннее представление стеков потоков Virtual Threads значительно компактнее.

В отличие от стеков платформенных потоков, стеки потоков Virtual Threads не являются корнями GC, поэтому содержащиеся в них ссылки не обходятся во время паузы stop-the-world сборщиками мусора, которые выполняют конкурентное сканирование кучи, например G1. Это также означает, что если поток Virtual Threads заблокирован, например, на BlockingQueue.take() и никакой другой поток не может получить ссылку ни на этот поток Virtual Threads, ни на очередь, то поток может быть удалён сборщиком мусора — и это нормально, поскольку этот поток Virtual Threads уже никогда не может быть прерван или разблокирован. Разумеется, поток Virtual Threads не будет удалён сборщиком мусора, если он выполняется или если он заблокирован и может быть когда-либо разблокирован.

Текущее ограничение Virtual Threads состоит в том, что G1 GC не поддерживает объекты stack chunk категории humongous (сверхбольшие). Если стек потока Virtual Threads достигнет половины размера региона, который может составлять всего 512 КБ, может быть выброшено StackOverflowError.

Подробное описание изменений

В остальных подразделах подробно описаны изменения, которые мы предлагаем внести в платформу Java и её реализацию:

java.lang.Thread

Мы обновляем API java.lang.Thread следующим образом:

  • Thread.Builder, Thread.ofVirtual() и Thread.ofPlatform() — новые API для создания потоков Virtual Threads и платформенных потоков. Например,

    Thread thread = Thread.ofVirtual().name("duke").unstarted(runnable);

    создаёт новый незапущенный поток Virtual Threads с именем "duke".

  • Thread.startVirtualThread(Runnable) — удобный способ создать и сразу запустить поток Virtual Threads.

  • Thread.Builder может создать либо поток, либо ThreadFactory, которая затем может создавать несколько потоков с одинаковыми свойствами.

  • Thread.isVirtual() проверяет, является ли поток потоком Virtual Threads.

  • Thread.getAllStackTraces() теперь возвращает отображение всех платформенных потоков, а не всех потоков.

В остальном этот JEP API java.lang.Thread не меняет. Конструкторы, определённые в классе Thread, как и прежде, создают платформенные потоки. Новых публичных конструкторов нет.

(Три метода в Thread, которые выбрасывают UnsupportedOperationException для потоков Virtual Threads, — stop(), suspend() и resume() — будут изменены в JDK 20 так, чтобы выбрасывать UnsupportedOperationException и для платформенных потоков. Это изменение не зависит от данного JEP.)

Основные различия в API между потоками Virtual Threads и платформенными потоками:

  • Публичные конструкторы Thread не могут создавать потоки Virtual Threads.

  • Потоки Virtual Threads всегда являются потоками-демонами. Метод Thread.setDaemon(boolean) не может сделать поток Virtual Threads потоком, не являющимся демоном.

  • У потоков Virtual Threads фиксированный приоритет Thread.NORM_PRIORITY. Метод Thread.setPriority(int) не действует на потоки Virtual Threads. Это ограничение может быть пересмотрено в одном из будущих выпусков.

  • Потоки Virtual Threads не являются активными членами групп потоков. При вызове для потока Virtual Threads Thread.getThreadGroup() возвращает группу потоков-заполнитель с именем "VirtualThreads". API Thread.Builder не определяет метода для установки группы потоков у потока Virtual Threads.

  • Потоки Virtual Threads не имеют никаких разрешений при работе с установленным SecurityManager.

Локальные переменные потока

Потоки Virtual Threads, как и платформенные потоки, поддерживают локальные переменные потока (ThreadLocal) и наследуемые локальные переменные потока (InheritableThreadLocal), поэтому в них может выполняться существующий код, использующий локальные переменные потока. Однако поскольку потоков Virtual Threads может быть очень много, используйте локальные переменные потока, только тщательно всё взвесив. В частности, не используйте локальные переменные потока для организации пула дорогостоящих ресурсов, разделяемых несколькими задачами, которые используют один и тот же поток в пуле потоков. Потоки Virtual Threads никогда не следует объединять в пулы, поскольку каждый из них предназначен для выполнения только одной задачи за всё время своей жизни. Готовясь к Virtual Threads, мы убрали многие случаи использования локальных переменных потока из модуля java.base, чтобы уменьшить потребление памяти при работе с миллионами потоков.

Кроме того:

Для некоторых сценариев использования Scoped Values (значения с ограниченной областью видимости, JEP 429) могут оказаться лучшей альтернативой локальным переменным потока.

java.util.concurrent

Примитивный API для поддержки блокировок, java.util.concurrent.LockSupport, теперь поддерживает Virtual Threads: парковка потока Virtual Threads освобождает нижележащий платформенный поток для другой работы, а снятие потока Virtual Threads с парковки планирует продолжение его выполнения. Благодаря этому изменению LockSupport все API, которые его используют (Lock, Semaphore, блокирующие очереди и т. д.), корректно паркуются при вызове в потоках Virtual Threads.

Кроме того:

Работа с сетью

Реализации сетевых API в пакетах java.net и java.nio.channels теперь работают с потоками Virtual Threads: операция в потоке Virtual Threads, которая блокируется, например, при установлении сетевого соединения или чтении из сокета, освобождает нижележащий платформенный поток для другой работы.

Чтобы операции можно было прерывать и отменять, блокирующие методы ввода-вывода, определённые в java.net.Socket, ServerSocket и DatagramSocket, по спецификации теперь прерываемые при вызове в потоке Virtual Threads: прерывание потока Virtual Threads, заблокированного на сокете, снимает поток с парковки и закрывает сокет. Блокирующие операции ввода-вывода на сокетах этих типов, полученных из InterruptibleChannel, прерываемыми были всегда, поэтому это изменение согласует поведение этих API при создании через конструкторы с их поведением при получении из канала.

java.io

Пакет java.io предоставляет API для байтовых и символьных потоков ввода-вывода. Реализации этих API активно используют синхронизацию и требуют изменений, чтобы избежать Pinning при их использовании в потоках Virtual Threads.

Для справки: спецификация не требует, чтобы байтовые потоки ввода и вывода были потокобезопасными, и не определяет ожидаемое поведение при вызове close(), когда поток выполнения заблокирован в методе чтения или записи. В большинстве сценариев нет смысла использовать один и тот же поток ввода или вывода из нескольких конкурентных потоков выполнения. Символьные классы чтения и записи (reader/writer) по спецификации тоже не обязаны быть потокобезопасными, но они предоставляют подклассам объект блокировки. Помимо Pinning, синхронизация в этих классах проблемна и непоследовательна; например, декодеры и кодировщики потоков, используемые InputStreamReader и OutputStreamWriter, синхронизируются на объекте потока ввода-вывода, а не на объекте блокировки.

Чтобы предотвратить Pinning, реализации теперь работают так:

  • BufferedInputStream, BufferedOutputStream, BufferedReader, BufferedWriter, PrintStream и PrintWriter при прямом использовании теперь применяют явную блокировку вместо монитора. Если от этих классов наследуются, они синхронизируются, как и раньше.

  • Декодеры и кодировщики потоков, используемые InputStreamReader и OutputStreamWriter, теперь используют ту же блокировку, что и объемлющий InputStreamReader или OutputStreamWriter.

Дальнейшие шаги по устранению всех этих зачастую ненужных блокировок выходят за рамки этого JEP.

Кроме того, начальные размеры буферов, используемых BufferedOutputStream, BufferedWriter и кодировщиком потока для OutputStreamWriter, теперь меньше, чтобы сократить расход памяти, когда в куче много потоков ввода-вывода или объектов записи, — как может случиться, если существует миллион потоков Virtual Threads, у каждого из которых есть буферизованный поток на сокетном соединении.

Java Native Interface (JNI)

JNI определяет одну новую функцию, IsVirtualThread, которая проверяет, является ли объект потоком Virtual Threads.

В остальном спецификация JNI не изменилась.

Отладка

Архитектура отладки состоит из трёх интерфейсов: JVM Tool Interface (JVM TI), Java Debug Wire Protocol (JDWP) и Java Debug Interface (JDI). Все три интерфейса теперь поддерживают Virtual Threads.

Изменения в JVM TI:

  • Большинство функций, вызываемых с jthread (то есть JNI-ссылкой на объект Thread), можно вызывать со ссылкой на поток Virtual Threads. Небольшое число функций, а именно PopFrame, ForceEarlyReturn, StopThread, AgentStartFunction и GetThreadCpuTime, для потоков Virtual Threads не поддерживается. Функции SetLocal* могут задавать локальные переменные только в самых верхних кадрах стека потоков Virtual Threads, приостановленных на событии точки останова или пошагового выполнения.

  • Функции GetAllThreads и GetAllStackTraces по спецификации теперь возвращают все платформенные потоки, а не все потоки.

  • Для всех событий, кроме публикуемых на раннем этапе запуска VM или во время обхода кучи, обратные вызовы событий могут вызываться в контексте потока Virtual Threads.

  • Реализация приостановки и возобновления позволяет отладчикам приостанавливать и возобновлять потоки Virtual Threads, а также позволяет приостанавливать платформенные потоки, когда на них смонтирован поток Virtual Threads.

  • Новая возможность (capability) can_support_virtual_threads даёт агентам более точный контроль над событиями запуска и завершения потоков Virtual Threads.

  • Новые функции поддерживают массовую приостановку и возобновление потоков Virtual Threads; для них требуется возможность can_support_virtual_threads.

Существующие агенты JVM TI в основном будут работать как раньше, но могут столкнуться с ошибками, если вызывают функции, которые не поддерживаются для потоков Virtual Threads. Это произойдёт, когда агент, не знающий о Virtual Threads, используется с приложением, которое использует Virtual Threads. Изменение GetAllThreads, которая теперь возвращает массив только с платформенными потоками, может стать проблемой для некоторых агентов. Существующие агенты, которые включают события ThreadStart и ThreadEnd, могут столкнуться с проблемами производительности, поскольку не могут ограничить эти события платформенными потоками.

Изменения в JDWP:

  • Новая команда позволяет отладчикам проверить, является ли поток потоком Virtual Threads.

  • Новый модификатор команды EventRequest позволяет отладчикам ограничить события запуска и завершения потоков платформенными потоками.

Изменения в JDI:

Как отмечалось выше, потоки Virtual Threads не считаются активными потоками в группе потоков. Поэтому списки потоков, возвращаемые функцией JVM TI GetThreadGroupChildren, командой JDWP ThreadGroupReference/Children и методом JDI com.sun.jdi.ThreadGroupReference.threads(), включают только платформенные потоки.

JDK Flight Recorder (JFR)

JFR поддерживает Virtual Threads с помощью нескольких новых событий:

  • jdk.VirtualThreadStart и jdk.VirtualThreadEnd отмечают запуск и завершение потока Virtual Threads. По умолчанию эти события отключены.

  • jdk.VirtualThreadPinned означает, что поток Virtual Threads был припаркован в состоянии Pinning, то есть без освобождения своего платформенного потока (см. обсуждение). Это событие включено по умолчанию с порогом 20 мс.

  • jdk.VirtualThreadSubmitFailed означает, что запуск потока Virtual Threads или снятие его с парковки не удались, вероятно, из-за проблемы с ресурсами. Это событие включено по умолчанию.

Java Management Extensions (JMX)

java.lang.management.ThreadMXBean поддерживает мониторинг и управление только для платформенных потоков. Метод findDeadlockedThreads() находит циклы платформенных потоков, находящихся во взаимоблокировке; циклы потоков Virtual Threads во взаимоблокировке он не находит.

Новый метод в com.sun.management.HotSpotDiagnosticsMXBean создаёт дамп потоков в новом формате, описанном выше. Этот метод также можно вызвать косвенно через платформенный MBeanServer из локального или удалённого инструмента JMX.

Альтернативы

  • Продолжать полагаться на асинхронные API. Асинхронные API трудно интегрировать с синхронными API, они разделяют мир на два представления одних и тех же операций ввода-вывода и не дают единого понятия последовательности операций, которое платформа могла бы использовать как контекст для диагностики неполадок, мониторинга, отладки и профилирования.

  • Добавить в язык Java синтаксические бесстековые сопрограммы (то есть async/await). Их проще реализовать, чем потоки пользовательского режима, и они дали бы объединяющую конструкцию, представляющую контекст последовательности операций.

    Однако эта конструкция была бы новой и отдельной от потоков, во многом похожей на них, но в некоторых тонкостях отличающейся. Она разделила бы мир на API, спроектированные для потоков, и API, спроектированные для сопрограмм, и потребовала бы внедрить новую конструкцию, подобную потокам, во все уровни платформы и её инструментов. Экосистеме понадобилось бы больше времени на её освоение, и она не была бы такой же элегантной и гармоничной с платформой, как потоки пользовательского режима.

    Большинство языков, принявших синтаксические сопрограммы, сделали это из-за невозможности реализовать потоки пользовательского режима (например, Kotlin), унаследованных семантических гарантий (например, изначально однопоточный JavaScript) или технических ограничений, специфичных для языка (например, C++). К Java эти ограничения не относятся.

  • Ввести новый публичный класс для представления потоков пользовательского режима, не связанный с java.lang.Thread. Это дало бы возможность избавиться от ненужного груза, который класс Thread накопил за 25 лет. Мы исследовали несколько вариантов этого подхода и создали их прототипы, но в каждом случае сталкивались с вопросом, как выполнять существующий код.

    Главная проблема в том, что Thread.currentThread() прямо или косвенно повсеместно используется в существующем коде (например, при определении владельца блокировки или для переменных, локальных для потока). Этот метод должен возвращать объект, представляющий текущий поток выполнения. Если бы мы ввели новый класс для представления потоков пользовательского режима, currentThread() должен был бы возвращать какой-то объект-обёртку, который выглядит как Thread, но делегирует вызовы объекту потока пользовательского режима.

    Иметь два объекта, представляющих текущий поток выполнения, было бы запутанно, поэтому в итоге мы пришли к выводу, что сохранение старого API Thread не является серьёзным препятствием. За исключением нескольких методов, таких как currentThread(), разработчики редко используют API Thread напрямую; в основном они работают с API более высокого уровня, такими как ExecutorService. Со временем мы избавимся от ненужного груза в классе Thread и связанных с ним классах, таких как ThreadGroup, помечая неактуальные методы как Deprecated (устаревший) и удаляя их.

Тестирование

  • Существующие тесты гарантируют, что предлагаемые здесь изменения не вызовут неожиданных регрессий во множестве конфигураций и режимов выполнения, в которых эти тесты запускаются.

  • Мы расширим тестовую среду jtreg, чтобы существующие тесты можно было запускать в контексте потока Virtual Threads. Так не придётся иметь по две версии многих тестов.

  • Новые тесты проверят все новые и изменённые API, а также все области, изменённые для поддержки Virtual Threads.

  • Новые стресс-тесты будут нацелены на области, критически важные для надёжности и производительности.

  • Новые микробенчмарки будут нацелены на области, критичные для производительности.

  • Для более масштабного тестирования мы будем использовать ряд существующих серверов, включая Helidon и Jetty.

Риски и допущения

Основные риски этого предложения связаны с совместимостью из-за изменений в существующих API и их реализациях:

  • Изменения внутреннего (и недокументированного) протокола блокировки, используемого в классах java.io.BufferedInputStream, BufferedOutputStream, BufferedReader, BufferedWriter, PrintStream и PrintWriter, могут повлиять на код, который предполагает, что методы ввода-вывода синхронизируются на потоке ввода-вывода, для которого они вызваны. Эти изменения не влияют на код, который наследует эти классы и рассчитывает на блокировку в суперклассе, а также не влияют на код, который наследует java.io.Reader или java.io.Writer и использует объект блокировки, предоставляемый этими API.

Несколько изменений, несовместимых на уровне исходного и двоичного кода, могут повлиять на код, который наследует java.lang.Thread:

  • Thread определяет несколько новых методов. Если код в существующем исходном файле наследует Thread и метод подкласса конфликтует с каким-либо из новых методов Thread, файл не скомпилируется без изменений.

  • Thread.Builder — новый вложенный интерфейс. Если код в существующем исходном файле наследует Thread, импортирует класс с именем Builder и код подкласса обращается к Builder по простому имени, файл не скомпилируется без изменений.

  • Thread.isVirtual() — новый final-метод. Если существующий скомпилированный код наследует Thread и подкласс объявляет метод с тем же именем и возвращаемым типом, то при загрузке подкласса во время выполнения будет выброшено IncompatibleClassChangeError.

При совместном использовании существующего кода с новым кодом, который использует Virtual Threads или новые API, можно заметить некоторые различия в поведении платформенных потоков и потоков Virtual Threads:

  • Метод Thread.setPriority(int) не действует на потоки Virtual Threads, у которых приоритет всегда равен Thread.NORM_PRIORITY.

  • Метод Thread.setDaemon(boolean) не действует на потоки Virtual Threads, которые всегда являются потоками-демонами.

  • API Thread позволяет создавать потоки, которые не поддерживают локальные переменные потока. ThreadLocal.set(T) и Thread.setContextClassLoader(ClassLoader) выбрасывают UnsupportedOperationException, если их вызывают в контексте потока, который не поддерживает локальные переменные потока.

  • Thread.getAllStackTraces() теперь возвращает отображение всех платформенных потоков, а не всех потоков.

  • Блокирующие методы ввода-вывода, определённые в java.net.Socket, ServerSocket и DatagramSocket, теперь прерываемы, если их вызывают в контексте потока Virtual Threads. Существующий код может перестать работать при прерывании потока, заблокированного на операции с сокетом: прерывание разбудит поток и закроет сокет.

  • Потоки Virtual Threads не являются активными членами ThreadGroup. Вызов Thread.getThreadGroup() для потока Virtual Threads возвращает пустую фиктивную группу "VirtualThreads".

  • Потоки Virtual Threads не имеют никаких разрешений при работе с установленным SecurityManager.

  • В JVM TI функции GetAllThreads и GetAllStackTraces не возвращают потоки Virtual Threads. У существующих агентов, которые включают события ThreadStart и ThreadEnd, могут возникнуть проблемы с производительностью, поскольку они не могут ограничить эти события платформенными потоками.

  • API java.lang.management.ThreadMXBean поддерживает мониторинг и управление платформенными потоками, но не потоками Virtual Threads.

  • Флаг -XX:+PreserveFramePointer резко снижает производительность потоков Virtual Threads.

Зависимости

  • JEP 416 (Reimplement Core Reflection with Method Handles) в JDK 18 удалил нативную реализацию рефлексии в VM. Благодаря этому потоки Virtual Threads могут корректно паковаться (park), когда методы вызываются через рефлексию.

  • JEP 353 (Reimplement the Legacy Socket API) в JDK 13 и JEP 373 (Reimplement the Legacy DatagramSocket API) в JDK 15 заменили реализации java.net.Socket, ServerSocket и DatagramSocket новыми реализациями, рассчитанными на использование с потоками Virtual Threads.

  • JEP 418 (Internet-Address Resolution SPI) в JDK 18 определил интерфейс поставщика услуг (service-provider interface) для поиска имён хостов и адресов. Благодаря этому сторонние библиотеки смогут реализовывать альтернативные преобразователи java.net.InetAddress, которые не вызывают Pinning потоков во время поиска хоста.