JEP 425: Virtual Threads (Preview)
Virtual Threads (виртуальные потоки), версия Preview (предварительная версия)
| Authors | Ron Pressler, Alan Bateman |
| Ответственный | Alan Bateman |
| Тип | Feature |
| Область | SE |
| Статус | Closed / Delivered |
| Выпуск | 19 |
| Компонент | core-libs |
| Обсуждение | loom dash dev at openjdk dot java dot net |
| Трудоёмкость | XL |
| Связан с | JEP 436: Virtual Threads (Second Preview) |
| Рецензенты | Alex Buckley, Brian Goetz, Chris Hegarty |
| Создан | 2021/11/15 16:43 |
| Обновлён | 2023/06/07 09:43 |
| Задача | 8277131 |
Аннотация
Добавить в платформу Java Virtual Threads. Virtual Threads — это легковесные потоки, которые резко сокращают трудозатраты на написание, сопровождение и отслеживание работы конкурентных приложений с высокой пропускной способностью. Это API в статусе Preview.
Цели
-
Позволить серверным приложениям, написанным в простом стиле «поток на запрос», масштабироваться при почти оптимальном использовании оборудования.
-
Позволить существующему коду, использующему 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 так, чтобы разорвать их взаимно однозначное соответствие потокам ОС. Подобно тому как операционные системы создают иллюзию изобилия памяти, отображая большое виртуальное адресное пространство на ограниченный объём физической RAM, среда выполнения Java может создать иллюзию изобилия потоков, отображая большое количество потоков Virtual Threads на небольшое количество потоков ОС.
Поток Virtual Threads — это экземпляр java.lang.Thread, не привязанный к конкретному потоку ОС. Платформенный поток, напротив, — это экземпляр java.lang.Thread, реализованный традиционным способом, как тонкая обёртка над потоком ОС.
Код приложения в стиле «поток на запрос» может выполняться в потоке Virtual Threads всё время обработки запроса, но поток 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 могут значительно повысить пропускную способность приложения, когда
- Число конкурентных задач велико (больше нескольких тысяч) и
- Нагрузка не ограничена процессором (не CPU-bound), поскольку в этом случае число потоков, намного превышающее число процессорных ядер, не может повысить пропускную способность.
Virtual Threads помогают повысить пропускную способность типичных серверных приложений именно потому, что такие приложения состоят из огромного числа конкурентных задач, которые проводят значительную часть времени в ожидании.
Поток Virtual Threads может выполнять любой код, который может выполнять платформенный поток. В частности, Virtual Threads поддерживают локальные переменные потока и прерывание потоков так же, как платформенные потоки. Это значит, что существующий Java-код, обрабатывающий запросы, легко будет работать в потоке Virtual Threads. Многие серверные фреймворки предпочтут делать это автоматически: запускать новый поток Virtual Threads для каждого входящего запроса и выполнять в нём бизнес-логику приложения.
Вот пример серверного приложения, которое объединяет результаты двух других сервисов. Гипотетический серверный фреймворк (не показан) создаёт новый поток Virtual Threads для каждого запроса и выполняет в этом потоке код handle приложения. Код приложения, в свою очередь, создаёт два новых потока 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 19, нужно включить Preview API следующим образом:
-
Скомпилируйте программу с
javac --release 19 --enable-preview Main.javaи запускайте её сjava --enable-preview Main; или -
При использовании средства запуска исходного кода запускайте программу с
java --source 19 --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 назначает их на платформенные потоки (это и есть упомянутое ранее планирование M:N для Virtual Threads). Затем платформенные потоки, как обычно, планирует ОС.
Планировщик Virtual Threads в JDK — это ForkJoinPool с перехватом работы (work-stealing), работающий в режиме FIFO. Параллелизм планировщика — это число платформенных потоков, доступных для планирования Virtual Threads. По умолчанию он равен числу доступных процессоров, но его можно настроить системным свойством jdk.virtualThreadScheduler.parallelism. Обратите внимание, что этот ForkJoinPool отличается от общего пула, который используется, например, в реализации параллельных потоков данных (streams) и работает в режиме LIFO.
Платформенный поток, на который планировщик назначает поток Virtual Threads, называется носителем (carrier) этого потока. За время своего существования поток Virtual Threads может планироваться на разных носителях; иными словами, планировщик не поддерживает привязку (affinity) между потоком 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 разделение времени (time sharing). Разделение времени — это принудительное вытеснение потока, израсходовавшего выделенный ему квант процессорного времени. Хотя разделение времени может эффективно снижать задержку некоторых задач, когда платформенных потоков относительно немного, а загрузка процессора составляет 100 %, неясно, будет ли разделение времени столь же эффективным при миллионе потоков Virtual Threads.
Выполнение потоков Virtual Threads
Чтобы воспользоваться Virtual Threads, переписывать программу не нужно. Virtual Threads не требуют и не ожидают, что код приложения будет явно передавать управление обратно планировщику; иными словами, Virtual Threads не являются кооперативными. Пользовательский код не должен делать предположений о том, как и когда потоки Virtual Threads назначаются на платформенные потоки, так же как он не делает предположений о том, как и когда платформенные потоки назначаются на процессорные ядра.
Чтобы выполнить код в потоке Virtual Threads, планировщик Virtual Threads в JDK назначает этот поток на выполнение на платформенном потоке, монтируя (mounting) его на платформенный поток. В результате платформенный поток становится носителем этого потока Virtual Threads. Позже, выполнив некоторый код, поток Virtual Threads может размонтироваться (unmount) со своего носителя. В этот момент платформенный поток освобождается, и планировщик может смонтировать на него другой поток Virtual Threads, снова делая его носителем.
Как правило, поток Virtual Threads размонтируется, когда блокируется на вводе-выводе или на другой блокирующей операции в JDK, например BlockingQueue.take(). Когда блокирующая операция готова завершиться (например, на сокет пришли байты), она возвращает поток 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 (привязка виртуального потока к платформенному) — привязка виртуального потока к платформенному):
- когда он выполняет код внутри блока или метода
synchronizedили - когда он выполняет метод
nativeили внешнюю функцию.
Pinning не делает приложение некорректным, но может ухудшить его масштабируемость. Если поток Virtual Threads в состоянии Pinning выполняет блокирующую операцию, например ввод-вывод или BlockingQueue.take(), то его носитель и нижележащий поток ОС блокируются на всё время операции. Частый и длительный Pinning может ухудшить масштабируемость приложения, захватывая носители.
Планировщик не компенсирует Pinning увеличением степени параллелизма. Вместо этого избегайте частого и длительного Pinning: переделайте блоки или методы synchronized, которые выполняются часто и защищают потенциально долгие операции ввода-вывода, так, чтобы они использовали java.util.concurrent.locks.ReentrantLock. Заменять блоки и методы synchronized, которые используются редко (например, выполняются только при запуске) или защищают операции в памяти, не нужно. Как всегда, старайтесь, чтобы политики блокировок оставались простыми и понятными.
Новые средства диагностики помогают переводить код на Virtual Threads и оценивать, стоит ли заменить конкретное использование synchronized блокировкой java.util.concurrent:
-
Когда поток блокируется в состоянии Pinning, генерируется событие JDK Flight Recorder (JFR) (см. JDK Flight Recorder).
-
Системное свойство
jdk.tracePinnedThreadsвключает вывод трассировки стека, когда поток блокируется в состоянии Pinning. Запуск с-Djdk.tracePinnedThreads=fullвыводит полную трассировку стека при блокировке потока в состоянии Pinning, с выделением нативных фреймов и фреймов, удерживающих мониторы. Запуск с-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- Локальные переменные потока
java.util.concurrent- Сетевое взаимодействие
java.io- Java Native Interface (JNI)
- Отладка (JVM TI, JDWP и JDI)
- JDK Flight Recorder (JFR)
- Java Management Extensions (JMX)
java.lang.ThreadGroup
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.joinиThread.sleepпринимают время ожидания и сна в виде экземпляровjava.time.Duration. -
Новый final-метод
Thread.threadId()возвращает идентификатор потока. Существующий не-final методThread.getId()теперь объявлен устаревшим. -
Thread.getAllStackTraces()теперь возвращает отображение всех платформенных потоков, а не всех потоков.
В остальном API java.lang.Thread не изменился. Конструкторы, определённые в классе Thread, как и прежде, создают платформенные потоки. Новых публичных конструкторов нет.
Основные различия в 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". В APIThread.Builderнет метода, задающего группу потоков для потока Virtual Threads. -
Потоки Virtual Threads не имеют никаких разрешений при работе с установленным
SecurityManager. -
Потоки Virtual Threads не поддерживают методы
stop(),suspend()иresume(). При вызове на потоке Virtual Threads эти методы выбрасывают исключение.
Локальные переменные потока
Потоки Virtual Threads, как и платформенные потоки, поддерживают локальные переменные потока (ThreadLocal) и наследуемые локальные переменные потока (InheritableThreadLocal), поэтому в них может выполняться существующий код, использующий локальные переменные потока. Однако, поскольку потоков Virtual Threads может быть очень много, используйте локальные переменные потока только после тщательного обдумывания. В частности, не используйте локальные переменные потока для организации пула дорогих ресурсов между несколькими задачами, разделяющими один и тот же поток в пуле потоков. Потоки Virtual Threads никогда не следует объединять в пулы, поскольку каждый из них предназначен для выполнения за время своей жизни только одной задачи. При подготовке к Virtual Threads мы убрали многие случаи использования локальных переменных потока из модуля java.base, чтобы уменьшить потребление памяти при работе с миллионами потоков.
Кроме того:
-
В API
Thread.Builderопределён метод, позволяющий отказаться от локальных переменных потока при создании потока. Также в нём определён метод, позволяющий отказаться от наследования начального значения наследуемых локальных переменных потока. При вызове из потока, не поддерживающего локальные переменные потока,ThreadLocal.get()возвращает начальное значение, аThreadLocal.set(T)выбрасывает исключение. -
Унаследованный контекстный загрузчик классов теперь по спецификации работает как наследуемая локальная переменная потока. Если
Thread.setContextClassLoader(ClassLoader)вызывается на потоке, не поддерживающем локальные переменные потока, он выбрасывает исключение.
Scope-local variables в некоторых сценариях могут оказаться лучшей альтернативой локальным переменным потока.
java.util.concurrent
Примитивный API для поддержки блокировок, java.util.concurrent.LockSupport, теперь поддерживает потоки Virtual Threads: парковка потока Virtual Threads освобождает нижележащий платформенный поток для другой работы, а распарковка потока Virtual Threads планирует продолжение его выполнения. Благодаря этому изменению в LockSupport все использующие его API (Lock, Semaphore, блокирующие очереди и т. д.) корректно паркуются при вызове в потоках Virtual Threads.
Кроме того:
-
Executors.newThreadPerTaskExecutor(ThreadFactory)иExecutors.newVirtualThreadPerTaskExecutor()создаютExecutorService, который создаёт новый поток для каждой задачи. Эти методы обеспечивают миграцию и совместимость с существующим кодом, использующим пулы потоков иExecutorService. -
ExecutorServiceтеперь расширяетAutoCloseable, поэтому этот API можно использовать в конструкции try-with-resource, как показано в примерах выше. -
В
Futureтеперь определены методы для получения результата или исключения завершённой задачи, а также для получения состояния задачи. Вместе эти дополнения упрощают использование объектовFutureкак элементов потоков данных (streams): можно отфильтровать поток future-объектов, чтобы найти завершённые задачи, а затем преобразовать его в поток результатов. Эти методы также будут полезны вместе с дополнениями API, предложенными для Structured Concurrency.
Сетевое взаимодействие
Реализации сетевых API в пакетах java.net и java.nio.channels теперь работают с потоками Virtual Threads: операция в потоке Virtual Threads, которая блокируется, например, чтобы установить сетевое соединение или прочитать данные из сокета, освобождает нижележащий платформенный поток для другой работы.
Чтобы можно было прерывать и отменять операции, блокирующие методы ввода-вывода, определённые в java.net.Socket, ServerSocket и DatagramSocket, теперь по спецификации являются прерываемыми при вызове в потоке Virtual Threads: прерывание потока Virtual Threads, заблокированного на сокете, снимет поток с парковки (unpark) и закроет сокет. Блокирующие операции ввода-вывода на сокетах этих типов, полученных из 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, теперь меньше, чтобы сократить расход памяти, когда в куче много потоков данных или объектов writer, — как может получиться, если есть миллион потоков 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.
-
Новая возможность,
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:
-
Новый метод в
com.sun.jdi.ThreadReferenceпроверяет, является ли поток потоком Virtual Threads. -
Новые методы в
com.sun.jdi.request.ThreadStartRequestиcom.sun.jdi.request.ThreadDeathRequestограничивают события, генерируемые для запроса, платформенными потоками.
Как отмечено выше, потоки 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 был припаркован (parked) в состоянии Pinning, т. е. без освобождения своего платформенного потока (см. обсуждение). Это событие по умолчанию включено с порогом 20 мс. -
jdk.VirtualThreadSubmitFailedозначает, что запуск или снятие с парковки (unpark) потока Virtual Threads не удались, вероятно, из-за нехватки ресурсов. Это событие по умолчанию включено.
Java Management Extensions (JMX)
java.lang.management.ThreadMXBean поддерживает мониторинг и управление только для платформенных потоков. Метод findDeadlockedThreads() находит циклы платформенных потоков, находящихся во взаимной блокировке; циклы потоков Virtual Threads во взаимной блокировке он не находит.
Новый метод в com.sun.management.HotSpotDiagnosticsMXBean создаёт дамп потоков в новом формате, описанном выше. Этот метод также можно вызвать косвенно через платформенный MBeanServer из локального или удалённого инструмента JMX.
java.lang.ThreadGroup
java.lang.ThreadGroup — унаследованный API для группировки потоков, который редко используется в современных приложениях и не подходит для группировки потоков Virtual Threads. Мы уже сейчас объявляем его устаревшим и ограничиваем его функциональность, а в будущем рассчитываем представить новую конструкцию для организации потоков в рамках Structured Concurrency.
Для справки: API ThreadGroup существует со времён Java 1.0. Изначально он предназначался для операций управления заданиями, например для остановки всех потоков в группе. Современный код скорее использует API пулов потоков из пакета java.util.concurrent, появившиеся в Java 5. ThreadGroup поддерживал изоляцию апплетов в ранних выпусках Java, но архитектура безопасности Java существенно изменилась в Java 1.2, и группы потоков перестали играть значимую роль. ThreadGroup также задумывался как полезный для диагностики, но эту роль взяли на себя средства мониторинга и управления, появившиеся в Java 5, включая API java.lang.management.
Помимо того что API ThreadGroup и его реализация сейчас в значительной мере утратили актуальность, у них есть ряд существенных проблем:
-
API и механизм уничтожения групп потоков имеют изъяны.
-
API требует, чтобы реализация хранила ссылки на все живые потоки в группе. Это добавляет накладные расходы на синхронизацию и конкуренцию при создании, запуске и завершении потоков.
-
API определяет методы
enumerate(), которые по своей природе подвержены состояниям гонки. -
API определяет методы
suspend(),resume()иstop(), которые по своей природе склонны к взаимным блокировкам и небезопасны.
Теперь ThreadGroup специфицирован, объявлен устаревшим и ограничен в функциональности следующим образом:
-
Возможность явно уничтожить группу потоков удалена: окончательно объявленный устаревшим метод
destroy()ничего не делает. -
Понятие групп потоков-демонов удалено: статус демона, устанавливаемый и получаемый окончательно объявленными устаревшими методами
setDaemon(boolean)иisDaemon(), игнорируется. -
Реализация больше не хранит сильные ссылки на подгруппы. Теперь группы потоков могут быть удалены сборщиком мусора, когда в группе нет живых потоков и ничто другое не удерживает группу потоков.
-
Окончательно объявленные устаревшими методы
suspend(),resume()иstop()всегда выбрасывают исключение.
Альтернативы
-
Продолжать полагаться на асинхронные API. Асинхронные API трудно интегрировать с синхронными API, они разделяют мир на два представления одних и тех же операций ввода-вывода и не дают единого понятия последовательности операций, которое платформа могла бы использовать как контекст для диагностики неполадок, мониторинга, отладки и профилирования.
-
Добавить в язык Java синтаксические бесстековые корутины (т. е. async/await). Их проще реализовать, чем потоки пользовательского режима, и они дали бы объединяющую конструкцию, представляющую контекст последовательности операций.
Однако эта конструкция была бы новой и отдельной от потоков, во многом похожей на них, но в некоторых тонкостях отличающейся. Она разделила бы мир на API, спроектированные для потоков, и API, спроектированные для корутин, и потребовала бы внедрить новую потокоподобную конструкцию во все уровни платформы и её инструментов. Экосистеме понадобилось бы больше времени на её освоение, и она была бы не столь элегантной и гармоничной с платформой, как потоки пользовательского режима.
Большинство языков, которые приняли синтаксические корутины, сделали это из-за невозможности реализовать потоки пользовательского режима (например, Kotlin), унаследованных семантических гарантий (например, изначально однопоточный JavaScript) или технических ограничений, присущих языку (например, C++). К Java эти ограничения не относятся.
-
Ввести новый публичный класс для представления потоков пользовательского режима, не связанный с
java.lang.Thread. Это дало бы возможность избавиться от ненужного груза, который классThreadнакопил за 25 лет. Мы исследовали и создали прототипы нескольких вариантов этого подхода, но в каждом случае сталкивались с вопросом, как выполнять существующий код.Главная проблема в том, что
Thread.currentThread()прямо или косвенно повсеместно используется в существующем коде (например, для определения владельца блокировки или для локальных переменных потока). Этот метод должен возвращать объект, представляющий текущий поток выполнения. Если бы мы ввели новый класс для представления потоков пользовательского режима, тоcurrentThread()пришлось бы возвращать некий объект-обёртку, который выглядит какThread, но делегирует объекту потока пользовательского режима.Иметь два объекта, представляющих текущий поток выполнения, было бы запутанно, поэтому в итоге мы пришли к выводу, что сохранение старого API
Threadне является серьёзным препятствием. За исключением нескольких методов, таких какcurrentThread(), разработчики редко используют APIThreadнапрямую; в основном они работают с API более высокого уровня, такими какExecutorService. Со временем мы избавимся от ненужного груза в классеThreadи связанных с ним классах, таких какThreadGroup, объявляя устаревшими и удаляя неактуальные методы.
Тестирование
-
Существующие тесты позволят убедиться, что предлагаемые здесь изменения не вызывают неожиданных регрессий во множестве конфигураций и режимов выполнения, в которых эти тесты запускаются.
-
Мы расширим тестовую среду
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.ThreadGroupбольше не позволяет уничтожать группу потоков, больше не поддерживает понятие групп потоков-демонов, а его методыsuspend(),resume()иstop()всегда выбрасывают исключения.
Есть несколько изменений API, несовместимых на уровне исходного кода, и одно изменение, несовместимое на двоичном уровне, которые могут затронуть код, расширяющий java.lang.Thread:
-
Если код в существующем исходном файле расширяет
Threadи метод подкласса конфликтует с каким-либо из новых методовThread, то файл без изменений не скомпилируется. -
Thread.Builderдобавлен как вложенный интерфейс. Если код в существующем исходном файле расширяетThread, импортирует класс с именемBuilder, а код в подклассе обращается к «Builder» по простому имени, то файл без изменений не скомпилируется. -
Thread.threadId()добавлен как final-метод, возвращающий идентификатор потока. Если код в существующем исходном файле расширяетThreadи подкласс объявляет метод с именемthreadIdбез параметров, то он не скомпилируется. Если существующий скомпилированный код расширяетThreadи подкласс определяет метод с именемthreadIdс типом возвращаемого значенияlongи без параметров, то при загрузке подкласса во время выполнения будет выброшеноIncompatibleClassChangeError.
При совместном использовании существующего кода и нового кода, который использует Virtual Threads или новые API, можно заметить несколько различий в поведении платформенных потоков и потоков Virtual Threads:
-
Метод
Thread.setPriority(int)не влияет на потоки Virtual Threads: их приоритет всегда равенThread.NORM_PRIORITY. -
Метод
Thread.setDaemon(boolean)не влияет на потоки Virtual Threads: они всегда являются потоками-демонами. -
Методы Thread.
stop(),suspend()иresume()выбрасываютUnsupportedOperationExceptionпри вызове для потока 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 могут корректно парковаться при рефлексивном вызове методов.
-
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 определил интерфейс поставщика услуг (SPI) для поиска имён хостов и адресов. Так сторонние библиотеки смогут реализовать альтернативные резолверы
java.net.InetAddress, которые не привязывают потоки во время поиска хоста.