[Перевод] Java ScopedValue: Ускоренный ThreadLocal
В инкубаторе JEP-429 появилась новая альтернатива ThreadLocal под названием ScopedValue (в значительной степени как поддержка и развитие Virtual Threads). ScopedValue предназначен для того, чтобы предоставить более легкую альтернативу ThreadLocal, которая хорошо работает с виртуальными потоками, а также решает многочисленные недостатки, присущие API своего аналога двадцатипятилетней давности. В этой статье мы рассмотрим основные отличия ScopedValue, и за счет чего он может работать быстрее.
Зачем было создавать ScopedValue?
В целом, в самом JEP отлично объясняются преимущества ScopedValue над ThreadLocal, ставшие причиной его создания. Из них можно выделить несколько ключевых моментов:
Обеспечить иммутабельность и сделать жизненный цикл объекта в структуре API явным. Это упрощает API, снижает риск возникновения ошибок, а также значительно расширяет возможности оптимизации производительности при его реализации.
Перейти к легковесной, удобной для процессора реализации, которая не платит за локализацию потоков ту же цену и максимизирует производительность для обычных/предполагаемых юзкейсов, с прицелом на сценарии с использованием виртуальных потоков со многими тысячами живых потоков (что вполне реально).
Обеспечить дешевое/бесплатное наследование значений от родительских потоков к дочерним потокам с помощью облегченной модели совместного использования данных, опять же с учетом расширенных сценариев использования виртуальных потоков.
Из-за различий в подходе к API явно указано, что ScopedValue не предназначен в качестве замены для всех случаев, когда может использоваться ThreadLocal; только для тех общих сценариев, когда thread-locals используются для захвата контекста потока:
Есть несколько сценариев, в которых лучше использовать thread-local переменные. Например, кэширование объектов, создание и использование которых требует больших затрат, таких как инстансы java.text.DateFormat. Как известно, объект DateFormat является мутабельным, поэтому его нельзя передавать между потоками без синхронизации. Зачастую самым практичным подходом является предоставление каждому потоку своего собственного объекта DateFormat через thread-local переменную, которая сохраняется в течение всего времени жизни потока.
Scoped values перешли в стадию превью в Java 20+ (включая последний релиз Java 21). По этому вам потребуется установить параметр --enable-preview в компиляторе и JVM args.
Примеры использования API
Сравнение конструкций
Исторически сложилось так, что если вы хотели иметь значение, которое было бы «глобальным для потока», thread-locals предоставляли удобный способ реализовать это. Ниже приведен пример кода, показывающий, как это может выглядеть:
private static final ThreadLocal CURRENT_FRUIT = new ThreadLocal<>();
// ...
CURRENT_FRUIT.set("banana");
printFruit();
CURRENT_FRUIT.set("apple");
printFruit();
CURRENT_FRUIT.remove();
// ...
void printFruit() {
System.out.println("Fruit: " + currentFruit.get());
}
В результате будет выведено banana
, а затем apple
.
Scoped values работают аналогично с точки зрения API, но их привязка теперь лежит в гораздо более явной области видимости, которая контролируется API. Принудительное использование runnable или callable гарантирует, что базовые значения будут правильно утилизированы по завершении области видимости:
private static final ScopedValue currentFruit = ScopedValue.newInstance();
// ...
ScopedValue
.where(currentFruit, "banana")
.run(() -> printFruit());
ScopedValue
.where(currentFruit, "apple")
.run(() -> printFruit());
// ...
void printFruit() {
System.out.println("Fruit: " + currentFruit.get());
}
Для распространенного случая с одним значением существуют более удобные аналоги — callWhere
и runWhere
, но я здесь демонстрирую более общую форму, чтобы проиллюстрировать, что создание серии привязок отделено от выполнения участков кода с ними.
Null
Основное различие между thread locals и scoped values заключается в обработке null b unset значений. ThreadLocal вернет null
из get()
, независимо от того, является ли значение незаданным или было явно установлено в null
:
// Выводит 'Fruit: null'
printFruit();
CURRENT_FRUIT.set(null);
// Выводит 'Fruit: null'
printFruit();
ScopedValue
, с другой стороны, проводит четкое различие между этими двумя понятиями и рассматривает несвязанные значения как ошибку:
// Ошибка с java.util.NoSuchElementException
printFruit();
// Выводит 'Fruit: null'
ScopedValue.runWhere(CURRENT_FRUIT, null, () -> printFruit());
Рекурсия
Scoped values также можно использовать рекурсивно, что будет выглядеть так, как вы скорее всего и ожидаете:
ScopedValue.where(CURRENT_FRUIT, "banana").run(() -> {
printFruit();
ScopedValue
.where(CURRENT_FRUIT, "apple")
.run(() -> printFruit());
printFruit();
});
Здесь будет выведено banana
, apple
, а затем снова banana
. С thread locals это потребовало бы больше работы руками над кодом с целью предотвращения ошибок:
CURRENT_FRUIT.set("banana");
printFruit();
var lastFruit = CURRENT_FRUIT.get();
try {
CURRENT_FRUIT.set("apple");
printFruit();
} finally {
CURRENT_FRUIT.set(lastFruit);
}
printFruit();
CURRENT_FRUIT.remove();
Наследование потоками
Еще одна интересующая нас область — границы потоков. Чтобы thread locals пересекали границы потоков, необходимо использовать специальный вариант thread local — InheritableThreadLocal
. Этот тип будет захвачен при порождении потока и перенесен в новый поток, но любые изменения, внесенные в thread local в любом из этих потоков, будут независимы друг от друга:
INHERITABLE_FRUIT.set("banana");
printFruit();
new Thread(() -> {
printFruit();
INHERITABLE_FRUIT.set("kiwi");
printFruit();
sleep(2000);
printFruit();
}).start();
sleep(1000);
printFruit();
INHERITABLE_FRUIT.set("apple");
printFruit();
Это выведет на печать следующее:
banana // из родительского потока
banana // из дочернего потока
kiwi // из дочернего потока
banana // из родительского потока
apple // из родительского потока
kiwi // из дочернего потока
Scoped values, с другой стороны, не наследуются обычными потоками. Единственный случай, когда scopes values наследуются, — это структурированный параллелизм. Хотя полная иллюстрация новых API для структурированного параллелизма выходит за рамки этой статьи, вот пример аналогичной конструкции:
ScopedValue.callWhere(CURRENT_FRUIT, "banana", () -> {
printFruit();
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
scope.fork(() -> {
printFruit();
ScopedValue.runWhere(CURRENT_FRUIT, "kiwi", () -> {
printFruit();
sleep(2000);
printFruit();
});
return null;
});
sleep(1000);
printFruit();
ScopedValue.runWhere(CURRENT_FRUIT, "apple", () -> printFruit());
scope.join();
}
return null;
});
Вывод будет таким же, как и в примере с thread local.
Детали реализации
ThreadLocal
В JEP природа реализации thread local обсуждается вкратце, но давайте все-таки заглянем под капот.
Thread locals хранятся непосредственно в каждом потоке в виде ThreadLocalMap
, которая представляет собой map с массивом. Эта map является довольно примитивной и предназначена специально для использования с thread locals:
Сам объект ThreadLocal
выступает в роли логического ключа в карте. Хэш-код ключа используется для поиска позиции в массиве записей, а коллизии обрабатываются довольно простой методикой «try next index» для поиска свободного места.
Кроме того, thread local map регулярно пытается удалить (expunge
) устаревшие записи, т.е. те, которые больше не связаны ни с одним действительным значением. Этот процесс запускается в различных потоках, например, в сценариях потенциального изменения размера, а также при возникновении хэш-коллизий.
Один из наиболее примечательных сценариев работы с ThreadLocalMap
— это работа с дочерними потоками. Существует специальный тип thread local, именуемый InheritableThreadLocal
, который предназначен для переноса состояния thread local из родительских потоков в дочерние. Однако, согласно спецификации, после переноса в дочерний поток любые изменения значения родительского потока не распространяются на дочерний поток, и наоборот.
Такое поведение в сочетании с «всегда мутабельной» природой thread locals приводит к неизбежной неэффективной реализации thread locals, поскольку у дочернего потока нет другого выбора, кроме как поспешно скопировать все состояние thread local во время конструирования. Рассмотрим ожидаемое поведение:
Наследуемый thread local в родительском потоке
Parent
имеет значение »test
»Из
Parent
создается/формируется дочерний потокChild
У потока
Child
есть значение »test
».У
Parent
thread local значение переустановлено на »test2
».Child
все еще имеет значение »test
»
В поддержку этого, внутренняя реализация выглядит следующим образом:
Другими словами, в системе существует две полностью независимые копии значений, хранящихся в двух независимых массивах кучи. Даже если значения являются полностью неизменяемыми.
Если задуматься о виртуальных потоках и о том, что в памяти находится десять тысяч или даже сто тысяч виртуальных потоков, то, надеюсь, становится понятно, как это наследуемое копирование thread local map может создавать серьезное давление на память.
ScopedValue
По сравнению с thread locals, scoped values разработаны с учетом оптимизированной внутренней модели. Нам определенно стоит взглянуть на детали их реализации.
При использовании scoped values основными механизмами для хранения связанных значений являются Carrier и Snapshot:
Сам объект
ScopedValue
ведет себя как ключ map; это уникальный указатель на значение, заданное в других ссылках. Здесь же обычно располагается API, ориентированный на пользователя, подобно объектуThreadLocal
.Объекты-носители (
Carrier
) представляют собой привязку значения к scoped value в определенный момент времени; фактически это пара «ключ-значение». Однако носители моделируются как связанный список (или цепочка) привязок, так что когда вызывающая сторона говорит что-то вроде.where(scope1, "xyz").where(scope2, "abc").run(() -> { ... })
, и scope1, и scope2 находятся в пути поиска для этого конкретного набора привязок носителя — фактически содержащееся значение будетCarrier[scope2 -> "abc"] -> Carrier[scope1 -> "xyz"]
.Объекты
Snapshot
— это место, где объекты-носители сохраняются для выполнения в области видимости. Снапшоты создаются при вызовеrun
илиcall
. Каждый снапшот на самом деле представляет собой «tier of scoping» в обработке. Как и носители, снапшоты моделируются как цепочка, поэтому по мере вложения scoping-объектов снапшоты будут расширяться друг от друга.
Все эти описания могут ненашутку озадачить, поэтому мы лучше попробуем использовать диаграмму в сочетании с кодом, чтобы немного упростить понимание. Обращаясь к предыдущим примерам, рассмотрим логику scoped value:
// Снапшот 1: [Carrier B -> "b2", Carrier A -> "a1"]
// Предыдущий: нет
ScopedValue.where(A, "a1").where(B, "b2")
.run(() -> {
// Снапшот 2: [Carrier C -> "c3"]
// Предыдущий: Снапшот 1
ScopedValue.where(C, "c3", () -> {
// Снапшот 3: [Carrier D -> "d5", Carrier A -> "a4"]
// Предыдущий: Снапшот 3
ScopedValue.where(A, "a4").where(D, "d5")
.run(() -> doSomething(A.get(), B.get(), C.get(), D.get()));
});
});
Напомним, что в момент вызова функции doSomething()
scopes values будут иметь такие значения:
A=a4
B=b2
C=c3
D=d5
Вот визуализация этой структуры:
Когда граница выполнения завершается, снапшот (и все носители) «сбрасываются», простым переходом потока к предыдущему (prev) снапшоту.
С точки зрения реализации, поскольку Snapshot
и связанные с ним объекты Carrier
представляют собой неизменяемую структуру данных, снапшот можно свободно передавать друг другу через границы потоков без риска повреждения или необходимости копирования или иной защиты значений для многопоточности.
Для традиционных thread locals каждый раз, когда создается новый дочерний поток, наследуемые значения копируются в новый поток, но при использовании scoped values это просто указатель на неизменяемый снапшот; «предыдущая» структура в иерархии. Фактически, новые объекты в этой модели создаются только при выполнении новых областей видимости. Изменение scoped value или добавление дополнительных scoped values с помощью where приводит к появлению новых носителей и нового снапшота на время выполнения этого блока кода.
Быстрый поиск
Хотя эта неизменяемая иерархия является большим преимуществом для совместного использования scoped привязки по потокам, в нее встроены дополнительные преимущества с точки зрения производительности, чтобы помочь в этой работе на больших масштабах. В частности, поиск значений в иерархии снапшотов происходит относительно медленно (по сравнению с простым поиском в хэш-таблице).
На примере выше мы можем проверить, насколько медленным это может быть, выполнив наивный обход для значения B
(первое связанное значение), находясь внутри Snapshot3
(самая внутренняя привязка):
Проверка носителя 1 снапшота 3 на наличие B — нет
Проверка носителя 2 снапшота 3 на наличие B — нет
Проверка носителя 1 снапшота 2 на наличие B — нет
Проверка носителя 2 снапшота 2 на наличие B — нет
Проверка носителя 1 снапшота 1 на наличие B — нет
Наконец: Проверка носителя 2 снапшота 1 носителя 2 на наличие B — да!
Для такого медленного обхода используются две оптимизации. Первая — это битовая маска для всех значений. Ниже представлен высокоуровневый обзор этой битовой маски:
Каждое
ScopedValue
имеетhash
, который генерируется случайным образом (по состоянию на Java 21, с помощью генератора xor сдвига Марсальи)Для любого заданного значения
ScopedValue
вычисляется bitmask, которая служит отпечатком фиксированного размера (хотя и потенциально неуникальным) для значения ScopedValue.Каждый носитель при привязке получает битовую маску ScopedValue, к которому он привязан. Если носитель имеет предыдущую привязку, то битовая маска носителя претерпевает побитовое или с битовой маской предыдущего носителя. Эта аддитивная операция создаст битовую маску, представляющую все привязки носителя
Аналогично, каждый снапшот имеет битовую маску, равную битовой маске его головного носителя (который может представлять несколько привязок), объединенную с любыми битовыми масками предыдущих снапшотов с помощью побитового или.
При поиске привязки битовая маска ScopedValue сравнивается со снапшотом
Если маска не задана, то известно, что значение не привязано к данному снапшоту.
Если маска задана, то обходятся носители снапшотов, проверяя совпадение, если/пока маска не совпадает
Если носитель не найден, выполняется переход к предыдущему снапшоту
Этот процесс повторяется до тех пор, пока не будет найдена самая последняя привязка, или пока не будет найдена маска/привязка
По сути, эта битовая маска действует как фильтр Блума и позволяет очень эффективно находить «вероятные» привязки, но может давать ложные срабатывания в случае коллизий битовых масок.
Вот более конкретный вид того, как это может выглядеть на примере, приведенном выше. Для этой диаграммы я буду использовать упрощенное представление битовой маски, в частности, такого вида:
A = [1,0,0,1,0,0,0,0]
B = [0,1,0,0,1,0,0,0]
C = [0,0,1,0,0,1,0,0]
D = [0,0,0,0,0,0,1,1]
Как видите, в этом упрощенном примере все слоты заняты и коллизий нет. Важной деталью, которую необходимо проследить здесь, является то, что в случае коллизий логика поиска просто вернется к более медленной модели, однако битовое пространство в идеале достаточно велико, а количество используемых scoped values в идеале достаточно мало, чтобы коллизии происходили довольно редко.
Вот как эта структура с битовыми масками будет выглядеть в иерархии снапшотов:
С помощью этой иерархии можно увидеть, что на снапшоте 3 мы можем быстро убедиться, что, скорее всего, все scoped values здесь установлены.
Еще один компонент, который существует для еще большего повышения производительности обхода, — это ленивый кэш для каждого потока. Каждый поток содержит специальный ScopedValueCache
(просто Object[]
), который имеет заранее определенный, постоянный размер. Для облегчения использования кэша используется хэш ScopedValue
:
При сохранении значения в кэше будет совершена попытка выбрать первичное местоположение слота или вторичное местоположение слота, вычисленное на основе хэша.
Если первичный слот доступен,
Для заданного хэша вычисляется первичный слот, в котором scoped value может находиться в кэше, и проверяется это местоположение.
Если значение не найдено, вычисляется вторичный слот, в котором оно может находиться
Согласно документации по самим scoped values, все это оптимизировано с учетом того, что к scoped values может обращаться много потоков, но не очень много scoped values (что означает: меньше шансов для коллизий и переворачивания кэша):
Scoped values предназначены для использования в довольно небольшом количестве.
get()
сначала выполняет поиск по окружающим областям видимости, чтобы найти самую внутреннюю привязку scoped value. Затем он кэширует результат поиска в небольшом thread-local кэше. Последующие вызовыget()
для этого scoped value почти всегда будут очень быстрыми. Однако если в программе много scoped values, которые она использует циклически, частота попадания в кэш будет мдаленькой, а производительность — низкой. Такая конструкция позволяет наследовать scoped values потокамиStructuredTaskScope
очень быстро: по сути, это не более чем копирование указателя, а выход из привязки scoped value также требует не более чем обновления указателя.Поскольку кэш scoped values для каждого потока достаточно небольшой, клиентам следует минимизировать количество используемых связанных scoped values. Например, если необходимо передать несколько значений таким образом, имеет смысл создать класс записи для хранения этих значений, а затем привязать одно
ScopedValue
к экземпляру этой записи.В этом релизе эталонная реализация предоставляет некоторые системные свойства для настройки производительности scoped values.
Системное свойство
java.lang.ScopedValue.cacheSize
управляет размером (для каждого потока) кэша scoped values. Этот кэш имеет решающее значение для производительности scoped values. Если он слишком мал, то библиотеке времени выполнения придется многократно сканировать его для каждогоget()
. Если он слишком велик, то память будет расходоваться без надобности. По умолчанию размер кэша scoped values составляет 16 записей. Его размер можно варьировать от 2 до 16 записей.ScopedValue.cacheSize
должен быть целым числом, равным 2.Например, вы можете использовать
-Djava.lang.ScopedValue.cacheSize=8
.Еще одно системное свойство —
jdk.preserveScopedValueCache
. Это свойство определяет, сохраняется ли кэш scoped values для каждого потока, когда виртуальный поток блокируется. По умолчанию это свойство установлено в true, что означает, что каждый виртуальный поток сохраняет свой кэш scoped values при блокировке. Как иScopedValue.cacheSize
, это компромисс между пространством и скоростью: в ситуациях, когда многие виртуальные потоки блокируются большую часть времени, установка этого свойства в false может привести к полезной экономии памяти, но после операции блокировки придется восстанавливать кэш scoped values каждого виртуального потока.
Заключение
Scoped values — это долгожданное дополнение к Java, которое обеспечивает высокоэффективную альтернативу thread-local значениям для будущего, где будет больше виртуальных потоков, но при этом имеет гораздо более ограниченный иммутабельный API, который устраняет целый ряд возможных ошибок, которые могли бы совершить разработчики.
Приглашаем всех желающих на открытый урок, посвященный работе с Websocket в Spring. На встрече поговорим о том, как в Spring организовать взаимодействие с клиентским веб-приложением по протоколу Websocket, зачем это может понадобится и какие проблемы может решить. Записывайтесь по ссылке.