Career Guide

ThreadLocal vs. Scoped Values in Java: A Modern Approach to Data Sharing

ThreadLocal has been the go-to mechanism for implicit data sharing in Java, but it comes with memory leaks and scalability issues. Learn how Scoped Values (JDK 21+) solve this.

When you build enterprise-grade applications in India, whether it is a high-throughput UPI payment gateway at Razorpay or a microservices-driven e-commerce engine at Flipkart, managing request-specific data is a critical requirement. In modern web applications, when a request hits your controller, you often need to carry the user's security context, database transaction details, or local tenant ID down to deep-nested database repositories or external API clients.

The Traditional Solution: ThreadLocal

For decades, Java developers have relied on ThreadLocal for this task. ThreadLocal provides a way to store data that is accessible only by the specific thread currently executing. Under the hood, it binds variables to the current thread by storing them in a thread-specific map. This allows implicit data sharing—you do not have to pollute every single method signature in your call stack with parameters like UserContext or TransactionId.

Here is a common scenario in a Spring Boot application:

public class UserContextHolder {
    private static final ThreadLocal<UserContext> CONTEXT = new ThreadLocal<>();

    public static void set(UserContext context) {
        CONTEXT.set(context);
    }

    public static UserContext get() {
        return CONTEXT.get();
    }

    public static void clear() {
        CONTEXT.remove();
    }
}

This looks elegant on the surface, but it hides severe traps that have caused many production outages in Indian tech houses.

The Dark Side of ThreadLocal

  1. Unbounded Lifetimes and Memory Leaks: Modern Java servers use thread pools (like Tomcat's request processing threads) to handle incoming requests. When a thread finishes processing a request, it does not die; it goes back to the pool to be reused. If a developer forgets to call CONTEXT.remove() in a finally block, the data remains bound to the thread. This results in memory leaks, where old user data lingers, causing the Java Virtual Machine (JVM) heap to fill up and eventually throw an OutOfMemoryError (OOM). Worse, it can cause security leaks where request B accidentally sees the credentials of request A!
  2. Mutability Issues: Any code executing on the same thread can call ThreadLocal.set() and overwrite the value. This mutability makes it extremely hard to trace where and why a context value was modified, leading to bug-hunting nightmares during production incidents.
  3. The Scaling Barrier of Virtual Threads: With the arrival of Virtual Threads in JDK 21 (Project Loom), the paradigm has shifted. Virtual threads are so lightweight that you can run millions of them. However, if each of these millions of virtual threads allocates its own ThreadLocal map, the memory overhead scales linearly. The benefit of cheap, lightweight virtual threads is completely wiped out by the memory footprint of ThreadLocals.

The Modern Alternative: Scoped Values

To address these fundamental issues, JDK 21 introduced Scoped Values (JEP 464, and later JEP 481 in JDK 24). A Scoped Value allows you to share data safely and efficiently within a bounded scope.

Unlike ThreadLocal, Scoped Values are:

  • Immutable: Once bound to a value, that value cannot be modified within the scope.
  • Bounded: The lifetime of the bound value is strictly limited to the execution scope of a code block. Once the block exits, the binding is automatically destroyed. No manual clean-ups or risk of leaks!
  • Efficient: Scoped Values are designed to be extremely lightweight, making them perfect for millions of virtual threads.

Defining, Binding, and Executing with Scoped Values

To use Scoped Values, you first declare a public static final ScopedValue instance. Then, you bind a value to it and run a block of code.

Here is how you do it:

import java.lang.ScopedValue;

public class SecurityService {
    // 1. Declare the Scoped Value
    public static final ScopedValue<String> USER_ID = ScopedValue.newInstance();

    public void handleRequest(String userId) {
        // 2. Bind the value and run code within the scope
        ScopedValue.where(USER_ID, userId).run(() -> {
            // Inside this block, USER_ID is bound to userId
            processPayment();
        });
        // Outside the block, USER_ID is no longer bound!
    }

    private void processPayment() {
        // 3. Read the value implicitly
        if (USER_ID.isBound()) {
            System.out.println("Processing payment for user: " + USER_ID.get());
        }
    }
}

How It Works Under the Hood

When you call ScopedValue.where(...), you are creating a dynamic scope. Any method invoked inside the lambda passed to .run() can access USER_ID.get() without passing it as an argument. If you spawn child tasks or virtual threads from within this scope using a StructuredTaskScope, the Scoped Value is automatically inherited by those child threads in a highly optimized, read-only manner.

Interview Strategy for Indian Developers

In technical rounds at companies like PhonePe, Paytm, or Oracle India, you are highly likely to face questions on Java 21 features. When asked about Virtual Threads, immediately connect the dot to Scoped Values. Explain that while virtual threads solve concurrency scalability, Scoped Values solve the data-sharing memory issues that ThreadLocal introduces when scaled to millions of threads. Demonstrating this depth shows that you are not just a developer who reads release notes, but an engineer who understands system performance and memory management.

📢 Share this article