Scoped Values wurden – zusammen mit virtuellen Threads und Structured Concurrency – in Project Loom entwickelt. Sie sind seit Java 20 als Incubator-Feature (JEP 429) und seit Java 21 als Preview Feature (JEP 446) 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 sind eine Form von impliziten Methodenparametern, die es ermöglichen, einen oder mehrere Werte (d. h. beliebige Objekte) an eine oder mehrere weit entfernte Methoden zu übergeben, ohne sie als explizite Parameter zu jeder Methode in der Aufrufkette hinzufügen zu müssen.
Scoped Values werden in der Regel als öffentliche statische Felder angelegt, so dass sie von beliebigen Methoden aus abrufbar sind.
Verwenden mehrere Threads dasselbe statische ScopedValue
-Feld, dann 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
Ein klassisches 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. Die meisten der Methoden benötigen den User gar nicht – und es könnte sogar Methoden geben, denen wir aus Sicherheitsgründen den Zugriff auf den User gar nicht erst erlauben wollen.
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 wir ein weiteres Argument durch zahllose Methoden durchschleifen.
Die Alternative ist es, den User in einem Scoped Value zu speichern.
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 ScopedValue.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)
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.
Aufruf einer Methode mit Rückgabewert
Wenn der aufgerufene Code einen Rückgabewert hat, kannst du nach ScopedValue.where()
anstelle von run(Runnable op)
die Methode call(CallableOp op)
aufrufen.
CallableOp
ist ein funktionelles, generisches Interface, das wie folgt definiert ist:
@FunctionalInterface
public static interface CallableOp<T, X extends Throwable> {
T call() throws X
}
Code-Sprache: Java (java)
Das Interface enthält neben dem Rückgabewert auch eine möglicherweise geworfene Exception als Typ-Parameter. Somit kann der Compiler erkennen, welche Art von Exception der Aufruf von call(...)
werfen kann.
Wenn wir also z. B. folgende Methode im Kontext eines Scoped Values aufrufen wollen:
Result doSomethingSmart() throws SpecificException {
. . .
}
Code-Sprache: Java (java)
Dann erkennt der Compiler, dass auch call()
nur eine SpecificException
werfen kann, und wir können sie wie folgt abfangen:
try {
Result result = ScopedValue.where(USER, loggedInUser).call(() -> doSomethingSmart());
} catch (SpecificException e) { // ⟵ Catching SpecificException
. . .
}
Code-Sprache: Java (java)
Und wenn die aufgerufene keine Exception wirft, brauchen wir auch keine abzufangen.
Preview-Features aktivieren
Falls du selbst mit Scoped Values experimentieren möchtest: Preview-Features müssen explizit freigeschaltet werden. Dazu musst du die java
- und javac
-Kommandos mit folgenden VM-Optionen aufrufen:
$ javac --enable-preview --source <Java version> <java file to compile>
$ java --enable-preview <java file or compiled 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 Runnable
s ü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
run(...)
-Methode übergebenenRunnable
s 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 mitThreadLocal.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 hingegenInheritableThreadLocal
verwenden, wird dessen Wert in jeden Kind-Thread kopiert, damit ein Kind-Thread nicht den Thread-Local-Wert des Eltern-Threads verändern kann. Dies kann den Speicherbedarf erheblich erhöhen.
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 bis Java 24 noch im Preview-Stadium befinden und somit noch kleinen Ä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.