CodeGym /Courses /JAVA 25 SELF /Scoped Values and new threading mechanics (Java 21+)

Scoped Values and new threading mechanics (Java 21+)

JAVA 25 SELF
Level 57 , Lesson 4
Available

1. Why ThreadLocal is losing relevance

What is ThreadLocal for, anyway?

In classic multithreading, where threads live a long time (for example, on a server), you sometimes need to store per-thread data that must not overlap with others. For example, a user name, a request ID, or a temporary buffer.

To do this, Java introduced ThreadLocal<T>—a kind of “personal space” for a thread where you can store data without interfering with neighbours:

ThreadLocal<String> user = new ThreadLocal<>();

user.set("Alice"); // value is stored only for this thread
String name = user.get(); // will return "Alice" here; in other threads - null

Why ThreadLocal doesn't play well with virtual threads

Virtual threads live very differently from old “heavyweight” threads. They appear and disappear by the thousands—sometimes in fractions of a millisecond. And ThreadLocal ties data to a specific thread as if it were going to live forever.

When a virtual thread finishes, its data in ThreadLocal can remain hanging in memory—even if the thread itself has long since died. This leads to leaks, because the JVM does not always know that these values are no longer needed by anyone.

And if threads are reused (for example, in pools), an even more unpleasant situation is possible: a “foreign” context can accidentally leak into a new request. Imagine user Alice receiving Bob’s data—hello, bugs and vulnerabilities.

ThreadLocal works great where there are few threads and they live long. But with virtual threads, it’s like trying to store things in a wardrobe that disappears every second.

2. Scoped Values: a new way to pass context

Scoped Values is a fresh tool from Java 21 that solves the old ThreadLocal problem, but does so elegantly. Instead of storing data inside a thread like ThreadLocal, it “attaches” them to an execution scope—that is, to a specific section of code. The value lives only while that section runs and then automatically disappears, leaving no traces in memory.

import java.lang.ScopedValue;

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

ScopedValue.where(USER, "Alice").run(() -> {
    System.out.println("Hello, " + USER.get()); // Prints: Hello, Alice
});

When the code exits the run block, the value is no longer available—an attempt to access it will throw an exception. You don’t need to clean up anything manually.

Scoped Values do not pollute memory, do not confuse context between threads, and allow you to create nested scopes where inner values temporarily shadow outer ones. It’s a neat, predictable, and safe way to pass context, especially in the world of virtual threads.

3. Examples of using Scoped Values

Example 1: Passing user context

Suppose we have a server that handles requests from different users. For each request, we want to know who initiated it.

import java.lang.ScopedValue;

public class ServerExample {
    static final ScopedValue<String> USER = ScopedValue.newInstance();

    public static void main(String[] args) {
        processRequest("Alice");
        processRequest("Bob");
    }

    static void processRequest(String userName) {
        ScopedValue.where(USER, userName).run(() -> {
            handleBusinessLogic();
        });
    }

    static void handleBusinessLogic() {
        System.out.println("Processing for user: " + USER.get());
    }
}

What will happen:

  • A dedicated scope is created for each request in which USER equals “Alice” or “Bob”.
  • Inside handleBusinessLogic() we always get the correct user name.
  • As soon as the request processing is finished, the value disappears.

Example 2: Context-aware logging

Suppose we want to automatically inject the request identifier into logs:

import java.lang.ScopedValue;

public class LoggingExample {
    static final ScopedValue<String> REQUEST_ID = ScopedValue.newInstance();

    public static void main(String[] args) {
        for (int i = 1; i <= 3; i++) {
            String reqId = "REQ-" + i;
            ScopedValue.where(REQUEST_ID, reqId).run(() -> {
                log("Start processing");
                doWork();
                log("End processing");
            });
        }
    }

    static void log(String message) {
        System.out.printf("[%s] %s%n", REQUEST_ID.get(), message);
    }

    static void doWork() {
        log("Working...");
    }
}

Output (example):

[REQ-1] Start processing
[REQ-1] Working...
[REQ-1] End processing
[REQ-2] Start processing
[REQ-2] Working...
[REQ-2] End processing
[REQ-3] Start processing
[REQ-3] Working...
[REQ-3] End processing

Each scope stores its own request identifier, and mix-ups between threads are impossible.

4. Scoped Values and virtual threads: a perfect match

Why Scoped Values are especially useful with virtual threads

Virtual threads don’t live long—they’re created and destroyed by the thousands, sometimes in fractions of a second. Therefore, the old approach with ThreadLocal, where data are tightly “tied” to the thread itself, simply doesn’t work here: threads disappear too quickly, and the context can accidentally leak or get mixed up.

ScopedValue, on the contrary, ties data to the task itself—to its execution scope. This means the context (for example, a user name or a request ID) follows the code, not the thread. When the task ends, the value automatically disappears. For virtual threads, this is the ideal solution: safe, clean, and without surprises.

Example: Bulk processing of tasks with virtual threads

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

public class VirtualThreadScopedValueDemo {
    static final ScopedValue<Integer> TASK_ID = ScopedValue.newInstance();

    public static void main(String[] args) {
        ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();

        for (int i = 1; i <= 10_000; i++) {
            int taskId = i;
            executor.submit(() -> ScopedValue.where(TASK_ID, taskId).run(() -> {
                processTask();
            }));
        }

        executor.shutdown();
    }

    static void processTask() {
        // Each task has its own TASK_ID
        System.out.println("Processing task #" + TASK_ID.get());
    }
}

Key points:

  • A dedicated scope is created for each task’s TASK_ID value.
  • Even if tasks run in parallel, values don’t get mixed up across threads.
  • No memory leaks: the scope “dies” together with the task.

5. Comparison: ThreadLocal vs ScopedValue

Criterion ThreadLocal ScopedValue
Binding To the thread To the code scope (scope)
Life cycle While the thread lives While the scope is executing
Safety Risk of leaks and mix-ups No leaks, no mix-ups
Virtual threads Inefficient, risky Ideal fit
Usage
set/get
where(...).run(...), get
Nesting Does not support overriding Values can be overridden

6. Nested scopes: overriding values

ScopedValue<String> INFO = ScopedValue.newInstance();

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

Result:

Outer
Inner
Outer

This is convenient when, for example, you need to temporarily override a context value inside a single task.

Scoped Values: common use cases

  • Passing a user or request identifier: to log actions or check permissions.
  • Logging: automatic injection of context into logs.
  • Tracing: for debugging and profiling.
  • Transaction parameters: e.g., isolation level or operating mode.
  • Any “context” visible only within a single task (or its subtasks).

7. Other new mechanics: Structured Concurrency

Structured Concurrency is an approach where related tasks (for example, sub-processes of one operation) are managed as a single whole: if the parent task finishes or fails, all child tasks are automatically cancelled. This reduces the risk of “forgotten” or “hanging” threads.

Example (high-level):

try (var scope = StructuredTaskScope.ShutdownOnFailure()) {
    Future<String> result1 = scope.fork(() -> fetchData1());
    Future<String> result2 = scope.fork(() -> fetchData2());

    scope.join(); // wait for both to finish
    scope.throwIfFailed(); // if any failed - throw an exception

    String combined = result1.resultNow() + result2.resultNow();
    System.out.println(combined);
}

Advantages:

  • Cleaner lifecycle management of tasks.
  • No “hanging” sub-processes.
  • Easier error handling.

Structured Concurrency is still in preview mode, but it is already evolving actively.

8. Practical tips and limitations

When to use Scoped Values?

  • Whenever you need to pass context between tasks, especially with virtual threads.
  • If you previously used ThreadLocal, consider switching to ScopedValue.

When do you still need ThreadLocal?

  • In rare cases where a thread lives for a very long time and the context must be “permanent” for its entire lifetime (for example, when working with legacy code).

Limitations

  • Scoped Values cannot be modified after the scope is created—they are read-only.
  • Scoped Values cannot be used outside a scope: attempting to get the value outside the scope will throw an exception.
  • Do not use Scoped Values to store large objects—the scope should be lightweight and fast.

9. Common mistakes when using Scoped Values

Error #1: trying to get a value outside the scope. If you call USER.get() outside the ScopedValue.where(...) block, you will get a NoSuchElementException. Make sure you access it only inside the scope.

Error #2: trying to change a value inside the scope. Scoped Values are an immutable container. If you need to temporarily “override” a value, create a nested scope.

Error #3: using ThreadLocal and ScopedValue together. Do not mix these mechanisms unless absolutely necessary—it can lead to confusion and context bugs.

Error #4: forgetting to wrap the logic in a run() block. If you wrote ScopedValue.where(USER, "Alice") without .run(() -> { ... }), no scope will be created!

Error #5: attempting to use a Scoped Value for long-lived global information. For such purposes, it’s better to use regular variables or ThreadLocal (when justified).

1
Task
JAVA 25 SELF, level 57, lesson 4
Locked
Data confidentiality in a corporate network 🔒
Data confidentiality in a corporate network 🔒
1
Task
JAVA 25 SELF, level 57, lesson 4
Locked
Order Tracking in a Drone Delivery Service 📦
Order Tracking in a Drone Delivery Service 📦
1
Survey/quiz
Virtual Threads, level 57, lesson 4
Unavailable
Virtual Threads
Virtual Threads
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION