scoped values javascoped values java
HappyCoders Glasses

Scoped Values in Java

Sven Woltmann
Sven Woltmann
11. Dezember 2022

Scoped Values wurden – zusammen mit virtuellen Threads und Structured Concurrency – in Project Loom entwickelt. Sie sind seit Java 20 als Preview-Feature (JEP 429) im JDK enthalten.

In diesem Artikel erfährst du:

  • Was ist ein Scoped Value?
  • Wie setzt man ScopedValue ein?
  • Wie werden ScopedValues vererbt?
  • Was ist der Unterschied zwischen ScopedValue und ThreadLocal?

Was ist ein Scoped Value?

Scoped Values ermöglichen es, einen Wert (d. h. ein beliebig Objekt) für einen begrenzten Zeitraum so zu speichern, dass nur derjenige Thread, der ihn geschrieben hat, den Wert auch lesen kann.

Scoped Values werden in der Regel als öffentliche statische Felder angelegt, so dass sie von beliebigen Methoden aus abrufbar sind, ohne dass wir sie als Parameter an diese Methoden übergeben müssen.

Verwenden mehrere Threads dasselbe ScopedValue-Feld, kann dieses aus Sicht eines jeden Threads einen anderen Wert enthalten.

Falls du mit ThreadLocal-Variablen vertraut bist, kommt dir das sicher bekannt vor. Tatsächlich stellen Scoped Values eine moderne Alternative für Thread Locals dar.

Am besten lassen sich Scoped Values an einem Beispiel erklären.

ScopedValue Beispiel

Das klassische Einsatzszenario ist ein Web-Framework, das bei einem eingehenden Request den User authentifiziert und die Daten des eingeloggten Users demjenigen Code, der den Request abarbeitet, zur Verfügung stellt.

Das kann z. B. ganz klassisch über ein Methodenargument funktionieren.

Nun kann sich in komplexen Anwendungen die Abarbeitung eines Requests über Hunderte von Methoden erstrecken – doch die Information über den eingeloggten User wird evtl. nur in wenigen Methoden benötigt. Trotzdem müssten wir den User durch alle Methoden durchschleifen, die irgendwann zum Aufruf einer Methode führen, für die der eingeloggte User relevant ist.

Im folgenden Beispiel wird der eingeloggte User vom Server über den RestAdapter und den UseCase bis hin zum Repository durchgereicht, wo er schließlich ausgewertet wird:

class Server { private void serve(Request request) { // ... User user = authenticateUser(request); restAdapter.processRequest(request, user); // ... } } class RestAdapter { public void processRequest(Request request, User loggedInUser) { // ... UUID id = extractId(request); useCase.invoke(id, loggedInUser); // ... } } class UseCase { public void invoke(UUID id, User loggedInUser) { // ... Data data = repository.getData(id, loggedInUser); // ... } } class Repository { public Data getData(UUID id, User loggedInUser) { Data data = findById(id); if (loggedInUser.isAdmin()) { enrichDataWithAdminInfos(data); } } }
Code-Sprache: Java (java)

Der zusätzliche Parameter loggedInUser macht unseren Code sehr schnell unübersichtlich.

Und was wäre, wenn wir an einer Stelle tief im Aufruf-Stack auch noch die IP-Adresse des Users benötigten? Dann müssten ein weiteres Argument durch zahllose Methoden durchschleifen.

Die Alternative ist es, den User in einem Scoped Value zu speichern, auf den von überall aus zugegriffen werden kann.

Das funktioniert wie folgt: Wir legen an öffentlich zugänglicher Stelle ein statisches Feld vom Typ ScopedValue an. Mit ScopedValue.where(…) binden wir den Scoped Value an den konkreten User, und der run-Methode übergeben wir – in Form eines Runnable – den Code, für dessen Aufrufdauer der Scoped Value gültig sein soll:

class Server { public final static ScopedValue<User> LOGGED_IN_USER = ScopedValue.newInstance(); private void serve(Request request) { // ... User loggedInUser = authenticateUser(request); ScopedValue.where(LOGGED_IN_USER, loggedInUser) .run(() -> restAdapter.processRequest(request)); // ... } }
Code-Sprache: Java (java)

Alternativ kann das Runnable auch als dritter Parameter direkt an die where-Methode übergeben werden:

ScopedValue.where(LOGGED_IN_USER, loggedInUser, () -> restAdapter.processRequest(request));
Code-Sprache: Java (java)

Statt eines Runnables kannst du auch ein Callable (also eine Methode mit Rückgabewert) übergeben – dafür rufst du dann call(…) statt run(…) auf oder übergibst das Callable als dritten Parameter an die where-Methode. Ein Beispiel dazu findest du weiter unten.

Den loggedInUser-Parameter können wir dann aus allen Methodensignaturen entfernen:

class RestAdapter { public void processRequest(Request request) { // ... UUID id = extractId(request); useCase.invoke(id); // ... } } class UseCase { public void invoke(UUID id) { // ... Data data = repository.getData(id); // ... } }
Code-Sprache: Java (java)

Und dort, wo wir den eingeloggten User benötigen, können wir ihn mit ScopedValue.get() auslesen:

class Repository { public Data getData(UUID id) { Data data = findById(id); User loggedInUser = Server.LOGGED_IN_USER.get(); if (loggedInUser.isAdmin()) { enrichDataWithAdminInfos(data); } } }
Code-Sprache: Java (java)

Das macht den Code deutlich les- und wartbarer, da wir den eingeloggten User nicht mehr von einer Methode zur nächsten durchreichen müssen, sondern genau dort auf ihn zugreifen können, wo wir ihn brauchen.

Falls du selbst mit Scoped Values experimentieren möchtest: Preview-Features müssen explizit freigeschaltet und Incubator-Module müssen explizit dem Modulpfad hinzugefügt werden. Dazu musst du das Java 20 Early-Access-Release runterladen und die java- und javac-Kommandos mit folgenden Parametern aufrufen:

$ javac --enable-preview -source 20 --add-modules jdk.incubator.concurrent *.java $ java --enable-preview --add-modules jdk.incubator.concurrent <class to execute>
Code-Sprache: Klartext (plaintext)

Rebinding von Scoped Values

ScopedValue hat keine set-Methode, um den gespeicherten Wert zu ändern. Dies ist beabsichtigt, da die Unveränderlichkeit eines Wertes komplexen Code deutlich les- und wartbarer macht.

Stattdessen kannst du den Wert für den Aufruf eines begrenzten Code-Abschnitts (z. B. für den Aufruf einer Untermethode) neu binden ("Rebinding" auf englisch). D. h. dass für diesen begrenzten Code-Abschnitt ein anderer Wert sichtbar ist … und sobald dieser Abschnitt beendet wird, wieder der ursprüngliche.

So könnte unsere RestAdapter-Methode z. B. die Informationen über den eingeloggten User vor der extractId()-Methode verbergen wollen. Dazu können wir erneut ScopedValue.where(…) aufrufen und während des Aufrufs der Untermethode den eingeloggten User auf null setzen:

class RestAdapter { public void processRequest(Request request) { // ... UUID id = ScopedValue.where(LOGGED_IN_USER, null) .call(() -> extractId(request)); useCase.invoke(id); // ... } }
Code-Sprache: Java (java)

Hier siehst du auch, wie wir anstelle von run(…) die call-Methode verwenden und ein Callable (also eine Methode mit Rückgabewert) anstelle eines Runnables übergeben.

Vererbung von Scoped Values

Scoped Values werden automatisch an alle Kind-Threads vererbt, die über einen Structured Task Scope erzeugt werden.

Mittels StructuredTaskScope könnte unser Use Case z. B. parallel zur Repository-Methode noch einen externen Service aufrufen:

class UseCase { public void invoke(UUID id) { // ... try (var scope = new StructuredTaskScope.ShutdownOnFailure()) { Future<Data>. dataFuture = scope.fork(() -> repository.getData(id)); Future<ExtData> extDataFuture = scope.fork(() -> remoteService.getExtData(id)); scope.join(); scope.throwIfFailed(); Data data = dataFuture.resultNow(); ExtData extData = extDataFuture.resultNow(); // ... } } }
Code-Sprache: Java (java)

So kann aus den Kind-Threads, die per fork(…) erstellt werden, ebenfalls per LOGGED_IN_USER.get() auf den eingeloggten User zugegriffen werden.

Da der StructuredTaskScope erst abgeschlossen ist, wenn alle Kind-Threads beendet sind, passt dieser sehr gut in das Konzept der Scoped Values.

Was ist der Unterschied zwischen ScopedValue und ThreadLocal?

Wer die Anforderungen dieser Beispiele bisher mit Thread-Local-Variablen gelöst hat, wird sich nun vielleicht fragen: Warum brauchen wir Scoped Values? Was können sie, was Thread Locals nicht können?

Scoped Values haben folgende Vorteile:

  • Sie sind nur während der Laufzeit des an die where-Methode übergebenen Runnables gültig und werden danach (sofern keine weiteren Referenzen auf sie existieren) zur Garbage Collection freigegeben. Ein Thread-Local-Wert hingegen bleibt solange im Speicher, bis entweder der Thread beendet wird (was bei der Verwendung eines Thread-Pools u. U. niemals der Fall ist) oder er explizit mit ThreadLocal.remove() gelöscht wird. Da viele Entwickler vergessen, das zu tun (oder es nicht tun, weil das Programm so komplex ist, dass nicht erkennbar ist, wann ein Thread-Local-Wert nicht mehr benötigt wird), sind Memory Leaks oft die Folge.
  • Ein Scoped Value ist unveränderlich – er kann nur durch das oben erwähnte Rebinding für einen neuen Scope neu gesetzt werden. Das verbessert die Verständlichkeit und Wartbarkeit des Codes erheblich gegenüber Thread Locals, dir jederzeit durch set() verändert werden können.
  • Die durch StructuredTaskScope erzeugten Kind-Threads haben Zugriff auf den Scoped Value des Eltern-Threads. Wenn wir hingegen InheritableThreadLocal verwenden, wird dessen Wert in jeden Kind-Thread kopiert, was den Speicherbedarf erheblich erhöhen kann.

Genau wie Thread Locals stehen auch Scoped Values sowohl für Plattform-Threads als auch für virtuelle Threads zur Verfügung. Insbesondere bei Tausenden bis Millionen von virtuellen Kind-Threads kann die Speicherersparnis durch den Zugriff auf den Scoped Value des Eltern-Threads (anstelle der Erstellung einer Kopie) erheblich sein.

Fazit

Mit "Scoped Values" erhalten wir ein sehr nützliches Konstrukt, um einem Thread und ggf. einer Gruppe von Kind-Threads für deren Lebensdauer einen nur lesbaren, Thread-spezifischen Wert zur Verfügung zu stellen.

Bitte beachte, dass sich Scoped Values zum Stand von Java 20 noch im Incubator-Stadium befinden und somit noch grundlegenden Änderungen unterliegen können.

Wenn du noch Fragen hast, stelle sie gerne über die Kommentar-Funktion. Möchtest du über neue Tutorials und Artikel informiert werden? Dann klicke hier, um dich für den HappyCoders.eu-Newsletter anzumelden.