scoped values javascoped values java
HappyCoders Glasses

Scoped Values in Java

Sven Woltmann
Sven Woltmann
Aktualisiert: 4. April 2024

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, die aus Sicherheitsgründen auf den User gar nicht zugreifen können sollen.

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.

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 runWhere-Methode übergeben werden:

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

Statt eines Runnables kannst du auch ein Callable oder ein Supplier (also eine Methode mit Rückgabewert) übergeben – dafür rufst du dann call(…) oder get(…) statt run(…) auf oder übergibst das Callable bzw. den Supplier als dritten Parameter an die callWhere(…)- oder getWhere(…)-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. Dazu musst du Java 21 runterladen und die java- und javac-Kommandos mit folgenden Parametern aufrufen:

$ javac --enable-preview --source 21 *.java
$ java --enable-preview <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 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 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, 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 zum Stand von Java 21 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.