Under The Hood
backend java JDK 21+ (ScopedValue preview/incubator, JEP 446)

Scoped Values (JEP 446) vs ThreadLocal Memory Leaks in Virtual Threads

Last updated
Prerequisites:
Java threads and ThreadLocal basics
Understanding of virtual threads (Project Loom)
  • java
  • scoped-values
  • threadlocal
  • virtual-threads
  • loom
  • jep-446

Read at your depth

The practical view

ThreadLocal gives each thread its own copy of a value, but it's mutable, inherited-by-default (for Threads), and must be manually removed (remove()) to avoid leaks. ScopedValue (JDK 21+ incubator) is immutable and scoped: ScopedValue.where(KEY, value).run(() -> { ... }); — inside the run block, KEY.get() returns value; outside, get() throws. Scoped values propagate to virtual threads and child threads created within the scope, and they are never 'inherited' after the scope ends. Use them for request-scoped context (user id, trace id) instead of ThreadLocal.

The same idea in other frameworks

react equivalent
N/A — JVM concurrency feature.

Legacy vs modern

ThreadLocal context vs ScopedValue context

ThreadLocal must be removed manually (and leaks with virtual threads); ScopedValue binds immutably for a scope and auto-clears when the scope exits.

before → after
ThreadLocal without cleanup (leak)
private static final ThreadLocal<String> USER = new ThreadLocal<>(); // ❌ OLD
ScopedValue scoped binding
private static final ScopedValue<String> USER = ScopedValue.newInstance(); // ✅ NEW

Interview gotchas

Context

This is the Loom-era interview question — interviewers want the scale problem (millions of threads × per-thread maps) and the lifecycle guarantee that scoped values provide.

The mechanical answer

ThreadLocal stores values in a per-thread ThreadLocalMap that persists for the thread's lifetime. With platform threads (a few hundred), forgetting remove() leaked a bounded amount. Virtual threads are designed to be created in the millions, so a ThreadLocal entry that isn't removed multiplies into unbounded memory retention — and each ThreadLocalMap is a nontrivial allocation per thread. Additionally, ThreadLocal.inheritable values copy into every child thread, compounding cost. ScopedValue removes both problems: bindings are immutable, live only for the dynamic scope (run()/call()), and are automatically discarded when the scope exits — no per-thread map, no manual cleanup, and the JVM can share bindings across virtual threads since they're captured in the execution context, not the thread identity.

Trap

The naive answer is 'just remember to call remove()'. With virtual threads the failure mode is much worse (millions of leaked maps) and easy to miss in hot paths. Another trap: thinking ScopedValue is just an immutable ThreadLocal — its scope-based lifetime and lack of per-thread storage are fundamentally different, and it is read-only (no set()). Candidates who tie the design to virtual-thread economics show real understanding.

Under The Hood — a multi-depth technical interview hub.

Third-party licenses Press ⌘ K to search.