Значения области действия Java 21: глубокое погружение с примерами

В программировании на Java обмен данными между различными частями приложения, работающими в одном потоке, часто является важнейшей задачей. Раньше разработчики полагались на переменные « ThreadLocal », чтобы добиться этого. Однако с появлением VirtualThread и необходимостью масштабируемости обмен данными через переменные ThreadLocal не всегда эффективен. Вот где в игру вступают Scoped Values.

Значения Scoped были предложены как функция предварительного просмотра инкубатора в Java 20( JEP-429 ) в дополнение к структурированному параллелизму. В JDK 21( JEP-446 ) эта функция больше не инкубаторная; вместо этого она является API предварительного просмотра.

См. также: Новые возможности Java 21

1. Понимание проблем с ThreadLocal

Начиная с Java 1.2, переменные ThreadLocal были рекомендуемым способом совместного использования данных в границах потока, без их явной передачи в качестве аргументов метода. Мы можем установить их в одной части приложения(например, контроллере) и позже получить доступ к их значению в другой части приложения(например, dao).

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

1.1. Понимание ThreadLocal с помощью простой программы

Синтаксически переменные ThreadLocal были общедоступными статическими полями, доступными в любом месте приложения. Но значение, которое они содержали, было разным для каждого потока, независимо от того, что они устанавливали. Мы обращались к этим локальным переменным потока по их имени и использовали их методы set() и get() для установки и получения значений.

Давайте разберемся с локальными переменными потока на примере кода. В следующей программе мы запускаем поток и устанавливаем значение ThreadLocal для переменной CONTEXT. Когда мы получаем доступ к ее значению в методе insideParentThread(), мы можем получить ожидаемое значение.

public static ThreadLocal<String> CONTEXT = ThreadLocal.withInitial(() -> null);Thread parentThread = new Thread(() -> {CONTEXT.set("TestValue");insideParentThread();});parentThread.start();}void insideParentThread() {System.out.println("ThreadLocal Value in insideParentThread(): " + CONTEXT.get());}// Prints 'ThreadLocal Value in insideParentThread(): TestValue'

1.2. Дочерние потоки не имеют доступа к ThreadLocal родительского потока.

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

Давайте разберемся на примере.

public static ThreadLocal<String> CONTEXT = ThreadLocal.withInitial(() -> null);//public static InheritableThreadLocal<String> CONTEXT = new InheritableThreadLocal();public static void main(String[] args) throws InterruptedException {Thread parentThread = new Thread(() -> {CONTEXT.set("TestValue");insideParentThread_1();Thread childThread = new Thread(() -> {insideChildThread();});childThread.start();insideParentThread_2();});parentThread.start();}static void insideParentThread_1() {System.out.println("ThreadLocal Value in insideParentThread_1(): " + CONTEXT.get());}static void insideParentThread_2() {System.out.println("ThreadLocal Value in insideParentThread_2(): " + CONTEXT.get());}static void insideChildThread() {System.out.println("ThreadLocal Value in insideChildThread(): " + CONTEXT.get());}

Результатом работы программы будет:

ThreadLocal Value in insideParentThread_1(): TestValueThreadLocal Value in insideParentThread_2(): TestValueThreadLocal Value in insideChildThread(): null

Мы видим, что значение CONTEXT недоступно в дочернем потоке. Чтобы получить доступ к этому значению, мы должны либо передать его потоку как параметр, а затем поток должен создать свои собственные переменные ThreadLocal. В качестве облегчения мы можем использовать InheritableThreadLocal, который был разработан именно для этой цели.

1.3. InheritableThreadLocal — «лучшая альтернатива», но она также неэффективна

Класс InheritableThreadLocal делает то же самое, что и ThreadLocal, но он также позволяет дочернему потоку получать доступ к локальным значениям потока из родительского потока.

Если в предыдущей программе мы заменим ThtreadLocal на InheritableThreadLocal, то увидим ожидаемые изменения выходных данных.

//public static ThreadLocal<String> CONTEXT = ThreadLocal.withInitial(() -> null);public static InheritableThreadLocal<String> CONTEXT = new InheritableThreadLocal();......

Вывод программы:

ThreadLocal Value in insideParentThread_1(): TestValueThreadLocal Value in insideParentThread_2(): TestValueThreadLocal Value in insideChildThread(): TestValue // Thread local value is accessible now

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

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

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

Поэтому если мы создадим миллион потоков, мы должны также создать миллион экземпляров ThreadLocal. Такое большое количество экземпляров может привести к снижению производительности в ресурсоемких приложениях. Значения Scoped призваны решить эту проблему.

2. Значения области действия помогают обмениваться данными с миллионом виртуальных потоков

По определению, Scoped Value — это значение, которое устанавливается один раз и затем доступно для чтения в течение ограниченного периода выполнения потоком. В течение ограниченного периода поток может разветвлять дочерние потоки, и эти дочерние потоки также будут получать доступ к той же копии scoped значения.

2.1. Как работают значения области действия?

Обратите внимание, что значения scoped работают иначе, чем переменные ThreadLocal, и они не являются похожими конструкциями. Значение scoped по сути является параметром метода, который нам не нужно объявлять, и Java автоматически привязывает его ко всем вызовам методов в потоке. Мы можем напрямую получить к нему доступ в его ограниченном контексте.

По сути, ScopedValue — это неявный параметр метода.

Типичный синтаксис для создания значения с областью действия и привязки к потоку выглядит следующим образом. В следующем примере мы можем получить доступ к значению CONTEXT в методе doSomething(). Когда поток завершается, значение считается несвязанным.

private static final ScopedValue<String> CONTEXT = ScopedValue.newInstance();ScopedValue.runWhere(CONTEXT, "TestValue",() -> doSomething());

Аналогично, когда мы создаем новые потоки из родительского потока, все дочерние виртуальные потоки автоматически получают доступ к ограниченному значению.

В следующем примере мы можем получить доступ к значению CONTEXT внутри и всех трех методов, то есть insideParentThread, insideChildThread1 и insideChildThread2.

private static final ScopedValue<String> CONTEXT = ScopedValue.newInstance();ScopedValue.runWhere(CONTEXT, "TestValue",() -> {insideParentThread();try(var scope = new StructuredTaskScope<String>()) {scope.fork(() -> insideChildThread1());scope.fork(() -> insideChildThread2());scope.join();}});

Значения области действия привязаны только к виртуальным потокам, которые разветвляются с помощью метода StructuredTaskScope.fork(). Если мы создаем новые потоки с помощью других средств(например, расширяя класс Thread), то мы не сможем получить доступ к значению области действия в них.

2.2. Значения области действия решают проблему объема памяти

Теперь давайте сосредоточимся на интересующей нас области, а именно на объеме памяти, который был главной проблемой в локальных переменных потока.

Как упоминалось ранее, Scoped значения являются невидимыми параметрами метода. Мы знаем, что Java — это 'передача по значению', и когда мы передаем объект как параметр метода, копируется только ссылка на переменную, и это не создает новый экземпляр в памяти.

Поэтому, когда мы привязываем значение scoped к потоку, и он разветвляется на миллион других потоков, даже тогда количество объектов в памяти остается только одним. Вот где значения scoped решают проблему с объемом памяти.

Еще одна важная вещь, которую следует помнить, заключается в том, что значения Scoped являются неизменяемыми. Поэтому мы можем передавать их на любой уровень глубины или любое количество ответвленных потоков, не беспокоясь о том, что кто-то может их повредить.

2.3 Что происходит с ограниченными значениями за пределами ограниченного контекста?

Как только ограниченный контекст заканчивается, т.е. поток, имеющий доступ к ограниченному значению, завершается, он становится неограниченным. Это означает, что если мы попытаемся получить значение ограниченного значения вне ограниченного контекста, мы получим NoSuchElementException.

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

ScopedValueTest instance = new ScopedValueTest();ScopedValue.runWhere(CONTEXT, "Test Value",() -> {//...});System.out.println("Outside bounded scope isBound() is: " + CONTEXT.isBound());System.out.println("Outside bounded scope the scoped value is: " + CONTEXT.orElse(null));

Вывод программы:

Outside bounded scope isBound() is: falseOutside bounded scope the scoped value is: null

3. Разветвленные виртуальные потоки могут иметь восстановленные значения

Значения области действия привязаны к контексту потока, для которого они созданы. Чтобы сделать все еще лучше, мы можем повторно привязать значение к переменной области действия, и это будет доступно только в контексте дочернего потока.

Давайте разберемся на примере. В следующем примере изначально значение, привязанное к родительскому потоку, — «Test Value». Но в ответвленном дочернем потоке мы изменили значение на «Changed Value». В области действия дочернего потока значение CONTEXT будет «Changed Value», а за пределами дочернего потока, т. е. в родительском потоке, значение будет «Test Value».

ScopedValue.runWhere(CONTEXT, "Test Value",() -> {System.out.println("In parent thread start the scoped value is: " + CONTEXT.get());doSomething();System.out.println("In parent thread end the scoped value is: " + CONTEXT.get());});public void doSomething() {System.out.println("In doSomething() and parent scope: " + CONTEXT.get());ScopedValue.runWhere(CONTEXT, "Changed Value",() -> {System.out.println("In doSomething() and child scope: " + CONTEXT.get());doSomethingAgain();});}public void doSomethingAgain() {System.out.println("In doSomethingAgain() and child scope: " + CONTEXT.get());}

Посмотрите на вывод программы:

In start the scoped value is: Test ValueIn doSomething() and parent scope: Test ValueIn doSomething() and child scope: Changed ValueIn doSomethingAgain() and child scope: Changed ValueIn end the scoped value is: Test Value

4. Передача значений с несколькими областями действия

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

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

public record ApplicationContext(Principal principal, Role role, Region region) { }private final ApplicationContext CONTEXT = new ApplicationContext(...);ScopedValue.runWhere(ApplicationContext, CONTEXT,() -> {doSomething();});

5. Заключение

Значения Scoped решают очень специфическую проблему обмена данными с виртуальными потоками, которые могут создаваться миллионами. Если не принять меры, это может снизить производительность любого ресурсоемкого приложения.

В этом руководстве по параллельному исполнению в Java мы узнали о значениях области действия, ограниченных контекстах, совместном использовании данных внутри/вне ограниченного контекста и, наконец, о доступе к значениям области действия в ответвленных дочерних потоках.

Исходный код на Github

Прокрутить вверх