Skip to content

ThreadLocal Goes Missing Inside StructuredTaskScope

A ThreadLocal that worked fine in a thread pool returns null the moment a virtual thread forks a subtask. Here's the mechanism, and what actually fixes it.

Java java-errors threadlocal nullpointerexception virtual-threads structured-concurrency jvm java-21
Gopi Gorantala
Reading Progress

On This Page

The correlation ID that wasn't there

Here's the verdict, up front: a plain ThreadLocal does not follow a virtual thread into a StructuredTaskScope.fork() subtask — not sometimes, never. And the fix most people reach for first, InheritableThreadLocal, does bring the value back, but it charges you for every single fork, and at virtual-thread scale that bill shows up in a heap dump.

I spent a chunk of this week with five JDK installs open side by side - 8, 11, 17, 21, 25 - proving that second half to myself, because I didn't believe it until jcmd printed the number for me. Going in, I'd braced for the wrong problem entirely. The thing everyone warns you about with ThreadLocal is the classic pooled-thread leak: a worker thread that outlives the request, still holding whatever the last caller stuffed into it. Virtual threads mostly kill that particular failure mode, since a virtual thread isn't pooled and doesn't get reused once its task finishes. What actually bit me was closer to the opposite problem, and it comes in two flavors depending on which fix you reach for.

Here's the first one, JDK 21, java.util.concurrent.StructuredTaskScope (still a preview API, needs --enable-preview):

import java.util.concurrent.StructuredTaskScope;

public class Repro {
    static final ThreadLocal<String> REQUEST_ID = new ThreadLocal<>();

    public static void main(String[] args) throws Exception {
        REQUEST_ID.set("req-42");
        System.out.println("Parent sees: " + REQUEST_ID.get());

        try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
            var task = scope.fork(() -> {
                String id = REQUEST_ID.get();
                System.out.println("Child sees: " + id);
                return id.toUpperCase();
            });
            scope.join().throwIfFailed();
            System.out.println("Result: " + task.get());
        }
    }
}
Parent sees: req-42
Child sees: null
Exception in thread "main" java.util.concurrent.ExecutionException: java.lang.NullPointerException: Cannot invoke "String.toUpperCase()" because "<local0>" is null
	at java.base/java.util.concurrent.StructuredTaskScope$ShutdownOnFailure.throwIfFailed(StructuredTaskScope.java:1317)
	at java.base/java.util.concurrent.StructuredTaskScope$ShutdownOnFailure.throwIfFailed(StructuredTaskScope.java:1294)
	at Repro.main(Repro.java:16)
Caused by: java.lang.NullPointerException: Cannot invoke "String.toUpperCase()" because "<local0>" is null
	at Repro.lambda$main$0(Repro.java:14)
	at java.base/java.util.concurrent.StructuredTaskScope$SubtaskImpl.run(StructuredTaskScope.java:886)
	at java.base/java.lang.VirtualThread.run(VirtualThread.java:329)

That's JDK 21's wording. On JDK 25 the same bug throws a differently-named wrapper - more on that in the version table, because the exception type genuinely changed between the two, which is the kind of thing that breaks a catch (ExecutionException e) block that used to work.

None of this is unique to StructuredTaskScope either. Swap the scope for a plain Executors.newVirtualThreadPerTaskExecutor().submit(...) and you get the exact same null. Any brand-new Thread - virtual or platform - starts with an empty thread-local map unless something explicitly copies one into it. That's been true since ExecutorService first showed up in Java 5. What's new is how often you're now creating a brand-new thread per unit of work, and how many teams are hitting this for the first time because a request handler, a correlation ID, or a tenant context that used to ride along for free on a shared pooled thread suddenly doesn't.

Fork it and watch the value vanish

a. What you need. Any of these five installs reproduce the null: 8u502, 11.0.32, 17.0.20, 21.0.10, 25.0.4. StructuredTaskScope only exists from 21 onward and needs --enable-preview on every release through 26; more on why in Section 3.

b. The code. Save the block above as Repro.java, imports and all - it's the exact file this run compiled and executed.

c. Steps.

Step 1 - compile with preview features on
$ javac --release 21 --enable-preview Repro.java
Note: Repro.java uses preview features of Java SE 21.

Step 2 - run it
$ java --enable-preview Repro
Expected: "Child sees: null", then the ExecutionException/NullPointerException
pair shown above, exit code 1.

d. The memory side, on purpose bigger. The null is deterministic and free to reproduce. The cost story needs scale, so this one asks for real numbers instead. Mem.java parks 300,000 virtual threads at once behind a CountDownLatch so they're all still alive when the measurement runs, reads heap with Runtime.totalMemory() - Runtime.freeMemory() after three explicit gc() calls, and repeats with zero thread-locals, 50 InheritableThreadLocal values set on the parent before forking, and one ScopedValue bound instead:

static long usedMB() throws InterruptedException {
    Runtime rt = Runtime.getRuntime();
    for (int i = 0; i < 3; i++) rt.gc();
    Thread.sleep(200);
    return (rt.totalMemory() - rt.freeMemory()) / (1024 * 1024);
}

static void bench(String label, int nLocals, int nThreads) throws Exception {
    InheritableThreadLocal<String>[] itls = new InheritableThreadLocal[nLocals];
    for (int i = 0; i < nLocals; i++) {
        itls[i] = new InheritableThreadLocal<>();
        itls[i].set("v" + i + "-" + "x".repeat(64));
    }
    long before = usedMB();
    var ready = new java.util.concurrent.CountDownLatch(nThreads);
    var release = new java.util.concurrent.CountDownLatch(1);
    for (int i = 0; i < nThreads; i++) {
        Thread.ofVirtual().start(() -> {
            ready.countDown();
            try { release.await(); } catch (InterruptedException ignored) {}
        });
    }
    ready.await();
    long after = usedMB();
    System.out.printf("%-25s threads=%d before=%dMB after=%dMB delta=%dMB%n",
            label, nThreads, before, after, after - before);
    release.countDown();
}

Real services won't usually have 300,000 requests in flight at once - "a lot of short-lived virtual threads alive concurrently" is the shape of a busy I/O-bound service, compressed into one process so the delta shows up without a load generator.

$ java --enable-preview -Xmx1200m Mem.java
0 InheritableThreadLocal      threads=300000  before=  1MB after= 243MB delta= 242MB
50 InheritableThreadLocal     threads=300000  before=  4MB after= 827MB delta= 823MB
ScopedValue (1 bound)         threads=300000  before=  4MB after= 390MB delta= 386MB

e. Confirmation signal. For the NPE repro: "Child sees: null" on stdout, then the two-frame exception pair, exit code 1, under a second. For the memory run: the 50-InheritableThreadLocal line should land at roughly 3.4x the zero-locals baseline. If you instead see NoSuchElementException: ScopedValue not bound, you're running the ScopedValue variant and forgot to call .where(...).run(...) before touching .get() - different failure, unbound rather than uninherited, and the fix is different too (Section 6).

f. Where it doesn't show up. A platform-thread ExecutorService with a small, warm, long-lived pool can mask this for a long time, because the first few tasks that ran on each pool thread may have left values behind from an earlier, unrelated request if someone forgot a .remove() - which is the opposite bug, and the one everyone's already braced for. It's specifically the combination of "new thread per task" and "context set on the caller, read in the task" that trips this.

g. Teardown. Nothing persists. Kill the JVM and the virtual threads, their stacks, and their thread-local maps go with it - that part, at least, doesn't leak.

Eight releases, and the API still isn't done

JDKStructuredTaskScopeScopedValueWhat I actually saw
8 / 11doesn't existdoesn't existclassic ExecutorService + ThreadLocal already returns null on a pool worker; nothing about this is new
17doesn't existdoesn't existconfirmed live: cannot find symbol: class StructuredTaskScope
19incubator, jdk.incubator.concurrent (JEP 428)doesn't exist yetsourced from the JEP text only, not installed this run
20incubator, 2nd round (JEP 437)incubator (JEP 429)sourced only
21preview 1, moved into java.util.concurrent (JEP 453)preview 1 (JEP 446)live-verified: fork() NPE wraps as ExecutionException
22preview 2 (JEP 462)preview 2 (JEP 464)sourced only
23preview 3 (JEP 480)preview 3 (JEP 481)sourced only
24preview 4 (JEP 499)preview 4 (JEP 487)sourced only
25preview 5 (JEP 505), ShutdownOnFailure removedfinal (JEP 506)live-verified: StructuredTaskScope.open() + Joiner instead; NPE now wraps as StructuredTaskScope$FailedException; ScopedValue compiles and runs with no --enable-preview flag at all
26 (GA 2026-03)preview 6 (JEP 525), "minor" Joiner/result-handling changesstill finalsourced only

ScopedValue finalized in JDK 25, and as of this writing StructuredTaskScope still hasn't - it's outlasted the thing it usually gets paired with by at least a release, with no finalization JEP in sight yet. If you're on 25 and you write new StructuredTaskScope.ShutdownOnFailure() the way every blog post from the last three years shows it, it won't even compile - that class is gone, replaced by a static StructuredTaskScope.open() factory taking a Joiner. I checked this by literally trying to compile the JDK 21 sample against --release 25 --enable-preview and watching javac report cannot find symbol. Eight JEPs, five JDK releases, and as of 26 the structured-concurrency half of this story is still marked preview. ScopedValue is the one piece of the pair you can actually ship without a flag.

Why the child never got the memo

A ThreadLocal isn't stored in some shared table indexed by the ThreadLocal object. It's stored on the Thread itself, in a per-thread field called threadLocals - a small hash map that only that one Thread instance can see. When you fork a subtask, StructuredTaskScope doesn't clone the calling thread. It constructs a brand-new Thread (a virtual one) with a brand-new, empty threadLocals map. Nobody copies anything into it, because ThreadLocal.set() was never designed to reach across threads in the first place - it's InheritableThreadLocal, a different subclass with different semantics, that opts into a copy, and only if you used that specific type on the parent before the child existed.

So the child not seeing req-42 isn't a bug. It's the API doing exactly what a ThreadLocal has always done since Java 1.2: stay local to the thread that set it. The surprise is entirely about expectations - code written when "the thread that runs this callback" and "the thread the request arrived on" were close enough in lifecycle that people stopped thinking about the difference.

What actually gets copied when a thread is born

I went and read the constructor instead of taking anyone's word for it, mine included. Thread's package-private constructor - the one every Thread.ofVirtual()...start() eventually funnels through - does this, right after it initializes the name and priority:

if ((characteristics & NO_INHERIT_THREAD_LOCALS) == 0) {
    Thread parent = currentThread();
    ThreadLocal.ThreadLocalMap parentMap = parent.inheritableThreadLocals;
    if (parentMap != null && parentMap.size() > 0) {
        this.inheritableThreadLocals = ThreadLocal.createInheritedMap(parentMap);
    }
    this.contextClassLoader = parent.getContextClassLoader();
}

createInheritedMap isn't a lazy pointer or a copy-on-write structure. It's new ThreadLocalMap(parentMap), and that constructor allocates a fresh backing array sized to the parent's whole table - not the number of live entries, the full hash-table capacity, which is usually bigger than the entry count because these maps grow in powers of two - then walks every slot and, for each one that's live, calls key.childValue(entry.value) and builds a brand-new Entry. Fifty InheritableThreadLocal values on the parent means fifty new Entry objects and one new backing array, allocated fresh, on every single fork. That's the whole story behind the 823MB line in the benchmark above: it's not a leak in the sense of "forgot to call remove()," it's the JDK doing precisely what you told it to do, at whatever scale you're now doing it at.

ScopedValue sidesteps the copy by not maintaining a mutable per-thread map at all. A new Thread starts with a sentinel (NEW_THREAD_BINDINGS) instead of a map, and ScopedValue.where(value, x).run(task) builds an immutable linked snapshot on the stack, not on the heap per thread. When StructuredTaskScope.fork() creates the child, it hands that child a reference to the same snapshot object the parent already has - one pointer, not fifty allocations. That's what JEP 506 means when it says scoped values are inherited "with minimal overhead": there's genuinely nothing to copy, because nothing was ever stored per-thread in the first place. If you want to see it for yourself, Thread.java and ScopedValue.java in openjdk/jdk are both plain-text and about as readable as JDK internals get.

Binding the value instead of stashing it

The ThreadLocal version reads like a global variable with extra steps:

// before - context set, hopefully read before it goes stale or missing
static final ThreadLocal<String> REQUEST_ID = new ThreadLocal<>();
REQUEST_ID.set("req-42");
scope.fork(() -> REQUEST_ID.get().toUpperCase()); // null in the child

The ScopedValue version reads like a parameter you didn't have to thread through every method signature:

// after - bound for the life of the lambda, inherited by every forked subtask
static final ScopedValue<String> REQUEST_ID = ScopedValue.newInstance();

ScopedValue.where(REQUEST_ID, "req-42").run(() -> {
    try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
        var task = scope.fork(() -> REQUEST_ID.get().toUpperCase());
        scope.join().throwIfFailed();
    }
});

I ran both, live, on 21. The ScopedValue child printed req-42 every time; the ThreadLocal child printed null every time. Two things are worth knowing before you switch, though. First, ScopedValue.get() on an unbound value doesn't return null the way ThreadLocal does - it throws NoSuchElementException: ScopedValue not bound, which I'd call a genuine improvement, since a missing correlation ID now fails loud instead of quietly logging "correlationId=null" for a week before anyone notices. Second, if all you did was swap ThreadLocal for InheritableThreadLocal because it was the smaller diff, you fixed the null and kept the allocation cost - that's a real fix for correctness and not really a fix for the thing that shows up in a heap dump.

Labeling the cost of each option honestly: InheritableThreadLocal costs one map copy per fork, scaling with however many inheritable values the parent is holding. ScopedValue costs one immutable snapshot build per where().run() block, reused by every fork underneath it, and it can't be reassigned mid-flight the way a ThreadLocal.set() can - which is a constraint, not a bug, but it does mean code that mutates context deep in a call stack needs restructuring, not just a find-and-replace.

Stop passing context through a global

The better shape, once you've felt this once, is to treat request-scoped context the way you'd treat a method parameter that happens to be implicit: bind it once, as close to the entry point as possible, and let structured concurrency do the propagating instead of a static field.

record RequestContext(String requestId, String tenantId) {}

static final ScopedValue<RequestContext> CTX = ScopedValue.newInstance();

void handle(Object exchange) throws Exception {
    var ctx = new RequestContext(newRequestId(), tenantOf(exchange));
    ScopedValue.where(CTX, ctx).run(() -> {
        try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
            var orders = scope.fork(() -> fetchOrders(CTX.get().tenantId()));
            var user   = scope.fork(() -> fetchUser(CTX.get().requestId()));
            scope.join().throwIfFailed();
            respond(exchange, orders.get(), user.get());
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
    });
}

One bound value, a record instead of five separate ThreadLocal fields, and every subtask forked underneath sees the same context without anyone remembering to pass it explicitly. It also means there's exactly one place code can rebind the context - the where() call - instead of an arbitrary number of places that could call .set() on a mutable static.

What the histogram tells you before prod does

If you already shipped InheritableThreadLocal-based context and want to know how much it's actually costing before you touch the code, jcmd's class histogram gives you the exact count, not an estimate. I parked 100,000 virtual threads, each carrying 30 InheritableThreadLocal entries, and asked for it live:

$ jcmd <pid> GC.class_histogram
 num     #instances    #bytes  class name
   2:       3000030  96000960  java.lang.ThreadLocal$ThreadLocalMap$Entry
   3:        100001  27200272  [Ljava.lang.ThreadLocal$ThreadLocalMap$Entry;
   4:        100000  14400000  java.lang.VirtualThread
   9:        100001   2400024  java.lang.ThreadLocal$ThreadLocalMap

3,000,030 entries for 100,000 threads times 30 locals, plus change for the main thread - the arithmetic checks out exactly, which is a nice sanity check that this technique measures what you think it measures. grep ThreadLocal against a production heap dump or a live jcmd GC.class_histogram is the cheapest way to find out you have this problem before someone files a ticket about a slow-growing heap that only shows up under real traffic. Pair it with a CI-time rule if you use ArchUnit or a similar tool: flag any new InheritableThreadLocal field in a class that also imports StructuredTaskScope or Executors.newVirtualThreadPerTaskExecutor, and make the reviewer justify it instead of discovering it in a heap dump eight months later. On the upgrade side: this cost exists identically on every JDK from 21 through 26, since InheritableThreadLocal's copy mechanism hasn't changed - moving to a newer JDK doesn't buy you out of it. Only switching the API does.

The part worth screenshotting

A plain ThreadLocal never crosses into a StructuredTaskScope.fork() child, on any JDK version, and that's not a bug to report.

InheritableThreadLocal fixes the null but copies a whole backing array plus one Entry per inherited value, on every fork - measured at 3.4x the baseline heap for 50 values across 300,000 concurrent virtual threads.

ScopedValue finalized in JDK 25 (JEP 506); StructuredTaskScope is still preview as of JDK 26 (JEP 525, sixth round) - don't assume they graduated together, because they didn't.

An unbound ScopedValue.get() throws NoSuchElementException instead of returning null, which turns a silent context loss into a loud, immediate failure - that alone is worth the migration on some codebases.

jcmd <pid> GC.class_histogram | grep ThreadLocal costs nothing and tells you today whether this is already happening in production.

Different question, same missing value

Does ScopedValue completely replace ThreadLocal?

Not for everything. ScopedValue is for read-mostly context that's set once and read by callees for the duration of one call tree - request IDs, security principals, tenant context. If you genuinely need a thread-confined mutable cache, like a reusable SimpleDateFormat or a buffer, ThreadLocal is still the right tool; ScopedValue bindings can't be reassigned once bound.

Why does my MDC / correlation ID disappear in a virtual-thread task?

Most logging MDC implementations are backed by a plain ThreadLocal, and the Logback manual says outright that "a child thread does not automatically inherit a copy of the mapped diagnostic context of its parent," recommending you call MDC.getCopyOfContextMap() on the caller and MDC.setContextMap() on the new thread by hand. A virtual-thread-per-task executor is exactly the kind of new-thread-per-unit-of-work setup that exposes this.

Is InheritableThreadLocal deprecated for virtual threads?

No, it still works exactly as documented, and I verified it does. It's discouraged, not removed - the JDK's own virtual-thread guidance flags the inheritance cost as a real concern at scale, which is a recommendation, not a compile error.

Can I use ScopedValue with a regular ExecutorService instead of StructuredTaskScope?

Only within the lexical scope of the where().run() call - if the Runnable you submit is defined inside that block, it sees the binding. ScopedValue bindings don't survive being handed to an executor that runs the task on a thread created outside that block's stack; StructuredTaskScope.fork() is what wires the two together correctly.

Javajava-errorsthreadlocalnullpointerexceptionvirtual-threadsstructured-concurrencyjvmjava-21

I'm Gopi — 15+ years in Java, building Kafka and Flink platforms for banks, where one lost event is a financial discrepancy. I write javahandbook.com because the guides I needed didn't exist. Everything here is tested against a real cluster first.

Comments