
Scoped Values wurden – zusammen mit virtuellen Threads und Structured Concurrency – in Project Loom entwickelt. Sie sind seit Java 20 als Incubator-Feature und seit Java 21 als Preview-Feature im JDK enthalten. In Java 25 wurden sie finalisiert.
In diesem Artikel erfährst du:
- Was ist ein Scoped Value?
- Wie verwendet man die
ScopedValue-Klasse? - Wie werden Scoped Values vererbt?
- Was ist der Unterschied zwischen
ScopedValueundThreadLocal?
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 statische, finale Felder angelegt. Die Sichtbarkeit des Felds ist zugleich die Zugriffskontrolle: Nur Code, der das Feld sehen kann, kann den Wert lesen. Deshalb empfehle ich, das Feld so eng wie möglich zu deklarieren – private oder package-private, wenn nur eine Klasse oder ein Package den Wert braucht. Im Beispiel unten ist es public, weil der Wert in einer anderen Klasse gelesen wird als der, die ihn bindet.
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 zu 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);
}
return data;
}
}
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 und eine Trace-ID benötigten? Dann müssten wir zwei weitere Argumente durch zahllose Methoden durchschleifen.
Die Alternative ist es, den User in einem Scoped Value zu speichern.
Das funktioniert wie folgt:
Wir legen ein statisches Feld vom Typ ScopedValue an – hier in einer eigenen Klasse RequestContext, damit Server und Repository darauf zugreifen können. 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:
public class RequestContext {
public static final ScopedValue<User> LOGGED_IN_USER = ScopedValue.newInstance();
}
class Server {
private void serve(Request request) {
// . . .
User loggedInUser = authenticateUser(request);
ScopedValue.where(RequestContext.LOGGED_IN_USER, loggedInUser)
.run(() -> restAdapter.processRequest(request));
// . . .
}
}
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);
// . . .
}
}
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 = RequestContext.LOGGED_IN_USER.get();
if (loggedInUser.isAdmin()) {
enrichDataWithAdminInfos(data);
}
return data;
}
}
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.
Mehrere Scoped Values binden
Brauchst du neben dem User auch die IP-Adresse und eine Trace-ID, bindest du mehrere Scoped Values in einem Aufruf – jedes where(…) liefert einen neuen, unveränderlichen Carrier, dem du weitere Bindungen hinzufügen kannst (ein zweites Feld TRACE_ID vom Typ ScopedValue<String> in RequestContext vorausgesetzt):
ScopedValue.where(RequestContext.LOGGED_IN_USER, loggedInUser)
.where(RequestContext.TRACE_ID, traceId)
.run(() -> restAdapter.processRequest(request));
Übertreiben solltest du es damit nicht: Scoped Values sind für kleine Stückzahlen ausgelegt. Jeder Thread hält einen Cache mit 16 Einträgen für gebundene Werte; erst ein Treffer in diesem Cache macht get() so schnell wie den Zugriff auf eine lokale Variable. Brauchst du mehrere Werte, bündelst du sie besser in einem Record und bindest einen einzigen Scoped Value daran – das empfiehlt auch das Javadoc.
Was passiert, wenn kein Wert gebunden ist?
Ein Hinweis zu get(): Ist der Scoped Value im aktuellen Thread nicht gebunden, wirft get() eine NoSuchElementException. Wenn nicht sichergestellt ist, dass ein Wert gebunden ist, hast du drei Möglichkeiten:
// Vorab prüfen, ob ein Wert gebunden ist:
if (RequestContext.LOGGED_IN_USER.isBound()) {
User user = RequestContext.LOGGED_IN_USER.get();
}
// Einen Ersatzwert verwenden, falls kein Wert gebunden ist
// (seit Java 25 darf dieser nicht mehr null sein):
User user = RequestContext.LOGGED_IN_USER.orElse(User.ANONYMOUS);
// Oder stattdessen eine eigene Exception werfen:
User user = RequestContext.LOGGED_IN_USER
.orElseThrow(() -> new IllegalStateException("Kein eingeloggter User"));
Dass orElse(…) seit Java 25 kein null mehr akzeptiert, war übrigens die einzige Änderung bei der Finalisierung – ansonsten wurde die API aus Java 24 unverändert übernommen.
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 funktionales, generisches Interface, das wie folgt definiert ist:
@FunctionalInterface
public interface CallableOp<T, X extends Throwable> {
T call() throws X;
}
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 {
// . . .
}
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(RequestContext.LOGGED_IN_USER, loggedInUser)
.call(() -> doSomethingSmart());
} catch (SpecificException e) { // ⟵ SpecificException abfangen
// . . .
}
Und wenn die aufgerufene Methode keine Exception wirft, brauchen wir auch keine abzufangen.
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(RequestContext.LOGGED_IN_USER, null)
.call(() -> extractId(request));
useCase.invoke(id);
// . . .
}
}
Weil extractId(…) einen Rückgabewert hat, verwenden wir hier call(…) statt run(…).
Das folgende Diagramm zeigt die verschachtelten Scopes unseres Beispiels: Jeder Rahmen ist der Scope, den ein where(…).run(…) bzw. where(…).call(…) öffnet, darin die Methoden, die in diesem Scope laufen. extractId(…) liegt im Scope des call(…)-Aufrufs und sieht null; useCase.invoke(…) läuft danach wieder im äußeren Scope und sieht den User. In serve() selbst, außerhalb von run(…), ist der Wert nicht gebunden.
Vererbung von Scoped Values
Scoped Values werden automatisch an alle Kind-Threads vererbt, die über einen Structured Task Scope erzeugt werden. An Threads, die du selbst startest – per Thread.ofVirtual().start(…) oder über einen ExecutorService –, werden Scoped Values nicht vererbt; dort ist der Wert nicht gebunden.
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)
throws InterruptedException, ExecutionException {
// . . .
try (var scope = StructuredTaskScope.open()) {
Subtask<Data> dataSubtask = scope.fork(() -> repository.getData(id));
Subtask<ExtData> extDataSubtask =
scope.fork(() -> remoteService.getExtData(id));
scope.join();
Data data = dataSubtask.get();
ExtData extData = extDataSubtask.get();
// . . .
}
}
}
So kann aus den Kind-Threads, die per fork(…) erstellt werden, ebenfalls per RequestContext.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.
Seit Java 27 wirft scope.join() eine ExecutionException, wenn ein Subtask fehlschlägt – bis Java 26 war es die unchecked FailedException. Deshalb deklariert invoke(…) beide Exceptions.
Structured Concurrency ist – anders als Scoped Values – im aktuellen Java 27 noch nicht finalisiert und muss per --enable-preview aktiviert werden.
Was ist der Unterschied zwischen ScopedValue und ThreadLocal?
Wer die Anforderungen dieser Beispiele bisher mit ThreadLocal-Variablen gelöst hat, wird sich nun vielleicht fragen: Warum brauchen wir Scoped Values? Was können sie, was Thread Locals nicht können?
Die Unterschiede im Überblick:
ScopedValue | ThreadLocal | |
|---|---|---|
| Lebensdauer des Werts | bis run() bzw. call() endet | bis remove() oder Thread-Ende |
| Wert ändern | nur Rebinding für einen inneren Scope | set() jederzeit, von überall |
| Vererbung an Kind-Threads | im StructuredTaskScope, per Referenz | InheritableThreadLocal, Map wird kopiert |
| Lesen ohne Bindung | NoSuchElementException, orElse(), orElseThrow() | null bzw. initialValue() |
Im Einzelnen:
- Der gebundene Wert ist nur während der Laufzeit des an
run(…)odercall(…)übergebenen Codes erreichbar und wird danach (sofern keine weiteren Referenzen auf ihn existieren) zur Garbage Collection freigegeben. Ein Thread-Local-Wert bleibt dagegen im Speicher, bis der Thread endet – was bei einem Thread-Pool u. U. nie der Fall ist – oder bisThreadLocal.remove()aufgerufen wird; vergesseneremove()-Aufrufe sind eine klassische Ursache für Memory Leaks. - Ein Scoped Value ist unveränderlich – er kann nur durch das oben beschriebene Rebinding für einen inneren Scope neu gebunden werden. Der Datenfluss läuft damit immer in Richtung der Aufrufkette, während ein Thread-Local-Wert jederzeit und von überall per
set()geändert werden kann. - Kind-Threads eines
StructuredTaskScopelesen den Scoped Value des Eltern-Threads per Referenz. BeiInheritableThreadLocalwird beim Start jedes Kind-Threads dagegen die komplette Map seiner Thread-Local-Einträge kopiert, damit einset()im Kind-Thread die Bindung des Eltern-Threads nicht verändert – bei vielen Kind-Threads summiert sich das.
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.
Historie
Scoped Values wurden in folgenden JDK Enhancement Proposals definiert:
- Java 20: JEP 429: Scoped Values (Incubator)
- Java 21: JEP 446: Scoped Values (Preview)
- Java 22: JEP 464: Scoped Values (Second Preview)
- Java 23: JEP 481: Scoped Values (Third Preview)
- Java 24: JEP 487: Scoped Values (Fourth Preview)
- Java 25: JEP 506: Scoped Values
Scoped Values vor Java 25 verwenden
Seit Java 25 sind Scoped Values final – du musst nichts weiter tun, um sie zu verwenden.
Falls du sie in Java 21 bis 24 als Preview-Feature einsetzen möchtest, musst du Preview-Features explizit freischalten. Dazu rufst du die javac- und java-Kommandos mit folgenden Optionen auf:
$ javac --enable-preview --source <Version> <zu kompilierende .java-Datei>
$ java --enable-preview <auszuführende .java-Datei oder Klasse>
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.
Der größte Gewinn gegenüber ThreadLocal liegt dabei nicht in der Performance, sondern in der Klarheit: Ein Scoped Value ist unveränderlich, sein Gültigkeitsbereich ist direkt am Code ablesbar, und er wird automatisch wieder freigegeben, sobald der run()- bzw. call()-Block endet. Damit fallen gleich zwei klassische Fehlerquellen von Thread Locals weg – vergessene remove()-Aufrufe und schwer nachvollziehbare Wertänderungen quer durch den Aufruf-Stack.
Ihre Stärke spielen Scoped Values besonders zusammen mit virtuellen Threads und Structured Concurrency aus: Tausende Kind-Threads können lesend auf denselben Wert des Eltern-Threads zugreifen, ohne dass dieser kopiert werden muss.
Hat dir dieser Artikel Zeit gespart? Dann freue ich mich, wenn du eine Minute davon in eine Bewertung auf meinem ProvenExpert-Profil investierst. Dein Feedback zeigt mir, dass sich die Arbeit an diesen Artikeln lohnt.




