All articles

// Knowledge.log — 技術記事

Scoped Values in Java 25: Replacing ThreadLocal Without Leaking State Across Pooled Threads

Java 25 finalizes Scoped Values: reproduce a ThreadLocal pool leak, use the real API (no bind), and wire StructuredTaskScope while it is still preview.

You have a ThreadLocal<String> holding the request's tenantId. It's worked for years. Then someone swaps the executor for a reused pool — or just cranks up concurrency — and request B starts responding with request A's data. Nobody touched the business logic. What changed was the thread's lifetime.

This article reproduces that leak on purpose, walks through the API JDK 25 finalized to fix it (ScopedValue, JEP 506), and covers the part that usually goes wrong: people pasting a fan-out snippet from a 2023 tutorial that no longer compiles.

Prerequisites

  • JDK 25 LTS. Tested here with Temurin 25.0.4.
  • ScopedValue has been final API since Java 25 — no preview flag needed for it.
  • The fan-out section uses StructuredTaskScope (JEP 505), which is still preview in 25 and remains preview in 26 (JEP 525). That means --enable-preview at compile time, in tests, and at runtime — only for that section, not for the rest of the article.

The leak, reproduced

A ThreadLocal holds a value per thread, not per task. In a pool, the thread outlives the task. If nobody calls remove(), the next submit() on the same thread finds the previous task's leftovers:

import java.util.concurrent.Executors;

static final ThreadLocal<String> TL = new ThreadLocal<>();

public static void main(String[] args) throws Exception {
    try (var pool = Executors.newSingleThreadExecutor()) {
        pool.submit(() -> { TL.set("tenant-A"); return TL.get(); }).get();
        String leaked = pool.submit(TL::get).get();
        System.out.println("LEAK_CONFIRMED=" + leaked);
    }
}

Running it locally: LEAK_CONFIRMED=tenant-A. The second task never called set, but it inherited the first task's tenant because both landed on the same pool thread. It's the kind of bug that sails through tests (one task per run, fresh thread) and only shows up under load.

Worth noting the honest exception: virtual threads generally dodge this specific variant of the bug, because they aren't reused — each one is born and dies with the task. But that's not an argument for keeping ThreadLocal: a set without a matching remove() still keeps the value alive until the thread dies, and InheritableThreadLocal still copies the value into child threads created with Thread.start(). Swapping the executor implementation isn't the fix; swapping the mechanism is.

The real ScopedValue API (there's no bind)

If you've seen an old preview of JEP 506, forget bind. It was never a public method, and it isn't one now. Binding happens through where(...) followed by run or call:

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

String id = ScopedValue.where(TENANT, "acme")
        .call(() -> service());          // inside service(), TENANT.get() == "acme"

ScopedValue.where(TENANT, "acme").run(() -> service());

where returns an immutable Carrier — you can chain where(A, a).where(B, b). call takes a Callable-like that can throw a checked exception; run takes a Runnable. Outside of run/call, the value doesn't exist: get() throws NoSuchElementException, it doesn't return null.

Rebinding is nested and reverts automatically once the scope exits:

ScopedValue.where(TENANT, "hello").run(() -> {
    // TENANT.get() -> "hello"
    ScopedValue.where(TENANT, "goodbye").run(() -> {
        // TENANT.get() -> "goodbye"
    });
    // back here, TENANT.get() -> "hello"
});

The win over ThreadLocal isn't syntactic, it's structural: there's no remove() because there's no state to forget to clean up. The value only exists within the dynamic extent of run/call. We verified this in the same single-thread pool from the earlier example — after call returns, a following task that never went through where arrives with isBound() == false, local result SCOPED_NO_LEAK laterBound=false. No remove, nothing left over.

The full API you'll actually use: newInstance(), where(key, value), get(), isBound(), orElse(other), and orElseThrow(supplier). One documented gotcha: orElse(null) throws NullPointerException — the argument has to be non-null. If you want to treat "unbound" as null, check isBound() first instead of relying on orElse(null).

Fan-out with StructuredTaskScope (preview)

ScopedValue alone solves the sequential case. The interesting part is what happens when a task splits into concurrent subtasks — that's exactly where ThreadLocal never had a decent answer, and ScopedValue doesn't solve it alone either: propagating context to subtasks is StructuredTaskScope's job.

Heads up: the JEP 506 text itself shows a snippet using new StructuredTaskScope.ShutdownOnFailure(). That doesn't compile on 25 or 26 — that class is gone. The current API (JEP 505, fifth preview) is StructuredTaskScope.open(...) plus a Joiner:

// javac --release 25 --enable-preview
// java --enable-preview
import java.util.concurrent.StructuredTaskScope;
import java.util.concurrent.StructuredTaskScope.Joiner;
import java.util.List;

record Ctx(String tenant, String trace) {}
static final ScopedValue<Ctx> CTX = ScopedValue.newInstance();

List<String> fanOut(String tenant) throws InterruptedException {
    return ScopedValue.where(CTX, new Ctx(tenant, "t-1")).call(() -> {
        try (var scope = StructuredTaskScope.open(
                Joiner.<String>allSuccessfulOrThrow())) {
            scope.fork(() -> "user:" + CTX.get().tenant());
            scope.fork(() -> "order:" + CTX.get().tenant());
            return scope.join().map(StructuredTaskScope.Subtask::get).toList();
        }
    });
}

open() with no argument already gives you a default behavior (fails if any subtask fails, but join() doesn't hand back the results); use an explicit Joiner — here allSuccessfulOrThrow() — when you need the list of results. Each fork runs on a fresh virtual thread by default.

Binding is inherited because it's captured when the scope is opened, not copied thread-to-thread. That's why fork sees CTX, while a loose Thread.start() or a plain ExecutorService wouldn't — there's no structural guarantee that a child thread dies before the scope exits its block, so the JEP simply doesn't propagate in that case. We confirmed this locally: Thread.ofVirtual().unstarted(...).start() inside a where produces a child thread with isBound() == false — local output: UNSTRUCTURED_VT_INHERITS=false.

Inside the scope, a child can rebind without leaking to its sibling or its parent — the rebind is local to that fork:

var pair = ScopedValue.where(TENANT, "outer").call(() -> {
    try (var scope = StructuredTaskScope.open()) {
        var inner = scope.fork(() ->
                ScopedValue.where(TENANT, "inner").call(TENANT::get));
        var sibling = scope.fork(TENANT::get);
        scope.join();
        return inner.get() + "/" + sibling.get();
    }
});

Locally: pair comes out inner/outer (local output: STS_INNER_SIBLING=inner/outer) — the fork that rebound sees "inner", its sibling still sees "outer", and the parent never changes.

Automated proof of isolation

Reproducing the leak by hand is convincing once; a test keeps it from coming back. Two tests cover what matters: isolation between concurrent tasks on the same pool, and the fork/sibling behavior of StructuredTaskScope.

@Test
void concurrentScopesDoNotLeak() throws Exception {
    var barrier = new java.util.concurrent.CyclicBarrier(2);
    try (var pool = Executors.newFixedThreadPool(2)) {
        var a = pool.submit(() -> ScopedValue.where(TENANT, "A").call(() -> {
            barrier.await();
            return TENANT.get();
        }));
        var b = pool.submit(() -> ScopedValue.where(TENANT, "B").call(() -> {
            barrier.await();
            return TENANT.get();
        }));
        assertEquals("A", a.get());
        assertEquals("B", b.get());
    }
}

@Test
void forkInheritsAndSiblingDoesNotSeeRebound() throws Exception {
    var pair = ScopedValue.where(TENANT, "outer").call(() -> {
        try (var scope = StructuredTaskScope.open()) {
            var inner = scope.fork(() ->
                    ScopedValue.where(TENANT, "inner").call(TENANT::get));
            var sibling = scope.fork(TENANT::get);
            scope.join();
            return inner.get() + "/" + sibling.get();
        }
    });
    assertEquals("inner/outer", pair);
}

@Test
void unboundGetThrows() {
    assertFalse(TENANT.isBound());
    assertThrows(NoSuchElementException.class, TENANT::get);
}

The CyclicBarrier forces the two tasks to run overlapping on purpose — without it, a two-thread pool could finish task A entirely before starting B, and the test would pass even with a broken propagation. Reminder: forkInheritsAndSiblingDoesNotSeeRebound uses StructuredTaskScope, so the test runtime also needs --enable-preview (see Prerequisites).

Migration checklist and things to watch

Not every ThreadLocal in your codebase is a candidate. ScopedValue solves immutable, one-way context (tenant, trace id, authenticated principal); ThreadLocal is still the right tool for mutable per-thread caches — JEP 506 doesn't deprecate it.

  1. Confirm the usage is read-only: nothing halfway down the stack calls set to "signal back" to the caller. That two-way pattern has no equivalent in ScopedValue — if you depend on it, keep the ThreadLocal.
  2. Replace static final ThreadLocal<String> TL = new ThreadLocal<>(); with static final ScopedValue<String> TENANT = ScopedValue.newInstance();.
  3. Replace the set() at the request's entry point with a where(key, value).run(...) (or .call(...)) wrapping the whole unit of work. Drop the remove() — reverting is automatic once the block exits.
  4. Replace reads of get() with get(), isBound(), orElse(default), or orElseThrow(...). Never orElse(null) — it throws NullPointerException.
  5. If the context needs to cross a fan-out, replace a loose ExecutorService.submit with StructuredTaskScope.open() + fork, inside the where (preview flag: see Prerequisites).
  6. Observability: always log isBound() at the handler's entry. Only log the bound value when it's a non-sensitive id (tenant, trace); for principal or token, log a hash or a stable id, never the raw value. In fan-out, log or assert isBound() at the entry of each subtask — it's the cheapest way to catch a stray Thread.start() or ExecutorService that didn't inherit the context.
  7. Storing a mutable object inside a ScopedValue still requires external synchronization — the API doesn't copy or protect the contents.

Recommendation

If your ThreadLocal only carries read-only per-request context (tenant, trace, principal) and you're already on JDK 25, migrating to ScopedValue is worth doing now — the API is final, the structural win (nothing to forget to clean up) is real, and the porting cost is low. If concurrent fan-out with StructuredTaskScope is part of the plan, treat that part as genuinely preview: keep --enable-preview scoped to the module that needs it instead of spreading it across the whole build, because the Joiner API is still shifting from one release to the next (JDK 26 itself already renames Joiner methods). Outside of that — mutable per-thread caches, the "set from inside to signal the caller" pattern — leave ThreadLocal where it is.

Next step

Run this article's three tests with --enable-preview in your pom.xml/build.gradle (compiler + surefire/test) and confirm the two outputs that matter most in your environment: SCOPED_NO_LEAK (nothing left over between tasks) and STS_INNER_SIBLING=inner/outer (fork isolates from its sibling). If you already have fan-out in production with StructuredTaskScope, that's the natural place to drop ScopedValue in for any ThreadLocal currently being dragged across that fork by force.

javajdk-25concurrency

// Continue.training — 次のステップ

Knowledge only counts when it becomes practice.

Go back to the article, run the examples, and share what you learned.

Explore more articles