Understanding Scoped Values in Java: Why Direct Thread Inheritance Fails

If you are exploring modern Java features like ScopedValue (introduced as a preview feature in Java 20 and refined in subsequent releases), you might run into a NoSuchElementException when trying to read a scoped value from a newly spawned child thread.

Consider the following snippet:

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

public static void main(String[] args) {
    ScopedValue.where(USERID, "guest").run(() -> {
        // Spawning an unstructured thread
        new Thread(() -> {
            System.out.println(USERID.get()); // Throws NoSuchElementException!
        }).start();
    });
}

This throws a NoSuchElementException because ScopedValue bindings are strictly bounded by thread execution frames and structured concurrency boundaries. Unlike InheritableThreadLocal, a ScopedValue does not automatically leak into unstructured child threads created via standard new Thread() calls.

Since an unstructured thread can outlive the scope in which the ScopedValue was bound, allowing access could lead to reading stale or invalid data. Java intentionally prevents this behavior.

Solution 1: Use Structured Concurrency (The Recommended Approach)

The idiomatic and safest way to share a ScopedValue with child threads is by using Structured Concurrency via StructuredTaskScope. Child threads created with scope.fork(...) automatically inherit the scoped value bindings of the parent thread because their lifecycle is strictly bounded by the scope.

import java.util.concurrent.StructuredTaskScope;

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

public static void main(String[] args) throws InterruptedException {
    ScopedValue.where(USERID, "guest").run(() -> {
        try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
            // Forking a subtask automatically inherits the ScopedValue
            scope.fork(() -> {
                System.out.println("User ID in child thread: " + USERID.get());
                return null;
            });

            scope.join(); // Wait for child threads to finish
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
    });
}

Solution 2: Manually Pass and Re-bind for Unstructured Threads

If you cannot use Structured Concurrency and must use standard threads or classic thread pools, you cannot inherit the scope automatically. Instead, you must capture the value in the parent thread and manually re-bind it using ScopedValue.where() inside the child thread:

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

public static void main(String[] args) {
    ScopedValue.where(USERID, "guest").run(() -> {
        // Read the current scoped value in the parent thread
        String capturedUser = USERID.get();

        new Thread(() -> {
            // Re-bind the scoped value inside the new thread
            ScopedValue.where(USERID, capturedUser).run(() -> {
                System.out.println("Re-bound User ID: " + USERID.get());
            });
        }).start();
    });
}

ScopedValue vs. InheritableThreadLocal Summary

  • InheritableThreadLocal: Copies data blindly to child threads. Can lead to memory leaks, high overhead, and unwanted mutability across thread boundaries.
  • ScopedValue: Immutable, lightweight, and safely bound to structured execution scopes. Automatically inherited only by child threads created within a StructuredTaskScope.