Scoped Values in Java 25

  • Last Updated: November 20, 2025
  • By: javahandson
  • Series
img

Scoped Values in Java 25

As Java continues to evolve, new features are being added to make concurrency safer, cleaner, and easier to manage. One such addition is Scoped Values in Java 25, a mechanism that helps applications pass context in a more predictable and structured way. They are designed to work seamlessly with modern concurrency models, especially virtual threads introduced through Project Loom.

 

In many real-world applications, we often need to pass information like user IDs, request IDs, or tenant details across several layers of code. Traditionally, developers have used ThreadLocal for this, but it comes with well-known issues, especially in environments where threads are reused or created in large numbers.

Scoped Values offer a fresh, modern alternative that avoids these pitfalls. They provide a controlled, safe, and efficient way to share contextual data only within the boundaries of a specific execution scope.

The Problem with ThreadLocal

ThreadLocal was introduced long ago as a way to store data that belongs to a specific thread. It works well for simple cases, but over time, developers started facing many issues with it, especially in large, modern applications. One of the biggest problems is that ThreadLocal stores data until you manually remove it. If you forget to clean it up, the old data stays attached to the thread and can leak into future requests.

This becomes a serious issue in applications that use thread pools. When the same thread is reused for another request, leftover data from a previous request can accidentally appear in the new one. This leads to unpredictable behaviour, hard-to-find bugs, and memory leaks.

With the introduction of virtual threads in Project Loom, the limitations of ThreadLocal became even clearer. Virtual threads are lightweight and can be created in huge numbers, but copying and managing ThreadLocal data for so many threads is inefficient and slow. Also, the way ThreadLocal inherits values across threads is not always consistent or intuitive.

Because of these problems, Java needed a safer, more predictable, and efficient way to pass context across method calls and threads. That is why Scoped Values were introduced—to provide a clean, leak-free, and virtual-thread-friendly alternative to ThreadLocal.

Example: ThreadLocal Leak in a Thread Pool

In this example, we first make a request called Request A and store a value inside a ThreadLocal. Since we are using a thread pool, the thread that handles Request A does not die after the request finishes; it simply goes back into the pool and waits for new work. Normally, we would expect the stored value to disappear once Request A is done, but ThreadLocal doesn’t automatically clear its data.

Next, when Request B arrives, the thread pool reuses the same thread that previously handled Request A. Because the developer forgot to call #remove() on the ThreadLocal, the old data from Request A is still attached to the thread. As a result, Request B unexpectedly sees the leftover value meant for Request A.

This means Request B receives incorrect data, even though it should have started with a clean state. This kind of silent leak is one of the biggest risks of using ThreadLocal in thread-pool-based applications.

package com.javahandson;

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class ThreadLocalLeakExample {

    private static final ThreadLocal<String> userContext = new ThreadLocal<>();

    public static void main(String[] args) {
        ExecutorService pool = Executors.newFixedThreadPool(1);

        // Request A
        pool.submit(() -> {
            userContext.set("User-A");
            System.out.println("Request A ThreadLocal value: " + userContext.get());

            // Forgot to remove() → leak happens here
            // userContext.remove();
        });

        sleep();

        // Request B using the same thread in the pool
        pool.submit(() -> {
            System.out.println("Request B ThreadLocal value: " + userContext.get());
            // Expecting null, but gets "User-A"
        });

        pool.shutdown();
    }

    private static void sleep() {
        try { Thread.sleep(500); } catch (Exception ignored) {}
    }
}
Output:
Request A ThreadLocal value: User-A
Request B ThreadLocal value: User-A

This leak happens because ThreadLocal stores its value inside the thread itself. When the thread is returned to the pool, the data remains attached to it. Since thread pools reuse the same threads for multiple tasks, any leftover ThreadLocal value will be visible to the next task handled by that thread.

If the developer forgets to call ThreadLocal.remove(), the value effectively becomes ‘sticky’ and stays in that thread forever. When a new request is handled by that same thread, it accidentally inherits the old data, leading to incorrect behaviour, security bugs, or data mixing between users.

In modern Java applications where thread pools are heavily used, and especially in servers that handle many user requests, this creates unpredictable and dangerous scenarios. Scoped Values were designed specifically to avoid such issues by ensuring values are automatically cleared when the scope ends, with no manual cleanup required.

What Are Scoped Values?

Scoped Values are a new feature in Java that allows us to safely attach small pieces of data, such as user IDs, request IDs, or context information, to a specific block of code. They help us pass information down a call chain without cluttering our methods with extra parameters. In simple terms, they act like temporary, read-only variables that exist only inside a well-defined scope.

Ex. Without Scoped Values – we must pass parameters everywhere

package com.javahandson;

public class WithoutScopedValues {

    public static void main(String[] args) {
        handleRequest("REQ-12345");
    }

    static void handleRequest(String requestId) {
        serviceLayer(requestId);
    }

    static void serviceLayer(String requestId) {
        repositoryLayer(requestId);
    }

    static void repositoryLayer(String requestId) {
        System.out.println("Request ID: " + requestId);
    }
}
Output: Request ID: REQ-12345

Ex. With Scoped Values – no parameter passing needed

package com.javahandson;

public class WithScopedValues {

    static final ScopedValue<String> REQUEST_ID = ScopedValue.newInstance();

    public static void main(String[] args) {
        ScopedValue.where(REQUEST_ID, "REQ-12345").run(WithScopedValues::handleRequest);
    }

    static void handleRequest() {
        serviceLayer();
    }

    static void serviceLayer() {
        repositoryLayer();
    }

    static void repositoryLayer() {
        // We can access it from anywhere inside the scope
        System.out.println("Request ID: " + REQUEST_ID.get());
    }
}
Output: Request ID: REQ-12345

When we use a Scoped Value, we create a temporary scope where REQUEST_ID is available. All methods called inside that scope can read this value without receiving it as a parameter.

Unlike ThreadLocal, Scoped Values are immutable. Once we bind a value, it cannot be changed. This makes them far safer because we never have to worry about other parts of the program accidentally modifying them. Their immutability removes many of the common pitfalls we have traditionally faced with ThreadLocal.

Ex. ThreadLocal – Mutable (Can Be Changed Anytime)

package com.javahandson;

public class ThreadLocalExample {

    static final ThreadLocal<String> USER = new ThreadLocal<>();

    public static void main(String[] args) {
        USER.set("Suraj");

        System.out.println("Before: " + USER.get());

        // Any method can change it anytime
        changeUser();

        System.out.println("After: " + USER.get());
    }

    static void changeUser() {
        USER.set("Shweta"); //
    }
}
Output:
Before: Suraj
After: Shweta

In the above example, we set the value to ‘Suraj’. Another method overwrites it with ‘Shweta’. This is risky in real applications because a deep method call or a library method can accidentally change the value, causing hard-to-debug bugs and leakage

Scoped Values – Immutable (Cannot Be Changed After Binding)

package com.javahandson;

public class ScopedValueExample {

    static final ScopedValue<String> USER = ScopedValue.newInstance();

    public static void main(String[] args) {

        ScopedValue.where(USER, "Suraj").run(() -> {
            System.out.println("Before: " + USER.get());
            tryChangeUser();  // This will NOT change the value
            System.out.println("After: " + USER.get());
        });
    }

    static void tryChangeUser() {
        USER.set("Shweta");
        System.out.println("Inside tryChangeUser(): " + USER.get());
    }
}
Output:
java: cannot find symbol
  symbol:   method set(java.lang.String)
  location: variable USER of type java.lang.ScopedValue<java.lang.String>

In the above example, we bind the USER to ‘Suraj’. Inside the scope, no method can modify it. Even accidental mutation is impossible because ScopedValue provides no #set() method. The value is totally read-only. When the scope ends, the value automatically disappears

Another major advantage is their clear lifetime. Scoped Values exist only for the duration of the block where they are bound, and they automatically disappear when the scope ends. There is no need for manual cleanup or calls to #remove(). This prevents issues such as memory leaks or stale data leaking into the next request.

Scoped Values were introduced as part of Java’s Project Loom, ensuring they work naturally with virtual threads and Structured Concurrency. They give us a clean, predictable way to propagate contextual information across different layers of our application, especially when we are dealing with highly concurrent workloads.

Overall, Scoped Values provide a modern, reliable, and scalable alternative to ThreadLocal. They make it easier for us to write clean, safe, and concurrency-friendly Java applications.

How do Scoped Values work?

Scoped Values are built on a few simple but powerful ideas that make them predictable, safe, and perfect for modern Java applications. Understanding these core concepts helps us see why they fit so well with virtual threads and structured concurrency.

1. Declaration (Creating a Scoped Value) – We start by declaring a Scoped Value as a static final variable:

static final ScopedValue<String> USER_ID = ScopedValue.newInstance();

This only creates a slot for a value. It does not store anything yet.

2. Binding (Attaching a Value to a Scope) – To attach a value, we use ScopedValue.where():

ScopedValue.where(USER_ID, "U123").run(() -> {
    // code inside the scope
});

This creates a temporary scope where USER_ID is bound to ‘U123’. This value exists only inside this scope. It is read-only. It is automatically cleaned up when the scope ends

3. Access (Reading the Value Inside the Scope) – Any method called inside the scope can access the bound value without parameter passing:

static void logUser() {
    System.out.println("User: " + USER_ID.get());
}

As long as #logUser() is executed inside the scope, it will see ‘U123’.

4. Immutability (Cannot Be Changed) – Once we bind a value, we cannot modify it. There is no #set() method, which makes Scoped Values safe and deterministic.

// Impossible – no API to change the value after binding
// USER_ID.set("AnotherUser");  // does not exist

5. Nesting (Scopes Can Be Nested Safely) – We can nest scopes, and the innermost scope temporarily overrides the outer one. This makes context-passing flexible and predictable.

package com.javahandson;

public class ScopedValueExample {

    static final ScopedValue<String> USER_ID = ScopedValue.newInstance();

    public static void main(String[] args) {

        ScopedValue.where(USER_ID, "Outer").run(() -> {
            System.out.println(USER_ID.get()); // Outer

            ScopedValue.where(USER_ID, "Inner").run(() -> {
                System.out.println(USER_ID.get()); // Inner
            });

            System.out.println(USER_ID.get()); // Outer again
        });
    }
}
Output:
Outer
Inner
Outer

6. Automatic Cleanup – Scoped Values clean themselves up automatically. As soon as the scope ends, the value is gone—there’s nothing we need to remove manually. This ensures that no data accidentally carries over to the next request or thread. With ThreadLocal, we always have to remember to call #remove(), and forgetting it can cause memory leaks. Scoped Values avoids this problem completely by handling cleanup for us.

7. Works Naturally with Virtual Threads & Structured Concurrency – Each scope is tied to the logical structure of our code, not to a physical thread. So if a virtual thread is parked, unparked, or switched, the Scoped Value context remains consistent. This makes Scoped Values a perfect fit for Loom.

Scoped Values work by binding immutable, read-only data to a limited code block. Any code running inside that block can read the value, but nothing can modify it, and the value disappears automatically when the scope ends. This simple model gives us a clean, safe, and modern alternative to ThreadLocal.

Scoped Values with Virtual Threads

Virtual threads are lightweight threads introduced as part of Project Loom. They allow us to create thousands or even millions of concurrent tasks without the heavy cost of traditional platform threads. However, this new concurrency model also exposes the weaknesses of ThreadLocal, because virtual threads can be parked, unparked, and moved around, and thread pooling becomes more common. In such setups, relying on mutable thread-local state quickly becomes fragile and hard to reason about.

A. ThreadLocal with Thread Pools – Stale Data Problem

With a fixed thread pool, the same physical thread is reused for many tasks. If we use ThreadLocal and forget to clear it, the next task on the same thread inherits the previous task’s data by mistake.

Example: Context leak with ThreadLocal and a pool

package com.javahandson;

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class ThreadLocalPoolProblem {

    static final ThreadLocal<String> USER = new ThreadLocal<>();

    public static void main(String[] args) {
        ExecutorService pool = Executors.newFixedThreadPool(1); // only 1 thread

        // Task 1
        pool.submit(() -> {
            USER.set("Suraj");
            System.out.println("Task 1 sees: " + USER.get());
        });

        // Task 2
        pool.submit(() -> {
            System.out.println("Task 2 sees: " + USER.get()); // unexpected!
        });

        pool.shutdown();
    }
}
Output:
Task 1 sees: Suraj
Task 2 sees: Suraj

Context we meant only for one request leaks into another request, just because they happened to run on the same pooled thread. With more data and more threads, this becomes a nightmare to debug.

B. ThreadLocal with Virtual Threads – Performance & Complexity

Virtual threads are not tied to one physical thread.

They can:

  • start running on Thread A
  • do some blocking I/O → get parked
  • Later resume on Thread B

This ‘moving around’ is expected.

Imagine this:

Virtual Thread V1
– starts on Carrier Thread A
– stores ThreadLocal(USER = “Suraj”)
– waits for DB → gets parked
– resumes on Carrier Thread B

The JVM must now carry the ThreadLocal map from A → B so the code keeps working. This is extra memory and work for the JVM – especially when we create hundreds of thousands of virtual threads. It still works, but it becomes slow, heavy, and not scalable.

package com.javahandson;

import java.util.concurrent.Executors;

public class ThreadLocalVThreadDemo {

    static final ThreadLocal<String> DATA = new ThreadLocal<>();

    public static void main(String[] args) throws Exception {

        try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {

            executor.submit(() -> {
                System.out.println("Thread before set: " + Thread.currentThread());
                DATA.set("Value set in virtual thread");
                System.out.println("Before sleep: " + DATA.get());

                try {
                    Thread.sleep(100); // virtual thread gets parked here
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }

                System.out.println("After sleep: " + DATA.get());
                System.out.println("Thread after set: " + Thread.currentThread());
                DATA.remove();
            }).get();
        }
    }
}
Output:
Thread before set: VirtualThread[#28]/runnable@ForkJoinPool-1-worker-1
Before sleep: Value set in virtual thread
After sleep: Value set in virtual thread
Thread after set: VirtualThread[#28]/runnable@ForkJoinPool-1-worker-2

Before sleep: VirtualThread[#28]/runnable@ForkJoinPool-1-worker-1

  • Our virtual thread has id #28, and right now it is mounted on carrier platform thread ForkJoinPool-1-worker-1.

After sleep: VirtualThread[#28]/runnable@ForkJoinPool-1-worker-2

  • Same virtual thread #28, but now it’s running on a different carrier, ForkJoinPool-1-worker-2.

The carrier threads used by virtual threads belong to the JDK’s internal ForkJoinPool (or similar). That pool itself creates OS threads using low-level mechanisms, but that’s hidden from us.

So the natural doubt is: ‘If ThreadLocal works fine here, where is the problem?’

Answer – It’s not that it doesn’t work – it works, but it’s heavy and tricky when you scale or when you mix it with different executors.

Simple analogy

  • Imagine each virtual thread is a person.
  • ThreadLocal is like a backpack that a person wears (per-thread data).
  • Carrier threads (ForkJoinPool workers) are cabs that transport people.

With virtual threads:

  • A person (virtual thread) can get into cab-1, then sleep, get out, and later continue in cab-2.
  • The backpack stays with the person, not with the cab.

So ThreadLocal works fine: same virtual thread – same backpack – same data.

But what’s the cost?

Virtual threads are meant to be millions. If many of them use ThreadLocal:

  • Each virtual thread keeps a map of ThreadLocal values.
  • That’s a lot of small maps/entries in memory.
  • GC has to scan them and eventually clean them.
  • If frameworks put big objects in ThreadLocal (like security context, DB stuff, etc.), this becomes costly.

So: More virtual threads + more ThreadLocal → more memory, more GC work.

C. How Scoped Values Fix This

Scoped Values solve both issues:

No leaks with pools / virtual threads – The value lives only inside the scope passed to ScopedValue.where(…).run(…). When the scope ends, the value is guaranteed to be cleaned up.

No mutation – We cannot accidentally overwrite context deep inside the call stack.

Scope-based, not thread-based – The context follows the logical execution flow, including across virtual threads, parking/unparking, and structured concurrency.

ThreadLocal was never designed for a world where applications run thousands or even millions of lightweight virtual threads. Every virtual thread carries its own ThreadLocal map, and the JVM must preserve this map even when the virtual thread is parked and later resumed on a different carrier thread. This adds unnecessary memory usage, GC pressure, and scheduling complexity. While ThreadLocal technically works, it becomes heavy, inefficient, and difficult to manage at large scale.

ThreadLocal also has another long-standing problem: stale data leaks when used with thread pools. In a fixed thread pool, the same platform thread handles multiple requests. If a ThreadLocal is not cleared properly, data from one request can “stick” to the thread and accidentally appear in a later request. This leads to subtle bugs involving wrong user identities, incorrect tenant information, or leaked security context. These issues are common because the burden of cleanup is placed entirely on the developer.

Scoped Values solve both problems by taking a completely different approach. Instead of attaching data to the thread, they attach data to the execution scope. The value exists only while the code inside the where(…).run(…) block is executing, and it disappears automatically when the scope ends. Because the value is stored in the virtual thread’s stack, not in a per-thread map, there is no extra memory overhead, no copying during park/unpark, and no risk of stale data leaking into another request—even if the same carrier thread or thread pool thread is reused.

In simple terms, Scoped Values provide a clean, lightweight, and predictable way to pass contextual information across methods and threads. They remove the biggest weaknesses of ThreadLocal performance issues with virtual threads and data leakage with thread pools, while giving developers a safer and more modern mechanism that fits perfectly with Java’s new concurrency model.

Why are Scoped Values better than ThreadLocal?

Scoped Values solve the two biggest weaknesses of ThreadLocal and fit naturally into Java’s modern concurrency model. ThreadLocal stores data inside a thread, which sounds convenient but creates several problems in real systems. In virtual-thread environments, each virtual thread carries its own ThreadLocal map. When a virtual thread sleeps, the JVM must preserve that map and restore it when the thread resumes on a different carrier thread. This adds memory overhead, GC pressure, and unnecessary complexity. ThreadLocal works, but it becomes inefficient and harder to reason about at scale.

ThreadLocal also behaves poorly with traditional thread pools. Since platform threads are reused to process many requests, ThreadLocal values can accidentally leak from one request to another if the developer forgets a #remove(). This can mix up user IDs, tenants, or security contexts, causing subtle and dangerous bugs. The core issue is that ThreadLocal attaches data to the thread, not to the actual execution flow.

Scoped Values completely avoid these problems. Instead of storing data on the thread, they store it in a well-defined execution scope. The value exists only while code inside where(…).run(…) executes, and it is automatically cleaned up afterwards. This makes them naturally safe with thread pools and perfectly compatible with virtual threads. There is no leakage, no manual cleanup, and no extra memory structures to maintain.

Because Scoped Values provide a lightweight, predictable, and leak-free way to pass contextual information, they align far better with Java’s structured concurrency and virtual-thread design philosophy. They offer all the usefulness of ThreadLocal, but without any of the hidden pitfalls—making them the modern, safer, and more efficient choice.

Performance Benefits of Scoped Values

1. Lightweight storage – Scoped Values are stored on the execution stack, not in per-thread maps, reducing memory usage dramatically.

2. Fast park/unpark handling – Virtual threads can suspend and resume without copying ThreadLocal maps, improving scheduling performance.

3. Lower GC pressure – No long-living ThreadLocal entries or hidden object graphs that slow down garbage collection.

4. Zero cleanup overhead – Values disappear automatically when the scope ends; nothing to remove manually.

5. Predictable access costs – Lookups are fast and consistent because the JVM uses optimised stack-based access, not ThreadLocal hashing.

6. Scales effortlessly – Works smoothly even with lakhs of virtual threads doing short-lived tasks.

Limitations of Scoped Values

1. Read-only by design – Scoped Values cannot be reassigned once bound, which restricts dynamic updates.

2. Scope-dependent – Values only exist within where(…).run(…); they cannot escape or outlive the block.

3. Not suitable for caching – Scoped Values are meant for passing contextual data, not storing reusable state.

4. Requires structured design – Works best when applications follow structured concurrency patterns; chaotic threading models may reduce benefits.

5. Not a replacement for all ThreadLocal use cases – ThreadLocal is still useful when per-thread mutable state is genuinely required.

6. Limited debugging tools – IDEs and profilers are still catching up with Scoped Values visibility.

Best Practices for Using Scoped Values

1. Use Scoped Values only for contextual, immutable data like request IDs, user metadata, or feature flags.

2. Keep scopes small and meaningful to ensure values stay well-contained and easy to reason about.

3. Avoid nesting too deeply; too many nested scopes can make code harder to follow.

4. Prefer structured concurrency (StructuredTaskScope) when accessing Scoped Values across multiple subtasks.

5. Do not use Scoped Values for global static configuration; they are meant for request-scoped context.

6. Replace ThreadLocal gradually – start with areas involving virtual threads, concurrency, or request tracing.

Future of Scoped Values

The introduction of Scoped Values marks a major step toward modernising Java’s concurrency model, and their importance will only grow in future releases. As virtual threads become the default choice for high-performance applications, developers will increasingly rely on mechanisms that avoid the pitfalls of thread-bound state. Scoped Values align perfectly with this direction, they encourage immutable, predictable context sharing and integrate naturally with structured concurrency.

We can expect deeper ecosystem adoption as popular frameworks like Spring, Quarkus, Micronaut, and reactive libraries adapt their designs to support Scoped Values for request context, logging correlation, and security metadata. Tooling support will also improve, making debugging and profiling Scoped Values more intuitive. Over time, Scoped Values may even replace many long-standing ThreadLocal patterns entirely, reducing bugs and improving performance in large-scale, multithreaded applications. Their design shows that Java is moving toward clearer, safer, and more structured concurrency primitives.

Conclusion

Scoped Values bring much-needed clarity and safety to how Java applications share contextual data across methods and threads. Unlike ThreadLocal, which stores mutable state at the thread level and often leads to leaks or performance issues, Scoped Values tie data directly to the execution scope. This makes them lightweight, predictable, and perfectly suited for virtual threads. They eliminate the stale data problems associated with thread pools and remove the need for manual cleanup, while offering fast and consistent access to contextual information.

In a world where applications can spawn thousands of virtual threads effortlessly, developers need tools that encourage clean design and avoid hidden state. Scoped Values meet this need by delivering a modern, structured, and transparent approach to context passing. They simplify concurrency, reduce bugs, and improve performance, all while fitting naturally into Java’s evolving direction with structured concurrency and Project Loom. For any developer embracing modern Java, Scoped Values are not just an alternative to ThreadLocal, they are the future.

Leave a Comment