Why ThreadLocal Can Leak Memory and How ScopedValue Solves It in Java 20
This article explains ThreadLocal's role in Java concurrency, the memory‑leak risk when its remove() method is omitted, introduces the new ScopedValue feature in JDK 20 as a safer alternative, and demonstrates practical Spring and StructuredTaskScope examples with complete code snippets.
1 ThreadLocal
ThreadLocal provides a way to keep variables isolated between threads; each Java thread holds a ThreadLocalMap whose keys are weak references to the corresponding ThreadLocal objects. If the stored value is not cleared with remove(), the map can retain references and cause serious memory leaks.
Basic concept – ThreadLocal not only isolates data but also helps reuse objects such as database connections. The map uses weak references, but forgetting to call remove() still leaks memory.
Application example – In a Spring e‑commerce project a ShoppingCartService bean uses a ThreadLocal<ShoppingCart> to store a cart per request:
@Service
public class ShoppingCartService {
private ThreadLocal<ShoppingCart> cartHolder = new ThreadLocal<>();
public ShoppingCart getCurrentCart() {
ShoppingCart cart = cartHolder.get();
if (cart == null) {
cart = new ShoppingCart();
cartHolder.set(cart);
}
return cart;
}
public void checkout() {
// get current cart
ShoppingCart cart = getCurrentCart();
// ... process checkout ...
cartHolder.remove(); // prevent memory leak
}
}
class ShoppingCart {
private List<Product> products = new ArrayList<>();
public void addProduct(Product p) { products.add(p); }
public List<Product> getProducts() { return products; }
}The service obtains the cart via cartHolder.get(), creates it if absent, and clears it with remove() after checkout, ensuring no leak even under high concurrency.
Another use case is a security aspect that stores the authenticated user in a ThreadLocal<UserVo> so that downstream code can retrieve the user without passing it as a method argument:
@Aspect
@Component
public class UserConsistencyAspect {
private static final ThreadLocal<UserVo> userHolder = new ThreadLocal<>();
@Pointcut("@annotation(org.nozomi.common.annotation.GetUser)")
public void userAuthPoint() {}
@Around("userAuthPoint()")
public Object injectUserFromRequest(ProceedingJoinPoint jp) throws Throwable {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
UserVo operator = (UserVo) auth.getPrincipal();
if (operator == null) {
return Response.fail("用户不存在");
}
userHolder.set(operator);
return jp.proceed();
}
public static UserVo getUser() { return userHolder.get(); }
}ThreadLocal is also used to keep a Hibernate Session per request, but the article notes that many frameworks already manage such sessions internally, so manual ThreadLocal usage may be unnecessary.
2 StructuredTaskScope
Structured concurrency, closely tied to virtual threads, encourages submitting tasks as Runnable or Callable to an executor and managing their lifecycles via a scope object. StructuredTaskScope acts as a virtual‑thread launcher:
public static Weather readWeather() throws Exception {
try (var scope = new StructuredTaskScope<Weather>()) {
Future<Weather> future = scope.fork(Weather::readWeatherFrom);
scope.join();
return future.resultNow();
}
}The scope is AutoCloseable, allowing a try‑with‑resources pattern. fork() creates a task, join() blocks until all forked tasks finish, and resultNow() retrieves the result, throwing if the task failed.
3 ScopedValue
JDK 20 introduces ScopedValue (in jdk.incubator.concurrent) as an immutable, scoped alternative to ThreadLocal. It is designed for structured concurrency: a value is bound for the duration of a runnable or callable execution and automatically unbound afterward.
Problems with ThreadLocal
Mutable – any code in the thread can change the value, leading to hard‑to‑debug bugs.
Long lifetime – the value persists for the whole thread life until remove() is called, which is often forgotten.
Inheritance – child threads inherit the parent’s values, causing extra memory overhead.
Because virtual threads are numerous and short‑lived, these issues are amplified. ScopedValue solves them by being immutable and having a well‑defined scope.
Basic usage
module my.module {
requires jdk.incubator.concurrent;
} static final ScopedValue<String> VALUE = ScopedValue.newInstance();
public static void main(String[] args) throws Exception {
System.out.println(Arrays.toString(stringScope()));
}
static Object[] stringScope() throws Exception {
return ScopedValue.where(VALUE, "value", () -> {
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
Future<String> user = scope.fork(VALUE::get);
Future<Integer> order = scope.fork(() -> VALUE.get().length());
scope.join().throwIfFailed();
return new Object[]{user.resultNow(), order.resultNow()};
}
});
}The static where() method binds a value to a ScopedValue for the execution of the supplied runnable/callable. Nested calls create nested scopes, each with its own immutable binding.
Key internal classes
Snapshot – an immutable map from ScopedValue to its bound value.
Carrier – builds a chain of bindings; each where() returns a new Carrier without mutating the previous one.
Cache – a small per‑thread cache that stores the result of a get() lookup for fast subsequent access.
The core API is ScopedValue.where(key, value, op), which delegates to Carrier.of(key, value).call(op). After op finishes, the binding is automatically cleared.
4 Summary
Both ThreadLocal and ScopedValue are essential for Java concurrency, but they serve different scenarios. ThreadLocal is suitable for classic thread‑based code where a long‑lived thread needs isolated state (e.g., database sessions, request‑scoped data). However, it suffers from mutability, potential memory leaks, and inheritance overhead. ScopedValue is designed for structured concurrency and virtual threads. Its immutable, scoped nature prevents leaks, eliminates inheritance costs, and simplifies passing context to asynchronous tasks without polluting method signatures.
Choosing the right tool depends on the execution model: use ThreadLocal for traditional thread pools and long‑running threads, and prefer ScopedValue when working with virtual threads, structured task scopes, or any scenario that benefits from bounded, immutable context.
Signed-in readers can open the original source through BestHub's protected redirect.
This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactand we will review it promptly.
Java Architect Essentials
Committed to sharing quality articles and tutorials to help Java programmers progress from junior to mid-level to senior architect. We curate high-quality learning resources, interview questions, videos, and projects from across the internet to help you systematically improve your Java architecture skills. Follow and reply '1024' to get Java programming resources. Learn together, grow together.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
