scoped values javascoped values java
HappyCoders Glasses

Scoped Values in Java erklärt mit ThreadLocal-Vergleich

Sven Woltmann
Sven Woltmann
Aktualisiert: 9. Juni 2026

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 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 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;
    } 
}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 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 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:

public class RequestContext {
    public final static 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));
        // . . .
    }
}Code-Sprache: Java (java)

Bis einschließlich Java 23 konnten wir alternativ die Convenience-Methode runWhere() verwenden und das Runnable dieser Methode als dritten Parameter übergeben:

ScopedValue.runWhere(
    RequestContext.LOGGED_IN_USER,
    loggedInUser,
    () -> restAdapter.processRequest(request) // ⟵ 3. Parameter
);


Diese Variante wurde in Java 24 entfernt, um die ScopedValue-API komplett „fluent“ zu gestalten.

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 = RequestContext.LOGGED_IN_USER.get();
        if (loggedInUser.isAdmin()) {
            enrichDataWithAdminInfos(data);
        }
        return 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.

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"));Code-Sprache: Java (java)

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 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(RequestContext.LOGGED_IN_USER, loggedInUser)
                               .call(() -> doSomethingSmart());
} catch (SpecificException e) {  // ⟵ SpecificException abfangen
    . . .
}Code-Sprache: Java (java)

Und wenn die aufgerufene Methode keine Exception wirft, brauchen wir auch keine abzufangen.

In Java 21 und 22 existierte das Interface CallableOp noch nicht. Dort wurde stattdessen ein Callable verwendet. Die call()-Methode des Callable-Interfaces wirft allerdings eine generische Exception:

@FunctionalInterface
public interface Callable<V> {
  V call() throws Exception;
}


Und somit musste in Java 21 und 22 beim Aufruf von ScopedValue.call() auch immer eine Exception abgefangen werden – selbst wenn die aufgerufene Methode nur eine spezifische oder gar keine Exception werfen konnte.

Bis Java 23 konnten wir alternativ die Convenience-Methode callWhere() verwenden. Diese wurde in Java 24 gemeinsam mit runWhere() (s. o.) entfernt.

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);
        // . . .
    }
}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) throws InterruptedException {
    // . . .
    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();
      // . . .
    }
  }
}Code-Sprache: Java (java)

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.

Structured Concurrency ist – anders als Scoped Values – im aktuellen Java 26 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 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 ü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:innen 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. Der Datenfluss läuft somit immer in Richtung der Aufrufkette. Das verbessert die Verständlichkeit und Wartbarkeit des Codes erheblich gegenüber Thread Locals, die 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, 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.

Historie

Scoped Values wurden in folgenden JDK Enhancement Proposals definiert:

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>Code-Sprache: Klartext (plaintext)

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 im Zusammenspiel 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.

Java-Schulungen
(online oder vor Ort)
»