JEP 506: What are Scoped Values and how can they be used?

Michael Simons

2025-10-10

When watching the Java 25 launch stream with friends, questions among them arose what’s the purpose of the new ScopedValue and in bigger terms, the slightly older ThreadLocal. In this article I try to answer that question.

Thread bound variables

Looking back at my last ten years of writing software, I needed to use a ThreadLocal in exactly two places, one of them reoccurring:

The whole idea of a ThreadLocal is to have one static field that contains an object or some information that is individual per thread. That field itself should be immutable. Why static? Because the information contained therein are usually needed in a one-to-many-relationship between a singleton and many threads. That singleton might be something like a transaction manager, translating database transactions into application-level transactions, a security manager providing tenant information to a context or something similar. If it wouldn’t be a singleton, you would manage the contextual information in a regular instance field.

My two examples above are non-exhaustive. I already mentioned tenant information, but you could also think tracing or logging information, such as a span-id or diagnostic context for logging. Essentially, many cross-cutting, non-functional aspects that might be used in methods deep down the stack are candidates to be contained in a thread local variable: You don’t want to pass them down a stack of nine methods to use them in the tenth. Sure, you might question your sanity if you have a call stack of application code that deep, but think about a webframework or similar, in which that isn’t the exception, but the rule: Its stack will always add to yours.

Caching non-thread-safe variables is actually one of the easiest use-cases and the one in which the fewest mistakes are possible. A safe way with a ThreadLocal looks like this (here: Caching javax.xml.parsers.DocumentBuilderFactory, which are not thread-safe):

private static final ThreadLocal<DocumentBuilderFactory> DOCUMENT_BUILDER_FACTORY = ThreadLocal.withInitial(() -> {
    var value = DocumentBuilderFactory.newInstance();
    value.setExpandEntityReferences(false);
    value.setNamespaceAware(true);
    return value;
});

You initialise the thread local variable once, and due to the fact you provide an initial value, you can safely call DOCUMENT_BUILDER_FACTORY and go ahead. This is mostly safe, as there is no need to call remove here, as the factory cannot leak information, but obviously, the thread local variable is still mutable, and any thread could modify it if the variable has not been private.

What’s wrong with ThreadLocal?

JEP 506 states:

Introduce scoped values, which enable a method to share immutable data both with its callees within a thread, and with child threads. Scoped values are easier to reason about than thread-local variables. They also have lower space and time costs, especially when used together with virtual threads (JEP 444) and structured concurrency (JEP 505).

— Andrew Haley & Andrew Dinn

To understand this, I will first look at ThreadLocal and create a web application that uses a thread local variable to hold a tenant id and provides the ability to escalate privileges if needed, effectively changing the tenant (aka impersonating another one). With plain Java 25 code a small web “framework” might look like this:

import module jdk.httpserver;

// The thread local variable holding the tenant
static final ThreadLocal<Tenant> TENANT = new ThreadLocal<>();

void main() throws IOException {

    var server = HttpServer.create(new InetSocketAddress("localhost", 8080), 0);
    server.setExecutor(Executors.newFixedThreadPool(2));
    
    var ctx = server.createContext("/", exchange -> {
        var path = exchange.getRequestURI().getPath();
        var response = switch (path) {
            case "/hello" -> getHello();
            default -> Response.notFound();
        };
        var headers = exchange.getResponseHeaders();
        headers.set("Content-Type", "text/html");
        headers.set("charset", StandardCharsets.UTF_8.name());
        exchange.sendResponseHeaders(response.code(), response.length());
        var outputStream = exchange.getResponseBody();
        if (response instanceof HasBody(_, byte[] body)) {
            outputStream.write(body);
            outputStream.flush();
        }
        outputStream.close();
    });
    ctx.setAuthenticator(new BasicAuthenticator("demo") {
        @Override
        public boolean checkCredentials(String username, String password) {
            return "verysecret".equals(password);
        }
    });
    // Add a filter that computes the tenant and stores it in the thread local
    ctx.getFilters().add(new TenantFilter()); 
    server.start();
}

sealed interface Response {
    static Response ok(String body) {
        return new HasBody(200, (body + "\n").getBytes(StandardCharsets.UTF_8));
    }

    static Response notFound() {
        return new NoBody(404);
    }

    int code();

    default int length() {
        return 0;
    }
}

record HasBody(int code, byte[] body) implements Response {
    @Override
    public int length() {
        return body.length;
    }
}

record NoBody(int code) implements Response {
}

Yes, this is a valid Java program, missing a class header and having a weird looking main-method while importing a whole module. The full source code is here and the above program can be directly run with java ThreadLocalApp.java.

The program implements a webserver, handling requests against / and reacting to /hello with a message. The message might include a tenant id, and when a special user is making the request, some escalated processing can be done. For that, the tenant id goes to eleven, which is shown below, effectively modifying the thread local variable state:

static Response getHello() {
    var tenant = TENANT.get();
    String msg;
    if (tenant == null) {
        msg = "Hello.";
    } else {
        msg = "Hello, " + tenant.id();
        if (tenant.userName().equals("michael")) {
            TENANT.set(new Tenant(tenant.userName(), 11));
            msg = escalatedProcessing();
        }
    }
    return Response.ok("%s [%s]".formatted(msg, Thread.currentThread()));
}

static String escalatedProcessing() {
    var tenant = TENANT.get();
    return "This is Spinal Tap, these go to %s!".formatted(tenant.id());
}

Given a method computing an optional tenant with a declaration like that:

Optional<Tenant> getTenant(HttpExchange exchange);

the full TenantFilter looks like this:

static final class TenantFilter extends AbstractTenantFilter {
    @Override
    public void doFilter(HttpExchange exchange, Chain chain) throws IOException {
        getTenant(exchange).ifPresent(TENANT::set);
        chain.doFilter(exchange);
    }
}

So simple, so wrong. Let’s run the program and cURL twice like that:

➜ curl -u foo:verysecret http://localhost:8080/hello
Hello, 1 [Thread[#36,pool-1-thread-1,5,main]]
➜ curl -u bar:verysecret http://localhost:8080/hello
Hello, 2 [Thread[#36,pool-1-thread-1,5,main]]

If we call it a third time, like this, the first error appears:

➜ curl -u oops:verysecret http://localhost:8080/hello
Hello, 2 [Thread[#36,pool-1-thread-1,5,main]]

Instead of seeing a plain Hello, we still have the tenant in our local threads context. Why? We never called TENANT.remove(); inside the filter. Bummer. Of course this also holds true when going through the escalation:

➜ curl -u michael:verysecret http://localhost:8080/hello
This is Spinal Tap, these go to 11! [Thread[#36,pool-1-thread-1,5,main]]
➜ curl -u baz:verysecret http://localhost:8080/hello    
This is Spinal Tap, these go to 11! [Thread[#36,pool-1-thread-1,5,main]]

This gets even worse when requests made in parallel: Some will have the correct header, some don’t.

Problems from the JEP easily made visible:

We didn’t address inheritance. The JEP speaks about Expensive inheritance, I see it more from a controls perspective: When using ThreadLocal, it is up to the declaration site: Is it using a ThreadLocal or an InheritableThreadLocal? The caller will never know if any child thread is able to access the content or not. This is quite important for transaction management for example: You want to know this if you plan on implementing nested transactions and things like these. Or, if you fan out many, many child threads, you may especially don’t want to pay the cost of inheritance of thread local virtuals. The cost is high as child threads cannot share the storage used by the parent thread because the ThreadLocal API requires that changing a thread’s copy of the thread-local variable is not seen in other threads.

ScopedValue to the rescue

Find the full program here: ScopedValuesApp.java. The first significant change is made to the tenant holder of course:

static final ScopedValue<Tenant> TENANT = ScopedValue.newInstance();

If you look at the exposed methods, you surprisingly don’t find mutators, only getters and methods that looks strangely familiar like the ones on Optional, such as orElse() and orElseThrow(), indicating that get will should never return a literal null (Spoiler: It does, you can bind null to it, too). The lack of mutators is on purpose, fixing the unconstrainted mutability. Let’s fix the tenant filter first:

static final class TenantFilter extends AbstractTenantFilter {

    @Override
    public void doFilter(HttpExchange exchange, Chain chain) {

        Runnable runnableChain = () -> {
            try {
                chain.doFilter(exchange);
            } catch (IOException ex) {
                throw new UncheckedIOException(ex);
            }
        };

        getTenant(exchange).ifPresentOrElse(
            // If present, we present the value to the scoped value (where)
            // and then execute $something inside the scope
            tenant -> ScopedValue.where(TENANT, tenant).run(runnableChain),
            // Otherwise it runs outside scope
            runnableChain
        );
    }
}

If that would all the work, we broke the program: The keen reader will notice that I refrained from binding null to the scoped value (it works, though), so we must first check whether it’s bound or not. While we are at it, we also introduce proper nested scoping:

static Response getHello() {
    // Need to check if the scoped value is bound
    var tenant = (TENANT.isBound()) ? TENANT.get() : null;
    String msg;
    if (tenant == null) {
        msg = "Hello.";
    } else {
        msg = "Hello, " + tenant.id();
        if (tenant.userName().equals("michael")) {
            // TENANT is bound already here, when presented with a new value, 
            // it will be bound to that one in a child scope for the duration
            // of the call. Note that it would also apply to `run`, but we want
            // the result of the method being called
            msg = ScopedValue.where(TENANT, new Tenant(tenant.userName(), 11))
                .call(() -> escalatedProcessing());
        }
    }
    return Response.ok("%s [%s]".formatted(msg, Thread.currentThread()));
}

The errors and information leaking we observed with ThreadLocalApp won’t appear in ScopedValuesApp anymore. As a next step we should obviously don’t restrict our webserver to a thread pool of two anymore (changing Executors.newFixedThreadPool(2) to Executors.newVirtualThreadPerTaskExecutor() for example), but that is a different story.

When JEP 505 is out of preview, StructuredTaskScope and it’s corresponding fork method will also ensure that the calling site is aware of whether scoped values are inherited or not.

Conclusion

I have shown an example in which you would have used a thread local variable to pass information in a unidirectional fashion down a call stack. It screams for scoped values. The JEP itself also speaks for something like nested transactions, as detecting recursion can be useful in the case of flattened transactions: Any transaction started while a transaction is in progress becomes part of the outermost transaction. That resonates a lot with me.

In general, it remarks that one shouldn’t work against the framework, and I very much agree. For example, if you want to share information up- and down the stack, you can’t migrate to scope value, as their goal is quite the opposite. Generally speaking, the way scoped values are designed, combining a literal scope (the program flow) through method references or lambdas with the dynamic scope of the flow itself, makes it quite hard to do stupid things with it.

I tried for half a day to recreate the SQL statements BEGIN; and COMMIT; (note the semicolons, those are statements, not the beginning and end of a block) inside a fictive transaction manager: I can with ThreadLocal relatively simple (and actually, correct), but not with a ScopedValue. I do think this speaks for the design of an API that most people creating business logic will hopefully not to have to use that often.

Links

1 Java 25 launch stream https://dev.java/community/java-25-launch/
2 class header and having a weird looking main-method https://openjdk.org/jeps/512
3 a whole module https://openjdk.org/jeps/511
4 here https://codeberg.org/michael-simons/publications/src/branch/main/demos/jep506
5 ScopedValuesApp.java https://codeberg.org/michael-simons/publications/src/branch/main/demos/jep506/ScopedValuesApp.java
6 JEP 505 https://openjdk.org/jeps/505

Source JEP506_what-are-scoped-values-and-how-can-they-be-used.md