Java Scoped Values Example

Java Scoped Values Image

A scoped value looks like a normal field, but it is not one. It holds a different value for each running task, all at the same time.

Think of a box. You put one value in the box, run some code, and every method that code calls can look inside. When the code finishes, the box is empty again. The next task gets a fresh box.

It is a final feature in Java 25, added by JEP 506.

P.S. Tested with Java 25

Table of contents:

Is it just a normal variable?

No. Look at what a normal variable does when two people use your app at the same time.

SharedVariableApp.java

package com.mkyong;

public class SharedVariableApp {

    // A normal variable. One value, shared by everyone.
    static String user;

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

        Thread alice = new Thread(() -> {
            user = "alice";                                    // Alice logs in
            sleep(2000);                                       // Alice does slow work
            System.out.println("Alice's task sees: " + user);  // reads the shared value
        });

        Thread bob = new Thread(() -> {
            sleep(1000);                                       // Bob arrives 1 second later
            user = "bob";                                      // Bob logs in, overwrites Alice
            System.out.println("Bob's task sees: " + user);
        });

        alice.start();
        bob.start();

        alice.join(); // wait for both to finish
        bob.join();
    }

    // A small helper so the example stays short
    static void sleep(long ms) {
        try {
            Thread.sleep(ms);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
    }
}

Output:

Terminal

Bob's task sees: bob
Alice's task sees: bob

Alice’s task now thinks Alice is Bob. There is only one value, and the last writer wins.

Java already has a fix for this: a ThreadLocal. It looks like one field, but each thread gets its own value inside it.

ThreadLocalUserApp.java

package com.mkyong;

public class ThreadLocalUserApp {

    // One field, but each thread keeps its own value
    static final ThreadLocal<String> USER = new ThreadLocal<>();

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

        Thread alice = new Thread(() -> {
            USER.set("alice");                                        // only Alice's thread
            sleep(2000);
            System.out.println("Alice's task sees: " + USER.get());
            USER.remove();                                            // you must clean up
        });

        Thread bob = new Thread(() -> {
            sleep(1000);
            USER.set("bob");                                          // only Bob's thread
            System.out.println("Bob's task sees: " + USER.get());
            USER.remove();                                            // you must clean up
        });

        alice.start();
        bob.start();

        alice.join();
        bob.join();
    }

    static void sleep(long ms) {
        try {
            Thread.sleep(ms);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
    }
}

Output:

Terminal

Bob's task sees: bob
Alice's task sees: alice

That works. So why did Java add a new feature? Because ThreadLocal has three holes:

  • Any method your thread runs can call set and change the value under your feet.
  • You must remember remove(). Nothing reminds you.
  • If you forget, a thread pool hands your value to the next request. You will see that leak later.

Each thread has its own value, so why does `set` matter?

Because one thread runs many methods, and they all share that one value.

OverwriteApp.java

package com.mkyong;

public class OverwriteApp {

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

    public static void main(String[] args) {

        USER.set("alice");                            // you log Alice in
        System.out.println("Before: " + USER.get());

        doWork();                                     // a library, or a workmate's code

        System.out.println("After : " + USER.get());  // is Alice still here?
    }

    static void doWork() {
        // Deep inside, this code needs admin rights for a moment
        USER.set("admin");
        // ...and forgets to put "alice" back
    }
}

Output:

Terminal

Before: alice
After : admin

One thread. No other request. Your own line USER.get() returned a value you never wrote, and your code never shows where it came from. If the next line is a permission check, that is a bug you will hunt for days.

A scoped value cannot do this. Here is the same code, same doWork(), with a scoped value.

OverwriteScopedApp.java

package com.mkyong;

public class OverwriteScopedApp {

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

    public static void main(String[] args) {

        // Alice is bound for everything inside this run()
        ScopedValue.where(USER, "alice").run(() -> {

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

            doWork();                                     // the same library code

            System.out.println("After : " + USER.get());  // Alice is still here
        });
    }

    static void doWork() {
        // doWork needs admin rights, so it binds a new value...
        // ...but only for the code it calls itself
        ScopedValue.where(USER, "admin").run(() -> deleteOldFiles());
    }

    static void deleteOldFiles() {
        System.out.println("Inside doWork: " + USER.get());
    }
}

Output:

Terminal

Before: alice
Inside doWork: admin
After : alice

doWork() still got its admin value. Your value never moved. There is no set method, so the only way to change anything is to open a new box for your own callees, and that box closes when they finish.

Now go back to Alice and Bob, the two people using your app at the same time, and give them a scoped value.

OwnCopyApp.java

package com.mkyong;

public class OwnCopyApp {

    // One field, but each running task gets its own value
    private static final ScopedValue<String> USER = ScopedValue.newInstance();

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

        Thread alice = new Thread(() ->
                ScopedValue.where(USER, "alice").run(() -> {   // Alice's own value
                    sleep(2000);
                    System.out.println("Alice's task sees: " + USER.get());
                }));

        Thread bob = new Thread(() ->
                ScopedValue.where(USER, "bob").run(() -> {     // Bob's own value
                    sleep(1000);
                    System.out.println("Bob's task sees: " + USER.get());
                }));

        alice.start();
        bob.start();

        alice.join();
        bob.join();
    }

    static void sleep(long ms) {
        try {
            Thread.sleep(ms);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
    }
}

Output:

Terminal

Bob's task sees: bob
Alice's task sees: alice

Same field, same moment, two different values. Bob cannot touch Alice’s value, because USER has no set method.

Here is the whole idea in one table:

Local variable Static variable ThreadLocal Scoped value
Who can see it Only that method Everyone Everyone in the same thread Only the code your call runs
How many values at once One per call One, shared by all One per thread One per running task
Who can change it That method Any code Any code, any time Nobody, it is set once
When does it die End of the method Never When you call remove() End of run()
Must you pass it as a parameter Yes No No No

A local variable is safe but you must pass it everywhere. A static variable is easy to reach but everyone shares it. A ThreadLocal gives each thread its own value but never cleans up by itself. A scoped value gives you all three: easy to reach, private to your task, and gone when the task ends.

A short history of scoped values in Java

The idea is old. Lisp had it in the 1960s, and the JEP says Java borrowed it from there. But the Java story is short.

Release Year Step
Java 1.2 1998 ThreadLocal
Java 20 2023 Incubator API (JEP 429)
Java 21 2023 First preview (JEP 446)
Java 22 2024 Second preview (JEP 464)
Java 23 2024 Third preview (JEP 481)
Java 24 2025 Fourth preview (JEP 487)
Java 25 2025 Final (JEP 506)

For 25 years ThreadLocal was the only answer. It was good enough, because a server had maybe 200 threads in a pool.

Then virtual threads came. Now a server can run a million threads, one per request. Every thread with its own copy of every thread-local costs real memory, and child threads copy the parent’s values too. That is what pushed scoped values into the JDK.

Two API changes are worth knowing, because old blog posts still show the old code:

  • Java 24 removed the static runWhere and callWhere methods. Today you always chain where(...).run(...) or where(...).call(...).
  • Java 25 stopped letting orElse take null.

Your first scoped value

Now zoom in on the three steps: make the box, fill it, read it.

Main.java

package com.mkyong;

public class Main {

    // 1. Make an empty box. ScopedValue lives in java.lang, so no import is needed.
    private static final ScopedValue<String> USER = ScopedValue.newInstance();

    public static void main(String[] args) {

        // 2. Put "mkyong" in the box, then run sayHello()
        ScopedValue.where(USER, "mkyong")
                .run(() -> sayHello());
    }

    static void sayHello() {
        // 3. Look inside the box
        System.out.println("Hello " + USER.get());
    }
}

Output:

Terminal

Hello mkyong

You never passed the name to sayHello(). The method still found it, because it ran inside the box.

Must a scoped value be static?

No. static final is only a habit, not a rule. But it is a good habit, and here is why.

A scoped value object is the key to the box. Two objects mean two different keys, so they open two different boxes.

InstanceApp.java

package com.mkyong;

public class InstanceApp {

    // No static. Every InstanceApp object makes its own key.
    private final ScopedValue<String> user = ScopedValue.newInstance();

    public static void main(String[] args) {

        InstanceApp a = new InstanceApp();
        InstanceApp b = new InstanceApp();

        // Fill the box of object a
        ScopedValue.where(a.user, "mkyong").run(() -> {

            System.out.println("a sees: " + a.user.orElse("nothing"));

            // b holds a different key, so its box was never filled
            System.out.println("b sees: " + b.user.orElse("nothing"));
        });
    }
}

Output:

Terminal

a sees: mkyong
b sees: nothing

It runs, and nothing breaks. But now the value follows the object, not the request. Most of the time you want one key for the whole app, so use private static final.

Keep the final too. If the field is not final, other code can point it at a new key, and every get() below will fail.

Deep methods can read the box too

You will call three methods, one after another, with no parameters at all.

DeepCallApp.java

package com.mkyong;

public class DeepCallApp {

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

    public static void main(String[] args) {

        // Everything called from here can read USER
        ScopedValue.where(USER, "mkyong").run(() -> methodA());
    }

    static void methodA() {
        System.out.println("A sees: " + USER.get());
        methodB(); // no parameter passed
    }

    static void methodB() {
        System.out.println("B sees: " + USER.get());
        methodC(); // no parameter passed
    }

    static void methodC() {
        System.out.println("C sees: " + USER.get());
    }
}

Output:

Terminal

A sees: mkyong
B sees: mkyong
C sees: mkyong

The box follows the call, not the method signature. No method needs a parameter it does not use.

The box is empty outside the scope

You will check the box before, during, and after the run() call.

BoundApp.java

package com.mkyong;

import java.util.NoSuchElementException;

public class BoundApp {

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

    public static void main(String[] args) {

        // Before: nothing in the box
        System.out.println("Before -> bound? " + USER.isBound());

        // orElse gives you a backup value when the box is empty
        System.out.println("Before -> user: " + USER.orElse("guest"));

        ScopedValue.where(USER, "mkyong").run(() -> {
            System.out.println("Inside -> bound? " + USER.isBound());
            System.out.println("Inside -> user: " + USER.get());
        });

        // After: the box is empty again
        System.out.println("After -> bound? " + USER.isBound());

        try {
            USER.get(); // reading an empty box throws
        } catch (NoSuchElementException e) {
            System.out.println("After -> get() throws " + e.getClass().getSimpleName());
        }
    }
}

Output:

Terminal

Before -> bound? false
Before -> user: guest
Inside -> bound? true
Inside -> user: mkyong
After -> bound? false
After -> get() throws NoSuchElementException

The value lives only while run() is running. Nothing is left behind, so nothing leaks into the next task. Note that orElse no longer accepts null in Java 25.

Fill two boxes at once

You will bind two scoped values in one line with a chained where.

MultiValueApp.java

package com.mkyong;

public class MultiValueApp {

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

    public static void main(String[] args) {

        // Chain where() to bind two values together
        ScopedValue.where(USER, "mkyong")
                .where(TRACE_ID, "trace-001")
                .run(() -> printLog());
    }

    static void printLog() {
        // Both boxes are open here
        System.out.println("[" + TRACE_ID.get() + "] user = " + USER.get());
    }
}

Output:

Terminal

[trace-001] user = mkyong

One chained call is faster and easier to read than two nested run() calls.

Get a result back with call()

run() returns nothing. Use call() when you want an answer.

CallApp.java

package com.mkyong;

public class CallApp {

    private static final ScopedValue<Integer> DISCOUNT = ScopedValue.newInstance();

    public static void main(String[] args) {

        // call() runs the code and hands the result back to you
        int price = ScopedValue.where(DISCOUNT, 20)
                .call(() -> finalPrice(100));

        System.out.println("Final price = " + price);
    }

    static int finalPrice(int original) {
        int off = original * DISCOUNT.get() / 100; // read the box
        return original - off;                     // 100 - 20
    }
}

Output:

Terminal

Final price = 80

call() can also throw an exception from the code inside it, so you can use it for real work like a database read.

Change the value for callees only

There is no set method. But you can put a new value in the box for a nested block. This is called rebinding.

RebindApp.java

package com.mkyong;

public class RebindApp {

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

    public static void main(String[] args) {
        ScopedValue.where(ROLE, "user").run(() -> outer());
    }

    static void outer() {
        System.out.println("outer sees: " + ROLE.get());

        // A new value, but only for inner()
        ScopedValue.where(ROLE, "admin").run(() -> inner());

        // outer() itself never changed
        System.out.println("outer sees: " + ROLE.get());
    }

    static void inner() {
        System.out.println("inner sees: " + ROLE.get());
    }
}

Output:

Terminal

outer sees: user
inner sees: admin
outer sees: user

A method can change what its callees see. It can never change what it sees itself. This is the rule that saved you from the OverwriteApp bug at the start.

Share the box with child threads

Child threads started with structured concurrency inherit your scoped values for free.

ChildThreadApp.java

package com.mkyong;

import java.util.concurrent.StructuredTaskScope;

public class ChildThreadApp {

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

    public static void main(String[] args) {

        ScopedValue.where(USER, "mkyong").run(() -> {

            try (var scope = StructuredTaskScope.open()) {

                // Each fork starts a new virtual thread
                var orders = scope.fork(() -> "orders of " + USER.get());
                var profile = scope.fork(() -> "profile of " + USER.get());

                scope.join(); // wait for both child threads

                System.out.println(orders.get());
                System.out.println(profile.get());

            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
        });
    }
}

StructuredTaskScope is still a preview API in Java 25, so add the preview flag:

Terminal

javac --release 25 --enable-preview -d target src/main/java/com/mkyong/ChildThreadApp.java
java --enable-preview -cp target com.mkyong.ChildThreadApp

Output:

Terminal

orders of mkyong
profile of mkyong

The child threads read the same value with no copying. scope.join() also promises the children finish before the box closes.

Real-life use case: a web request context

Here is the story in one place. A web request arrives. It carries a user id and a trace id. Your log lines need both, but the methods in the middle do not care about them.

The problem: you pass it everywhere

ParameterApp.java

package com.mkyong;

public class ParameterApp {

    record RequestContext(String userId, String traceId) {
    }

    public static void main(String[] args) {

        // Two requests, one after another
        handleRequest(new RequestContext("user-101", "trace-abc"), "Java Book");
        handleRequest(new RequestContext("user-202", "trace-xyz"), "Coffee Mug");
    }

    static void handleRequest(RequestContext context, String item) {
        placeOrder(item, context); // pass it down
    }

    static void placeOrder(String item, RequestContext context) {
        log("Placing order for " + item, context); // pass it down again
        saveToDatabase(item, context);             // and again
    }

    static void saveToDatabase(String item, RequestContext context) {
        log("Saved " + item + " to database", context); // and again
    }

    static void log(String message, RequestContext context) {
        System.out.println("[" + context.traceId() + "][" + context.userId() + "] " + message);
    }
}

Output:

Terminal

[trace-abc][user-101] Placing order for Java Book
[trace-abc][user-101] Saved Java Book to database
[trace-xyz][user-202] Placing order for Coffee Mug
[trace-xyz][user-202] Saved Coffee Mug to database

It is correct, and it never leaks. But saveToDatabase never reads the context. It only carries it. Add one more field next month, and you edit every method in the chain.

The old fix: ThreadLocal, and the leak

Same app, same output, but the context now rides in a ThreadLocal. All those parameters are gone.

Real servers run requests on a pool, so the same thread serves many people. Watch the last task, a background job that has no request context at all.

ThreadLocalApp.java

package com.mkyong;

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

public class ThreadLocalApp {

    // The context box. No more parameters.
    private static final ThreadLocal<RequestContext> CONTEXT = new ThreadLocal<>();

    record RequestContext(String userId, String traceId) {
    }

    public static void main(String[] args) {

        // One thread, reused by every task. This is a normal thread pool.
        try (ExecutorService pool = Executors.newSingleThreadExecutor()) {

            pool.submit(() -> handleRequest(new RequestContext("user-101", "trace-abc"), "Java Book"));
            pool.submit(() -> handleRequest(new RequestContext("user-202", "trace-xyz"), "Coffee Mug"));

            // A background job on the same thread. It belongs to nobody.
            pool.submit(() -> backgroundJob());
        }
    }

    static void handleRequest(RequestContext context, String item) {
        CONTEXT.set(context); // fill the box for this thread
        placeOrder(item);

        // THE LEAK IS HERE.
        // The request is finished, but the value is still glued to the thread.
        // The missing line is CONTEXT.remove(), and nothing forces you to write it.
    }

    static void backgroundJob() {
        log("Cleaning up old carts"); // never set anything
    }

    // No context parameter from here down
    static void placeOrder(String item) {
        log("Placing order for " + item);
        saveToDatabase(item);
    }

    static void saveToDatabase(String item) {
        log("Saved " + item + " to database");
    }

    static void log(String message) {
        RequestContext ctx = CONTEXT.get();
        System.out.println("[" + ctx.traceId() + "][" + ctx.userId() + "] " + message);
    }
}

Output:

Terminal

[trace-abc][user-101] Placing order for Java Book
[trace-abc][user-101] Saved Java Book to database
[trace-xyz][user-202] Placing order for Coffee Mug
[trace-xyz][user-202] Saved Coffee Mug to database
[trace-xyz][user-202] Cleaning up old carts

Look at the last line. A background job just wrote a log line wearing a real customer’s name, because user-202 was still sitting in the thread. Swap that log line for a permission check, and you are showing one customer another customer’s data.

That missing remove() also leaks memory. The RequestContext object stays reachable from the thread, so the garbage collector cannot free it. A pool thread lives as long as your app, so the object does too. With 200 pool threads you hold 200 dead requests in memory forever, and a fat context makes it worse.

The fix is to call CONTEXT.remove() in a finally block, so it runs even when the request throws. Everybody knows this. Everybody forgets it, and no compiler warning tells you.

Note the parameter version could not have this bug at all. backgroundJob would have no context to pass, and the compiler would stop you.

The new fix: a scoped value

Same app again, same thread pool, same background job.

OrderApp.java

package com.mkyong;

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

public class OrderApp {

    // Private box. Only this class can fill it or read it.
    private static final ScopedValue<RequestContext> CONTEXT = ScopedValue.newInstance();

    record RequestContext(String userId, String traceId) {
    }

    public static void main(String[] args) {

        try (ExecutorService pool = Executors.newSingleThreadExecutor()) {

            pool.submit(() -> handleRequest(new RequestContext("user-101", "trace-abc"), "Java Book"));
            pool.submit(() -> handleRequest(new RequestContext("user-202", "trace-xyz"), "Coffee Mug"));

            // The same background job on the same thread
            pool.submit(() -> backgroundJob());
        }
    }

    // Fill the box, run the request, and the box closes by itself
    static void handleRequest(RequestContext context, String item) {
        ScopedValue.where(CONTEXT, context).run(() -> placeOrder(item));
    }

    static void backgroundJob() {
        try {
            log("Cleaning up old carts"); // the box is empty here
        } catch (NoSuchElementException e) {
            System.out.println("Background job -> " + e.getClass().getSimpleName() + ", nothing leaked");
        }
    }

    // No context parameter from here down
    static void placeOrder(String item) {
        log("Placing order for " + item);
        saveToDatabase(item);
    }

    static void saveToDatabase(String item) {
        log("Saved " + item + " to database");
    }

    static void log(String message) {
        RequestContext ctx = CONTEXT.get();
        System.out.println("[" + ctx.traceId() + "][" + ctx.userId() + "] " + message);
    }
}

Output:

Terminal

[trace-abc][user-101] Placing order for Java Book
[trace-abc][user-101] Saved Java Book to database
[trace-xyz][user-202] Placing order for Coffee Mug
[trace-xyz][user-202] Saved Coffee Mug to database
Background job -> NoSuchElementException, nothing leaked

The first four lines match the parameter version exactly. The background job gets an empty box and fails at once, in the open, instead of quietly borrowing a customer.

You wrote no cleanup code. When run() returns, the binding is destroyed, so the next task on that thread sees nothing and the RequestContext object becomes garbage right away.

Why this matters

Parameters ThreadLocal Scoped value
Methods that carry data they never use Many None None
Can the value reach the next task No Yes, if you forget remove() No
Can the object stay in memory No Yes, for the life of the thread No
Can far away code overwrite it No Yes No
Cleanup code you must write None remove() in a finally None
Cost with a million virtual threads None High, values are copied Low, nothing is copied

The parameter version is safe but noisy. The ThreadLocal version is quiet but risky. The scoped value version is quiet and safe, and it tells you when something is wrong instead of guessing.

Other jobs that fit the same shape: the logged in user for a permission check, a database transaction, a tenant id in a multi tenant app, and a drawing context in a graphics app.

When should you still use ThreadLocal?

ThreadLocal is not deprecated. Dropping it was never a goal of the JEP. Move to a scoped value when the job is one way sharing of data that does not change. Three jobs are not that.

Data that must travel back up. A scoped value only flows down, from a caller to its callees. If a method deep in the stack calls set to hand something back to a faraway caller, a scoped value cannot replace it.

Caching objects that are expensive to create and use. That is the JEP’s own example. A SimpleDateFormat is slow to build and is mutable, so threads cannot share one without locking. Giving each thread its own copy, for the whole life of the thread, was the practical answer. Today you would use a DateTimeFormatter instead, which is safe to share from a static final field, so even this case is fading.

Unstructured use. If the value is written and read in no clear order, there is no scope to bind it to. Fix the design first, then move it.

Everything else, especially request context and virtual threads, is a better fit for a scoped value.

Common mistakes

Calling get() after run() ends. The box is already empty, so you get a NoSuchElementException. Use isBound() or orElse() when you are not sure.

Expecting a plain thread to inherit the value. A thread you start yourself with Thread.ofVirtual().start(...) or an ExecutorService does not see the box. Only StructuredTaskScope children do.

Looking for a set method. There is none, and that is the point. Rebind with a nested where(...).run(...) instead.

Calling newInstance() more than once for the same data. Each call makes a brand new key to a brand new box. Make it once, in a static final field.

Putting a mutable object in the box. The box cannot be changed, but the object inside still can. Use a record so the data stays fixed.

Conclusion

A scoped value is a one-way, read-only, self-cleaning box. You bind it with where, run your code with run or call, and read it anywhere below with get.

It fixes two old problems at once: the parameter you carry through ten methods, and the ThreadLocal you forgot to remove.

References

P.S This article is tested with Java 25.

No comments yet. Be the first to leave a comment!

Leave a Comment

Your email address will not be published. Required fields are marked *