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<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();
        // Perform checkout
        // Clear cart to prevent memory leak
        cartHolder.remove();
    }
}

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

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

    public List<Product> 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<UserVo> 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<Session> 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<Weather>()) {
        Future<Weather> 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<String> 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<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 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 <T,R> R where(ScopedValue<T> key, T value, Callable<? extends R> 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 <T> Carrier of(ScopedValue<T> key, T value) {
    return where(key, value, null);
}

private static final <T> Carrier where(ScopedValue<T> 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.

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.

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

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.