JEP 491: Synchronize Virtual Threads without Pinning
синхронизация потоков Virtual Threads (виртуальные потоки) без Pinning (привязка виртуального потока к платформенному)
| Автор | Patricio Chilano Mateo & Alan Bateman |
| Ответственный | Alan Bateman |
| Тип | Feature |
| Область | Implementation |
| Статус | Closed / Delivered |
| Выпуск | 24 |
| Компонент | hotspot / runtime |
| Обсуждение | hotspot dash dev at openjdk dot org, loom dash dev at openjdk dot org |
| Трудоёмкость | M |
| Связан с | JEP 444: Virtual Threads |
| Рецензенты | Daniel Daugherty, Vladimir Kozlov |
| Одобрен | Vladimir Kozlov |
| Создан | 2024/07/29 17:09 |
| Обновлён | 2025/11/27 09:24 |
| Задача | 8337395 |
Аннотация
Улучшить масштабируемость Java-кода, использующего методы и операторы synchronized: потоки Virtual Threads, которые блокируются в таких конструкциях, будут освобождать нижележащие платформенные потоки, чтобы их могли использовать другие потоки Virtual Threads. Это устранит почти все случаи Pinning, из-за которого резко сокращается число потоков Virtual Threads, доступных для обработки рабочей нагрузки приложения.
Цели
-
Позволить существующим Java-библиотекам хорошо масштабироваться с Virtual Threads без необходимости изменять их так, чтобы они не использовали методы и операторы
synchronized. -
Улучшить диагностику, выявляющую оставшиеся ситуации, в которых потоки Virtual Threads не освобождают платформенные потоки.
Мотивация
Virtual Threads, появившиеся в Java 21 в JEP 444, — это легковесные потоки, которые предоставляет JDK, а не операционная система (ОС). Virtual Threads значительно упрощают разработку, сопровождение и наблюдение конкурентных приложений с высокой пропускной способностью, позволяя приложениям использовать огромное число потоков. Базовая модель Virtual Threads такова:
-
Чтобы выполнять полезную работу, поток должен быть запланирован, то есть назначен на выполнение на ядре процессора. Для платформенных потоков, которые реализованы как потоки ОС, JDK полагается на планировщик ОС. Для потоков Virtual Threads, напротив, у JDK есть собственный планировщик. Вместо того чтобы назначать потоки Virtual Threads непосредственно ядрам процессора, планировщик JDK назначает их платформенным потокам, которые затем, как обычно, планирует ОС.
-
Чтобы выполнить код в потоке Virtual Threads, планировщик JDK назначает этот поток на выполнение на платформенном потоке, монтируя (mounting) поток Virtual Threads на платформенный поток. Так платформенный поток становится потоком-носителем (carrier) потока Virtual Threads. Позже, выполнив какой-то код, поток Virtual Threads может размонтироваться (unmount) со своего потока-носителя. В этот момент платформенный поток освобождается, и планировщик JDK может смонтировать на него другой поток Virtual Threads, снова сделав его потоком-носителем.
-
Поток Virtual Threads размонтируется при выполнении блокирующей операции, например ввода-вывода. Позже, когда блокирующая операция готова завершиться, например потому, что на сокет пришли байты, операция снова передаёт поток Virtual Threads планировщику JDK. Планировщик монтирует поток Virtual Threads на платформенный поток, чтобы продолжить выполнение кода.
Потоки Virtual Threads монтируются и размонтируются часто и прозрачно, не блокируя ни одного платформенного потока.
Pinning потоков Virtual Threads в методах synchronized
К сожалению, поток Virtual Threads не может размонтироваться, пока выполняет код внутри метода synchronized. Рассмотрим следующий метод synchronized, который читает байты из сокета:
synchronized byte[] getData() {
byte[] buf = ...;
int nread = socket.getInputStream().read(buf); // Can block here
...
}
Если метод read блокируется, потому что доступных байтов нет, мы хотели бы, чтобы поток Virtual Threads, выполняющий getData, размонтировался со своего потока-носителя. Это освободило бы платформенный поток, и планировщик JDK мог бы смонтировать на него другой поток Virtual Threads. К сожалению, поскольку getData объявлен как synchronized, JVM закрепляет поток Virtual Threads, выполняющий getData, за его потоком-носителем. Pinning не даёт потоку Virtual Threads размонтироваться. В результате метод read блокирует не только поток Virtual Threads, но и его поток-носитель, а значит, и нижележащий поток ОС, пока не появятся байты для чтения.
Причина Pinning
Ключевое слово synchronized в языке программирования Java определено через мониторы: с каждым объектом связан монитор, который можно захватить (т. е. заблокировать), удерживать некоторое время, а затем освободить (т. е. разблокировать). Монитор объекта одновременно может удерживать только один поток. Чтобы выполнить экземплярный метод synchronized, поток сначала захватывает монитор, связанный с экземпляром; когда метод завершается, поток освобождает монитор.
Для реализации ключевого слова synchronized JVM отслеживает, какой поток сейчас удерживает монитор объекта. К сожалению, она отслеживает, какой платформенный поток удерживает монитор, а не какой поток Virtual Threads. Когда поток Virtual Threads выполняет экземплярный метод synchronized и захватывает монитор, связанный с экземпляром, JVM записывает в качестве владельца монитора платформенный поток-носитель этого потока Virtual Threads, а не сам поток Virtual Threads.
Если бы поток Virtual Threads размонтировался внутри экземплярного метода synchronized, планировщик JDK вскоре смонтировал бы какой-нибудь другой поток Virtual Threads на освободившийся платформенный поток. Этот другой поток Virtual Threads из-за своего потока-носителя рассматривался бы JVM как удерживающий монитор, связанный с экземпляром. Код, выполняющийся в этом потоке, мог бы вызывать другие методы synchronized этого экземпляра или освободить монитор, связанный с экземпляром. Взаимное исключение было бы потеряно. Поэтому JVM активно препятствует размонтированию потока Virtual Threads внутри метода synchronized.
Другие случаи Pinning
Если поток Virtual Threads вызывает экземплярный метод synchronized, а монитор, связанный с экземпляром, удерживает другой поток, то поток Virtual Threads должен заблокироваться, поскольку монитор одновременно может удерживать только один поток. Мы хотели бы, чтобы поток Virtual Threads размонтировался со своего потока-носителя и вернул этот платформенный поток планировщику JDK. К сожалению, если монитор уже удерживается другим потоком, поток Virtual Threads блокируется в JVM, пока поток-носитель не захватит монитор.
Более того, когда поток Virtual Threads находится внутри экземплярного метода synchronized и вызывает Object.wait() для объекта, он блокируется в JVM, пока его не разбудят с помощью Object.notify() и поток-носитель повторно не захватит монитор. Поток Virtual Threads закреплён, потому что выполняется внутри метода synchronized, и закреплён ещё и потому, что его поток-носитель заблокирован в JVM.
Всё сказанное выше с соответствующими изменениями относится и к методам synchronized static, которые синхронизируются на мониторе, связанном с объектом Class для класса метода, и к операторам synchronized, которые синхронизируются на мониторе, связанном с указанным объектом.
Преодоление Pinning
Частый и продолжительный Pinning может ухудшить масштабируемость. Он может привести к голоданию или даже к взаимной блокировке, когда ни один поток Virtual Threads не может выполняться, потому что все платформенные потоки, доступные планировщику JDK, либо закреплены потоками Virtual Threads, либо заблокированы в JVM. Чтобы избежать этих проблем, сопровождающие многих библиотек изменили свой код так, чтобы вместо методов и операторов synchronized использовать блокировки java.util.concurrent — которые не закрепляют потоки Virtual Threads.
Однако для того, чтобы получить выигрыш в масштабируемости от Virtual Threads, не должно быть необходимости отказываться от методов и операторов synchronized. Реализация ключевого слова synchronized в JVM должна позволять потоку Virtual Threads размонтироваться внутри метода или оператора synchronized, а также при блокировке на мониторе. Это позволило бы шире использовать Virtual Threads.
Описание
Мы изменим реализацию ключевого слова synchronized в JVM так, чтобы потоки Virtual Threads могли захватывать, удерживать и освобождать мониторы независимо от своих потоков-носителей. Операции монтирования и размонтирования будут вести необходимый учёт, чтобы поток Virtual Threads мог размонтироваться и снова смонтироваться внутри метода или оператора synchronized либо при ожидании на мониторе.
Блокировка при захвате монитора будет размонтировать поток Virtual Threads и возвращать его поток-носитель планировщику JDK. Когда монитор освободится и JVM выберет этот поток Virtual Threads для продолжения, JVM передаст поток Virtual Threads планировщику. Планировщик смонтирует поток Virtual Threads, возможно, на другой поток-носитель, чтобы возобновить выполнение и снова попытаться захватить монитор.
Метод Object.wait() и его варианты с ожиданием по тайм-ауту аналогично будут размонтировать поток Virtual Threads при ожидании и при блокировке для повторного захвата монитора. Когда поток будет разбужен с помощью Object.notify(), монитор освобождён и JVM выберет этот поток Virtual Threads для продолжения, JVM передаст поток Virtual Threads планировщику для возобновления выполнения.
Диагностика оставшихся случаев Pinning
JDK Flight Recorder (JFR) записывает событие jdk.VirtualThreadPinned каждый раз, когда поток Virtual Threads блокируется внутри метода synchronized. Это событие помогало выявлять код, который стоило бы изменить: реже использовать методы и операторы synchronized, не блокироваться внутри таких конструкций или заменить такие конструкции блокировками java.util.concurrent.
Это событие JFR больше не понадобится для этой цели, когда ключевое слово synchronized перестанет закреплять потоки Virtual Threads, но мы сохраним его для других ситуаций Pinning. В частности, если поток Virtual Threads вызывает нативный код — через метод native или через Foreign Function & Memory API, — а этот нативный код обращается обратно к Java-коду, который выполняет блокирующую операцию или блокируется на мониторе, то поток Virtual Threads будет закреплён. Поэтому мы изменим JVM так, чтобы в этих случаях она выдавала событие jdk.VirtualThreadPinned, и доработаем само событие, чтобы оно сообщало и причину, по которой поток Virtual Threads закреплён, и то, какой поток служит носителем.
Системное свойство jdk.tracePinnedThreads больше не нужно
Системное свойство jdk.tracePinnedThreads, появившееся в JEP 444, приводит к выводу трассировки стека каждый раз, когда поток Virtual Threads блокируется внутри метода synchronized, но не тогда, когда поток Virtual Threads блокируется при захвате монитора или ожидании в Object.wait().
Это системное свойство станет ненужным, когда ключевое слово synchronized перестанет закреплять потоки Virtual Threads. Кроме того, оно оказалось проблемным, поскольку трассировки стека выводятся во время выполнения критического кода. Поэтому мы удалим это системное свойство; его установка в командной строке ни на что не будет влиять.
Выбор между synchronized и java.util.concurrent.locks
Когда ключевое слово synchronized перестанет закреплять потоки Virtual Threads, вы сможете выбирать между synchronized и API из пакета java.util.concurrent.locks, исходя исключительно из того, что лучше решает стоящую задачу.
Для справки: пакет java.util.concurrent.locks определяет API для блокировки и ожидания, которые отличаются от встроенного ключевого слова synchronized и более гибки. API ReentrantLock ведёт себя так же, как synchronized. API Condition — эквивалент методов Object.wait() и Object.notify(). Другие API в пакете дают больше возможностей и более тонкое управление для сложных случаев, где требуются справедливость, конкурентный доступ к общим данным с блокировками чтения-записи, захват блокировки с тайм-аутом или с возможностью прерывания либо оптимистичное чтение.
За гибкость API java.util.concurrent.locks приходится платить более громоздким синтаксисом. Эти API, как правило, следует использовать с конструкцией try-finally, чтобы гарантировать правильное освобождение блокировок; с synchronized это, конечно, не нужно. Кроме того, у API java.util.concurrent.locks другие характеристики производительности, чем у методов или операторов synchronized.
Ранее мы рекомендовали решать проблемы частого и продолжительного Pinning, переводя код с synchronized на ReentrantLock. Когда ключевое слово synchronized перестанет закреплять потоки Virtual Threads, такой перевод станет ненужным. Код, уже переведённый на ReentrantLock, не нужно возвращать к synchronized.
Если вы пишете новый код, мы согласны с рекомендацией из Java Concurrency in Practice §13.4: используйте synchronized, где это возможно, так как это удобнее и реже приводит к ошибкам, а ReentrantLock и другие API из java.util.concurrent.locks используйте, когда нужна большая гибкость. В любом случае снижайте вероятность конкуренции за блокировки, сужая область их действия, и по возможности избегайте ввода-вывода и других блокирующих операций при удержании блокировок.
Дальнейшая работа
Остаётся несколько случаев, не связанных с ключевым словом synchronized, в которых поток Virtual Threads при блокировке не может выполнить unmount:
-
При разрешении символьной ссылки (JVMS §5.4.3) на класс или интерфейс, когда поток Virtual Threads блокируется во время загрузки класса. В этом случае поток Virtual Threads закрепляет carrier-поток из-за нативного фрейма в стеке.
-
При блокировке внутри инициализатора класса. В этом случае поток Virtual Threads тоже закрепляет carrier-поток из-за нативного фрейма в стеке.
-
При ожидании инициализации класса другим потоком (JVMS §5.5). Это особый случай: поток Virtual Threads блокируется в JVM и тем самым закрепляет carrier-поток.
Эти случаи должны редко вызывать проблемы, но если они окажутся проблемными, мы к ним вернёмся.
Альтернативы
-
Компенсировать Pinning, временно увеличивая параллелизм планировщика Virtual Threads. Для
Object.wait()планировщик уже так делает: пока поток Virtual Threads ждёт, он обеспечивает наличие запасного платформенного потока.Увеличение параллелизма помогло бы в некоторых случаях, но этот подход не масштабируется. Максимальное число платформенных потоков, доступных планировщику, ограничено: по умолчанию это 256 потоков. Если бы много потоков Virtual Threads заблокировались внутри метода
synchronized, никакое значение параллелизма не помогло бы. -
Переписывать байт-код каждого класса при его загрузке в JVM, заменяя каждое использование
synchronizedэквивалентным использованиемReentrantLock.Оператор
synchronizedможно использовать с любым объектом, поэтому пришлось бы поддерживать отображение объектов на блокировки, а это значительные накладные расходы.В некоторых случаях такое преобразование было бы не полностью прозрачным, в частности для методов
synchronized, так как JVMS §2.11.10 требует захватывать монитор до вызова метода.У этого подхода есть и много других трудностей в таких областях, как блокировки JNI, ряд возможностей JVM TI и требование JVMS, чтобы монитор автоматически освобождался во всех случаях. Кроме того, этот подход потребовал бы заново реализовать многие функции обслуживаемости (serviceability).
Риски и допущения
Производительность некоторого кода может отличаться, когда вместо платформенных потоков используются потоки Virtual Threads. Когда поток выходит из монитора, ему может потребоваться поставить поток Virtual Threads в очередь планировщика. Сейчас это не так эффективно, как случай, когда выход из монитора возобновляет (unpark) платформенный поток.
Зависимости
Предлагаемые здесь изменения зависят от изменения спецификации функции JVM TI GetObjectMonitorUsage в Java 23. Эта функция больше не поддерживает возврат информации о мониторах, которыми владеют потоки Virtual Threads. Для этого потребовался бы значительный учёт, чтобы находить мониторы, которыми владеют потоки Virtual Threads, не смонтированные на carrier-поток.