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.

Java Architect Essentials
Java Architect Essentials
Java Architect Essentials
Why ThreadLocal Can Leak Memory and How ScopedValue Solves It in Java 20

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.

Animated illustration
Animated illustration
Original Source

Signed-in readers can open the original source through BestHub's protected redirect.

Sign in to view source
Republication Notice

This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactadmin@besthub.devand we will review it promptly.

JavaThreadLocalVirtualThreadsScopedValueStructuredConcurrency
Java Architect Essentials
Written by

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.

0 followers
Reader feedback

How this landed with the community

Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.