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.
Looking back at my last ten years of writing software, I needed to
use a ThreadLocal in exactly two places, one of them
reoccurring:
Transaction management
Caching non-thread-safe, expensive to create variables
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.
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
rescueFind 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.
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.
| 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