Fundamentals 23 min read

Understanding ThreadLocal and ScopedValue in Java: Concepts, Use Cases, and Structured Concurrency

This article explains the fundamentals of ThreadLocal, its memory‑leak pitfalls, introduces the new ScopedValue feature in JDK 20, demonstrates practical Spring‑based use cases, and shows how StructuredTaskScope enables safe structured concurrency with code examples.

Architect's Guide
Architect's Guide
Architect's Guide
Understanding ThreadLocal and ScopedValue in Java: Concepts, Use Cases, and Structured Concurrency

ThreadLocal is a mechanism that isolates variables between threads, implemented in Java as a ThreadLocalMap stored per thread; the map’s keys are weak references to the ThreadLocal objects.

Java developers must call remove() on ThreadLocal variables to avoid serious memory‑leak issues. JDK 20 Early‑Access Build 28 introduces a redesigned alternative called ScopedValue.

ScopedValue is an incubating JDK feature that can replace ThreadLocal in certain scenarios, allowing immutable values to be shared within a defined scope, especially useful for virtual threads.

ThreadLocal

Basic Concept

Beyond isolation, ThreadLocal solves object‑reuse problems (e.g., database connection pools). However, memory leaks can occur because ThreadLocalMap holds weak references; if the ThreadLocal is set to null without calling remove() , the entry remains.

Application Cases

In typical Spring applications, ThreadLocal can be used for request‑level data isolation.

@Service
public class ShoppingCartService {
    private ThreadLocal
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();
        // Perform checkout
        // Clear cart to prevent memory leak
        cartHolder.remove();
    }
}

// Shopping cart class
class ShoppingCart {
    private List
products = new ArrayList<>();

    public void addProduct(Product product) {
        products.add(product);
    }

    public List
getProducts() {
        return products;
    }
}

The code shows a Spring bean managing a per‑thread shopping cart via ThreadLocal<ShoppingCart> . The checkout method removes the thread‑local value to avoid leaks.

Another example uses an aspect to store the authenticated user in a ThreadLocal for the duration of an HTTP request.

@Aspect
@Component
public class UserConsistencyAspect {
    private static final ThreadLocal
userHolder = new ThreadLocal<>();

    @Pointcut("@annotation(org.nozomi.common.annotation.GetUser)")
    public void userAuthPoint() {}

    @Around("userAuthPoint()")
    public Object injectUserFromRequest(ProceedingJoinPoint joinPoint) throws Throwable {
        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
        UserVo operator = (UserVo) authentication.getPrincipal();
        if (operator == null) {
            return Response.fail("User not found");
        }
        userHolder.set(operator);
        return joinPoint.proceed();
    }

    /**
     * Retrieve the current thread's UserVo.
     */
    public static UserVo getUser() {
        return userHolder.get();
    }
}

In database session management, developers sometimes store a Hibernate Session in a ThreadLocal, though modern frameworks already handle this internally.

@Service
public class ProductService {
    private final ThreadLocal
sessionThreadLocal = new ThreadLocal<>();

    public Product getProductById(String id) {
        Session session = getSession();
        return session.get(Product.class, id);
    }

    public void updateProduct(Product product) {
        Session session = getSession();
        session.update(product);
    }

    private Session getSession() {
        Session session = sessionThreadLocal.get();
        if (session == null) {
            session = sessionFactory.openSession();
            sessionThreadLocal.set(session);
        }
        return session;
    }

    public void closeSession() {
        Session session = sessionThreadLocal.get();
        if (session != null) {
            session.close();
            sessionThreadLocal.remove();
        }
    }
}

StructuredTaskScope

Structured concurrency, closely tied to virtual threads, encourages submitting tasks as Runnable or Callable to an executor and working with the resulting Future . The Loom project adds a StructuredTaskScope that acts as a virtual‑thread launcher.

public static Weather readWeather() throws Exception {
    // try‑with‑resource
    try (var scope = new StructuredTaskScope
()) {
        Future
future = scope.fork(Weather::readWeatherFrom);
        scope.join();
        return future.resultNow();
    }
}

The scope is AutoCloseable ; using try‑with‑resources ensures proper cleanup. fork() creates a task, join() blocks until all tasks finish, and resultNow() retrieves the result (throwing if the task failed).

ScopedValue

Basic Concept

ScopedValue, incubated in JDK 20, is not meant to replace ThreadLocal but to provide immutable, scoped values for structured concurrency. Unlike mutable ThreadLocal, ScopedValue values are immutable and have a well‑defined lifetime.

Basic Usage

ScopedValue resides in the jdk.incubator.concurrent package. Declare a static final instance:

module dioxide.cn.module {
    requires jdk.incubator.concurrent;
}

Enable the preview feature with the VM option --enable-preview . Bind a value to a scope using ScopedValue.where(...) :

public class Main {
    private static final ScopedValue
VALUE = ScopedValue.newInstance();

    public static void main(String[] args) throws Exception {
        System.out.println(Arrays.toString(stringScope()));
    }

    public static Object[] stringScope() throws Exception {
        return ScopedValue.where(VALUE, "value", () -> {
            try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
                Future
user = scope.fork(VALUE::get);
                Future
order = scope.fork(() -> VALUE.get().length());
                scope.join().throwIfFailed();
                return new Object[]{user.resultNow(), order.resultNow()};
            }
        });
    }
}

The example demonstrates binding a string to a ScopedValue, forking two parallel tasks that read the value, and collecting their results.

Source Code Analysis

ScopedValue defines a where(ScopedValue, Object, Runnable) method that sets the value for the duration of the runnable’s execution. The dynamic scope ends when the runnable completes, restoring the previous binding.

Snapshot

Snapshot is an immutable map from ScopedValue to its bound values, providing a stable view that does not change even if the original ScopedValue is mutated later.

Carrier

Carrier accumulates mappings of ScopedValues to values, allowing a Runnable or Callable to execute with all bindings in place. The where method returns a new Carrier, preserving immutability.

Cache

Cache is a small per‑thread key‑value store that records the result of a ScopedValue get() for fast subsequent lookups.

where()

The core entry point, where() , builds a Carrier and then delegates to Carrier.call(op) to execute the operation within the scoped context.

public static
R where(ScopedValue
key, T value, Callable
op) throws Exception {
    return where(key, value).call(op);
}

Internally, Carrier.of(key, value) creates a new Carrier, and Carrier.where(...) chains carriers to support multiple bindings.

static
Carrier of(ScopedValue
key, T value) {
    return where(key, value, null);
}

private static final
Carrier where(ScopedValue
key, T value, Carrier prev) {
    return new Carrier(key, value, prev);
}

The call() method ultimately invokes the supplied Callable after setting up the snapshot and cache, then restores the previous state.

graph TB
    D("ScopedValue.Carrier")
    D --> E("ScopedValue.Carrier.call(op)")
    E -->|branch 1| F("ScopedValue.Cache.invalidate()")
    E -->|branch 2| G("ScopedValue.Carrier.runWith(newSnapshot, op)")
    G --> H("ScopedValueContainer.call(op)")
    H --> I("ScopedValueContainer.callWithoutScope(op)")
    I --> J("Callable.call()")

Summary

ThreadLocal and ScopedValue both play crucial roles in Java concurrency. ThreadLocal is suitable for classic thread‑local storage but requires careful removal to avoid leaks. ScopedValue, introduced for structured concurrency, offers immutable, scoped data sharing that integrates cleanly with virtual threads and StructuredTaskScope.

Choosing between them depends on the specific concurrency model: use ThreadLocal for simple thread‑isolated state, and ScopedValue when working with structured concurrency or virtual threads to avoid mutable shared state and simplify data propagation.

JavaConcurrencythreadlocalVirtualThreadsJDK20ScopedValueStructuredConcurrency
Architect's Guide
Written by

Architect's Guide

Dedicated to sharing programmer-architect skills—Java backend, system, microservice, and distributed architectures—to help you become a senior architect.

0 followers
Reader feedback

How this landed with the community

login 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.