Jeder Java-Entwickler kennt das Problem: Ein Request trifft ein, der HTTP-Controller startet einen Service, der ruft ein Repository auf – und irgendwo in der Tiefe des Stacks soll auf die Request-ID oder den aktuellen Benutzer zugegriffen werden. Ohne sie als Parameter durch alle Methoden zu schleppen. Die klassische Lösung heißt ThreadLocal – doch die hat gravierende Nachteile. Mit Java 21 (JEP 446) wurde Scoped Value als Preview-Feature eingeführt, das ThreadLocal in modernen Codebasen ablösen soll. Hinweis: Auch in Java 24 (Stand März 2025) ist die API noch im Preview-Status (JEP 487) und sollte nicht in Production verwendet werden.

Das Problem mit ThreadLocal

ThreadLocal speichert Daten pro Thread, global erreichbar und implizit vererbt. Das klingt praktisch, führt aber zu diesen Problemen:

  • Unkontrollierte MutabilitätThreadLocal.set() kann von überall aufgerufen werden – eine Einladung für Seiteneffekte
  • Keine definierte Lebensdauer: Einmal gesetzt bleibt der Wert, bis er aktiv entfernt wird – Memory Leaks drohen
  • Probleme mit Virtual Threads: Unter Java 21 können Millionen Virtual Threads existieren. Ein ThreadLocal-wert pro Thread skaliert hier nicht
  • Kein Scope: Es gibt keine Einschränkung, wo und wie lange ein Wert gültig ist

ScopedValue – immutable und scoped

ScopedValue verfolgt einen fundamental anderen Ansatz: Der Wert ist immutable und nur innerhalb eines definierten Scopes verfügbar.

private static final ScopedValue<String> REQUEST_ID = ScopedValue.newInstance();

ScopedValue.where(REQUEST_ID, "req-abc-123")
    .run(() -> {
        <em>// In diesem Scope ist der Wert verfügbar</em>
        System.out.println(REQUEST_ID.get()); <em>// "req-abc-123"</em>
    });

<em>// Außerhalb: kein Zugriff – REQUEST_ID.get() wirft NoSuchElementException</em>
Code-Sprache: JavaScript (javascript)

Der entscheidende Unterschied zu ThreadLocal: Der Scope wird explizit geöffnet (where) und mit einer Aktion (run) verbunden. Beim Verlassen des Scopes ist der Wert automatisch ungültig. Kein remove(), keine Leaks.

Vererbung an Kind-Threads

Scoped Values vererben sich automatisch an Kind-Threads – allerdings nur bei Verwendung von StructuredTaskScope (ebenfalls Preview-Feature). Bei manuell erstellten Virtual Threads via Thread.ofVirtual() oder Executors.newVirtualThreadPerTaskExecutor() findet keine automatische Vererbung statt:

ScopedValue.where(REQUEST_ID, "req-abc-123")
    .run(() -> {
        try (var scope = new StructuredTaskScope<Void>()) {
            scope.fork(() -> {
                System.out.println(REQUEST_ID.get()); <em>// "req-abc-123" wird vererbt</em>
                return null;
            });
            scope.join();
        }
    });
Code-Sprache: JavaScript (javascript)

In Kombination mit Structured Concurrency und Virtual Threads entsteht ein nahtloses Kontext-Propagation-Modell:

try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
    scope.fork(() -> apiService.rufeMitKontext());
    scope.fork(() -> datenbankService.ladeMitKontext());
    scope.join();
}
Code-Sprache: JavaScript (javascript)

Beide Tasks sehen denselben REQUEST_ID-Wert, ohne dass er als Parameter übergeben oder manuell kopiert wurde.

Vergleich: ThreadLocal vs. ScopedValue

<em>// ThreadLocal-Ansatz (legacy)</em>
ThreadLocal<String> requestId = new ThreadLocal<>();
requestId.set("abc");
try {
    meinService.verarbeite();
} finally {
    requestId.remove(); <em>// Sonst Memory Leak!</em>
}

<em>// ScopedValue-Ansatz (modern)</em>
private static final ScopedValue<String> REQUEST_ID = ScopedValue.newInstance();
ScopedValue.where(REQUEST_ID, "abc")
    .run(() -> meinService.verarbeite());
<em>// Kein finally, kein remove – Scope ist automatisch begrenzt</em>
Code-Sprache: JavaScript (javascript)

Mehrere Werte kombinieren

Scoped Values lassen sich mit where(...).where(...) ketten:

private static final ScopedValue<String> REQUEST_ID = ScopedValue.newInstance();
private static final ScopedValue<User>   CURRENT_USER = ScopedValue.newInstance();

ScopedValue.where(REQUEST_ID, "abc")
    .where(CURRENT_USER, user)
    .run(() -> {
        log.info("User {} in Request {}", CURRENT_USER.get(), REQUEST_ID.get());
    });
Code-Sprache: PHP (php)

Rebind – Wert überschreiben innerhalb eines Scopes

Mit callWhere lässt sich ein Wert innerhalb eines bestehenden Scopes temporär überschreiben:

ScopedValue.where(REQUEST_ID, "outer")
    .run(() -> {
        System.out.println(REQUEST_ID.get()); <em>// "outer"</em>
        ScopedValue.where(REQUEST_ID, "inner")
            .run(() -> {
                System.out.println(REQUEST_ID.get()); <em>// "inner"</em>
            });
        System.out.println(REQUEST_ID.get()); <em>// "outer" wiederhergestellt</em>
    });
Code-Sprache: JavaScript (javascript)

Dieses Rebind ist ein wichtiger Unterschied zu ThreadLocal, wo set() den Wert global-mutabel überschreibt.

Virtual Threads und Speicher

Ein zentrales Problem von ThreadLocal unter Virtual Threads ist der Speicher. Platform Threads (einige Hundert) mit einem ThreadLocal<String> machen kaum etwas aus. Aber eine Million Virtual Threads, jeder mit seinem eigenen ThreadLocal-Wert, summieren sich massiv.

ScopedValue löst das über strukturelles Teilen: Da der Wert immutable ist und vom Eltern-Thread vererbt wird, teilen sich Kind-Threads dieselbe Referenz – kein Kopieren, keine Duplizierung:

ScopedValue.where(REQUEST_ID, "abc")
    .run(() -> {
        try (var scope = new StructuredTaskScope<Void>()) {
            for (int i = 0; i < 100000; i++) {
                scope.fork(() -> {
                    assert REQUEST_ID.get().equals("abc");
                    return null;
                });
            }
            scope.join();
        }
    });
Code-Sprache: JavaScript (javascript)

100.000 Virtual Threads greifen hier auf denselben ScopedValue zu – mit ThreadLocal müsste der Wert 100.000-mal kopiert werden.

Migration von ThreadLocal

Wer eine bestehende ThreadLocal-Basis migrieren will, kann schrittweise vorgehen:

<em>// Vorher: ThreadLocal</em>
public class RequestContext {
    private static final ThreadLocal<String> requestId = new ThreadLocal<>();

    public static void setRequestId(String id) {
        requestId.set(id);
    }

    public static String getRequestId() {
        return requestId.get();
    }
}

<em>// Nachher: ScopedValue</em>
public class RequestContext {
    private static final ScopedValue<String> REQUEST_ID =
        ScopedValue.newInstance();

    public static void withRequestId(String id, Runnable action) {
        ScopedValue.where(REQUEST_ID, id).run(action);
    }

    public static String getRequestId() {
        return REQUEST_ID.get();
    }
}
Code-Sprache: PHP (php)

Der Unterschied: Statt eines flachen set() wird jetzt ein Scope benötigt. Das Runnable zwingt den Aufrufer, den Gültigkeitsbereich explizit zu machen – ein struktureller Vorteil, der Debugging und Wartung vereinfacht.

Fazit

Scoped Values sind mehr als ein ThreadLocal-Ersatz – sie sind ein Paradigmenwechsel im Umgang mit Thread-lokalen Daten. Die Immutability, der definierte Lebenszyklus und die automatische Vererbung an Virtual Threads machen sie zur natürlichen Wahl für Java 21+ Projekte. Besonders unter Virtual Threads, wo tausende Threads gleichzeitig laufen können, sind Scoped Values durch ihre Referenz-Teilung dem kopierenden ThreadLocal überlegen. In Verbindung mit Structured Concurrency entsteht ein Programmiermodell, das funktionale Reinheit mit nebenläufiger Leistungsfähigkeit verbindet. ThreadLocal sollte nur noch für Legacy-Code oder spezielle Low-Level-Anwendungen genutzt werden.

Wichtig: Sowohl Scoped Values als auch Structured Concurrency sind in Java 24 (Stand März 2025) noch Preview-Features und nicht für den produktiven Einsatz empfohlen. Die APIs sind stabil, können sich aber noch ändern. Für Production-Code sollte weiterhin ThreadLocal verwendet werden, bis diese Features final sind.