JDK JDK 学习手册8 → 27 · JDK 专用学习站
DEEPDIVE · CONCURRENCY · JEP 506

Scoped Values 内部:绑定快照、作用域与传递边界

JDK 25 · 深钻主题 01 / 03 · 预计 50 分钟
深入四步深钻JEP 506
一句话ScopedValue 把上下文从「每个线程一份可变副本」改成「一段执行路径上的只读绑定」:进入作用域安装,退出恢复,结构化 fork 时复制快照引用。理解这条链,才能判断什么时候它能替代 ThreadLocal,什么时候不能。
本质:不是把值复制给每个线程,而是把「键 → 值」的不可变绑定挂在当前执行路径上;作用域结束,绑定自然恢复。

学完你要能自己回答

  1. ThreadLocal 的三个设计缺陷是什么?虚拟线程为什么把它们放大?
  2. ScopedValue 的绑定在嵌套、异常、结构化 fork、手工线程下分别如何变化?
  3. Carrier 链与 run/call 的安装、恢复过程是怎样的?为什么不需要 remove?
  4. JDK 25 的 ScopedValue API 与 21 预览版有哪些不兼容变化?
  5. 哪些场景仍然应该使用 ThreadLocal,而不是 ScopedValue?
01 ThreadLocal 的三个缺陷,在虚拟线程上被放大02 动态作用域:绑定跟着括号走,不跟着线程走

ThreadLocal 的三个缺陷,在虚拟线程上被放大01

ThreadLocal 是「每个线程一份可变副本」。平台线程数量有限时,这点内存不算什么;虚拟线程可以轻松创建几十万个,每个线程的 ThreadLocalMap 都会放大开销。更麻烦的是,任何被调用的代码都能 set,忘记 remove 会把上一次请求的值带到下一次。

private static final ThreadLocal<User> CURRENT = new ThreadLocal<>();

void handle(Request req) {
    CURRENT.set(req.user());
    try {
        service.handle(req);
    } finally {
        CURRENT.remove(); // 少写一次就泄漏
    }
}

JEP 506 把问题归纳为三点:无约束的可变性、没有边界的生命周期、昂贵的继承。前两点让数据流难以推理,第三点在虚拟线程时代直接变成内存成本。

缺陷表现虚拟线程下为何更贵
无约束可变任何 callee 都能 set,难以判断谁改了值问题不在内存,而在并发推理
生命周期无界依赖 finally remove;线程池复用会串请求线程数量大,泄漏持续更久
继承昂贵InheritableThreadLocal 在创建线程时复制所有条目大量短命虚拟线程会放大复制成本
每线程存储每个线程一份 ThreadLocalMap线程越多,地图总量越大

ScopedValue 不是把 ThreadLocal 换了个名字,而是换模型:值只在一个明确的动态作用域内可见,写入只发生在 where,读取发生在作用域内,退出时自动恢复。没有 set,也没有 remove。

JEP 506 明确不要求迁移掉所有 ThreadLocal,也不废弃 ThreadLocal。可变的 per-thread 状态(计数器、缓冲区、某些框架的 MDC)仍然需要 ThreadLocal 或显式对象。

动态作用域:绑定跟着括号走,不跟着线程走02

ScopedValue.where(key, value).run(...) 在进入 lambda 前安装绑定,在退出时恢复原状。作用域内可以读,不能改;嵌套的 where 会遮蔽外层值,退出内层后外层值恢复。下面的输出来自本机 JDK 25。

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

    static void show(String label) {
        System.out.println(label + " -> " +
                (USER.isBound() ? USER.get() : "<unbound>"));
    }

    public static void main(String[] args) throws Exception {
        show("before");

        ScopedValue.where(USER, "alice").run(() -> {
            show("inside");
            ScopedValue.where(USER, "bob").run(() -> show("nested"));
            show("restored");

            Thread child = Thread.ofVirtual().start(() -> show("child virtual"));
            try {
                child.join();
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
        });

        show("after");

        try {
            USER.orElse(null);
        } catch (Exception e) {
            System.out.println("orElse(null) -> " + e.getClass().getSimpleName());
        }

        String result = ScopedValue.where(USER, "carol")
                .call(() -> "call returns " + USER.get());
        System.out.println(result);
    }
}
javac --release 25 ScopedValueDemo.java
java ScopedValueDemo
before -> <unbound>
inside -> alice
nested -> bob
restored -> alice
child virtual -> <unbound>
after -> <unbound>
orElse(null) -> NullPointerException
call returns carol

输出验证了四件事:嵌套绑定会遮蔽外层;退出内层恢复外层;手工启动的虚拟线程看不到父作用域的绑定;orElse(null) 在 25 直接抛 NPE,而不是返回 null。

执行位置能读到绑定吗原因
当前线程的作用域内能绑定已安装
嵌套 where 内能,内层遮蔽外层更短的 Carrier 链优先
StructuredTaskScope.fork能fork 时继承当前绑定快照
Thread.ofVirtual().start不能新线程从空绑定开始
ExecutorService.submit不能复用线程,不保证继承
作用域结束后不能绑定已恢复
「绑定跟着作用域走」是理解 ScopedValue 的核心。lambda 捕获的是变量引用,但 USER.get() 读的是当前线程当前的绑定,不是闭包里的值。
01 API 形状:Carrier 链与 CallableOp02 安装、恢复与 fork:一条不可变快照链

API 形状:Carrier 链与 CallableOp01

JDK 25 的 ScopedValue 是 java.lang 下的 final 类,没有公开构造器;键通过 newInstance() 创建。where 返回一个 Carrier,Carrier 可以继续叠加绑定,最后用 run 或 call 进入作用域。

public final class ScopedValue<T> {
    public static <T> ScopedValue<T> newInstance();
    public T get();                       // 未绑定抛 NoSuchElementException
    public boolean isBound();
    public T orElse(T other);
    public <X extends Throwable> T orElseThrow(Supplier<? extends X> ex);

    public static <T> Carrier where(ScopedValue<T>, T);
}

public final class ScopedValue.Carrier {
    public <T> Carrier where(ScopedValue<T>, T);
    public <T> T get(ScopedValue<T>);
    public <R, X extends Throwable> R call(
            ScopedValue.CallableOp<? extends R, X>) throws X;
    public void run(Runnable);
}

Carrier.get(key) 可以在不进入 run/call 的情况下读取临时绑定;它不会让绑定泄漏到当前线程。下面的输出来自本机验证:读取 dave 之后,USER 仍然是未绑定状态。

System.out.println("carrier.get=" +
        ScopedValue.where(USER, "dave").get(USER));
System.out.println("bound after carrier=" + USER.isBound());
carrier.get=dave
bound after carrier=false
API / 行为JDK 21JDK 25
where(...).run/call预览正式
runWhere / callWhere / getWhere存在已移除
Carrier.call 的参数CallableCallableOp,允许受检异常
orElse(null)允许抛 NullPointerException
--enable-preview编译/运行都需要不需要

CallableOp 是函数式接口,lambda 源码通常不用改;但方法签名从 Callable 变成了可抛受检异常的 CallableOp,依赖精确签名的反射或方法引用需要重新编译。ScopedValue 本身没有静态 call 方法,返回值只能通过 Carrier.call。

21 的预览 class 不能直接拿到 25 上运行;即使源码只差一个方法名,也要用 25 重新编译。预览 API 的签名不承诺二进制兼容。

安装、恢复与 fork:一条不可变快照链02

可以把当前线程的 ScopedValue 绑定理解成一个不可变快照链:每次 where 生成新的 Carrier 节点,记录键、值和前一个节点;进入 run/call 时安装,退出时在 finally 中恢复旧快照。因为快照只读,嵌套、恢复和跨线程传递都不需要加锁。

  1. where(USER, "alice") 创建一个 Carrier,记录键值对和前一个 Carrier。
  2. run 在当前线程安装这个 Carrier 对应的绑定快照。
  3. lambda 内 get() 在当前快照中查找;嵌套 where 安装更短的链,优先命中内层。
  4. lambda 正常返回或抛异常,run 都会恢复进入前的快照。
  5. StructuredTaskScope.fork 在创建子任务时捕获当前快照,子任务从该快照开始执行。
  6. 手工 Thread.ofVirtual().start / ExecutorService.submit 没有这个捕获步骤,新线程从空绑定开始。
ScopedValue.where(USER, "alice").run(() -> {
    try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
        var future = executor.submit(
                () -> USER.isBound() ? USER.get() : "<unbound>");
        try {
            System.out.println("executor=" + future.get());
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
    }
});
executor=<unbound>
操作发生什么代价
where创建 Carrier 节点,不修改线程一次小对象分配
run/call安装快照,执行,恢复一次保存/恢复
嵌套 where叠加一层,内层优先与嵌套深度相关
结构化 fork复制当前快照引用很轻
手工线程 / 线程池不复制快照新线程无绑定

这条模型解释了两个常见误解:第一,ScopedValue 并不为每个线程复制一份值,值对象本身可以共享,复制的是绑定快照的引用;第二,作用域结束不是「清空一个线程局部变量」,而是恢复进入前的状态。没有 remove,因为不存在需要手动清理的线程局部槽位。

不要把可变的大对象绑进 ScopedValue 当作缓存。绑定不可变只约束引用,不约束对象内部状态;共享可变对象仍然需要同步。
01 模式:在请求边界绑定上下文02 可运行示例:结构化子任务继承绑定03 常见坑

模式:在请求边界绑定上下文01

典型用法是在 HTTP / RPC 入口完成认证后绑定用户、租户和 traceId,领域代码只读,不再层层传参。绑定的生命周期就是一次请求。

static final ScopedValue<User> CURRENT_USER = ScopedValue.newInstance();
static final ScopedValue<String> TRACE_ID = ScopedValue.newInstance();

Response handle(Request req) throws Exception {
    User user = authenticate(req);
    return ScopedValue.where(CURRENT_USER, user)
            .where(TRACE_ID, req.traceId())
            .call(() -> service.handle(req));
}

// 深层服务代码
User user() {
    return CURRENT_USER.orElseThrow(() -> new UnauthorizedException());
}
  • 在入口绑定,在入口结束;不要在领域方法里随手 where,否则作用域会碎片化。
  • 绑定值用不可变对象;不要绑定可变实体,然后在作用域内修改它。
  • 需要默认值的读取用 orElse,需要强制认证的读取用 orElseThrow。
  • 异步任务如果必须携带上下文,用 StructuredTaskScope;不要把手工线程当成自动继承。
  • 日志 MDC 仍可能是 ThreadLocal;需要 traceId 时在绑定作用域时同步设置/清理,或使用支持 ScopedValue 的适配层。
ScopedValue 的键是对象身份,不是字符串 key。用 static final 持有键,避免把用户输入拼成键名。

可运行示例:结构化子任务继承绑定02

如果任务必须看到上下文,把它放进 StructuredTaskScope,而不是手工创建线程或提交到共享线程池。下面的例子同时展示继承与不继承两条路径。

import java.util.concurrent.*;

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

    static String current() {
        return USER.isBound() ? USER.get() : "<unbound>";
    }

    public static void main(String[] args) throws Exception {
        ScopedValue.where(USER, "alice").run(() -> {
            try (var scope = StructuredTaskScope.open(
                    StructuredTaskScope.Joiner.<String>allSuccessfulOrThrow())) {
                var inherited = scope.fork(ScopedStructuredDemo::current);
                scope.join().forEach(
                        s -> System.out.println("structured subtask=" + s.get()));
            } catch (Exception e) {
                throw new RuntimeException(e);
            }

            try {
                Thread manual = Thread.ofVirtual().start(
                        () -> System.out.println(
                                "manual virtual thread=" + current()));
                manual.join();
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
        });

        String outside = ScopedValue.where(USER, "bob").call(
                () -> "call returns " + USER.get());
        System.out.println(outside);
    }
}
javac --release 25 --enable-preview -d out ScopedStructuredDemo.java
java --enable-preview -cp out ScopedStructuredDemo
structured subtask=alice
manual virtual thread=<unbound>
call returns bob

同一段代码里,结构化 fork 读到了 alice;手工虚拟线程读到 <unbound>;作用域外 where(USER, "bob").call(...) 正常返回值。继承语义由 fork 完成,不是由「虚拟线程」这个类型自动赋予。

常见坑03

  • 以为手工线程会继承:Thread.ofVirtual().start、ExecutorService.submit 都不会自动继承;只有结构化 fork 会。
  • 绑定可变对象:ScopedValue 只保证绑定不可变,不保证对象内部状态不可变。
  • 在领域逻辑里到处 where:作用域应当由请求/任务边界统一管理,否则很难推理生命周期。
  • 依赖 JDK 21 的预览便捷方法:runWhere/callWhere/getWhere 在 25 已移除。
  • orElse(null) 当默认值:25 会抛 NullPointerException;用真实默认值或 orElseThrow。
  • 忘记 MDC:日志框架的 MDC 仍是 ThreadLocal,ScopedValue 不会自动同步。
  • 把 ScopedValue 当线程池使用:它只传递上下文,不创建、不调度任务。
迁移时先问「这个值会变吗」。会变的状态继续留在 ThreadLocal 或显式对象里;只读的请求上下文才适合 ScopedValue。
01 合上页面,先回答02 版本与边界一页表

合上页面,先回答01

先猜再看ScopedValue 的值是每个线程复制一份吗?
不是。绑定按线程/作用域存在,结构化的 fork 继承的是当前绑定快照的引用;值对象本身可以共享。手工线程和线程池不会复制,也不会继承。
先猜再看作用域结束后,绑定对象会被「清空」吗?
不是清空一个线程局部槽位,而是恢复到进入作用域前的快照。最外层结束后就是未绑定;绑定对象是否被回收,取决于还有没有其他引用。
01. 为什么 ScopedValue 适合虚拟线程?
它不需要每个线程维护一份可变的 ThreadLocalMap,也不依赖手工 remove;绑定生命周期由词法作用域限定,结构化子任务按 fork 继承。线程数量大时,省掉的是每线程副本和清理责任。
02. 手工启动的虚拟线程为什么读不到父作用域的绑定?
绑定传播不是线程创建的自带行为,而是 StructuredTaskScope 在 fork 时捕获快照。手工线程没有这个捕获步骤,从空绑定开始。
03. JDK 25 对 orElse(null) 做了什么改变?
25 起 orElse 不再接受 null,传 null 会抛 NullPointerException。需要默认值时传真实对象,需要失败时用 orElseThrow。
04. 哪些场景仍应使用 ThreadLocal?
可变的 per-thread 状态,例如计数器、可复用缓冲区、某些框架的 MDC,以及必须在线程生命周期内反复 set 的 API。ScopedValue 只适合只读上下文。

版本与边界一页表02

事项JDK 25 状态要记住的边界
ScopedValue正式(JEP 506)不再需要 --enable-preview
runWhere / callWhere / getWhere已移除改用 where(...).run/call
Carrier.callCallableOp允许受检异常,lambda 源码基本兼容
orElse(null)抛 NPE用真实默认值或 orElseThrow
结构化 fork 继承正式语义只有 fork 继承,手工线程不继承
JDK 21 预览 class不能运行65.65535 与 69.65535 不兼容,必须重编译

如果只能记一句话:ScopedValue 约束的是可见范围,不是线程身份。它把「谁知道这个值」变成代码结构问题,而不是线程生命周期问题。

ScopedValue 与结构化并发经常一起出现,但状态不同:ScopedValue 在 25 正式,结构化并发仍是第五次预览。不要把两者的版本边界混在一起。
学习进度 记录保存在本机浏览器 · 不上传

JDK 学习手册 · JDK / Java 专用学习站 · 纯静态 · 双击 index.html 即可使用

本站专注 JDK 版本演进与 Java 教学,非 Oracle / OpenJDK 官方站点。版本数据来自 OpenJDK 官方 Features 表;课程示例已在对应本机 JDK 实测。构建日期 2026-11-12。