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

Scoped Values:有作用域的不可变上下文

JDK 21 · 深钻主题 06 / 08 · 预计 50 分钟
进阶四步深钻JEP 446JEP 506需要 --enable-preview
一句话ScopedValue 用「在某个作用域内绑定一个不可变值」替代 ThreadLocal 的可变副本:作用域结束自动失效,不需要 remove,也不会因为虚拟线程数量巨大而产生大量副本。JDK 21 是预览,JDK 25 转正。
本质:不是「每个线程一份值」,而是「每段执行路径一份只读绑定」。绑定跟着作用域走,不跟着线程走;作用域结束时,绑定自然消失。

学完你要能自己回答

  1. ThreadLocal 在虚拟线程时代为什么变贵?ScopedValue 解决了哪一部分问题?
  2. ScopedValue 的绑定为什么不可变?嵌套 where 与作用域结束后分别是什么语义?
  3. StructuredTaskScope 的子任务会继承绑定吗?手工启动的虚拟线程呢?
  4. ScopedValue 能完全替代 ThreadLocal 吗?哪些场景仍然需要 ThreadLocal?
  5. JDK 21 与 JDK 25 的 ScopedValue API 有哪些边界差异?
01 ThreadLocal 的老问题,在虚拟线程上被放大02 ScopedValue 是带作用域的只读绑定03 谁继承绑定:结构化子任务会,手工线程不会

ThreadLocal 的老问题,在虚拟线程上被放大01

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

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

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

虚拟线程的挂起与恢复由 JVM 管理,ThreadLocal 的值会跟着虚拟线程走。它并没有「错」,只是把「每个线程一份可变状态」的成本和清理责任放大了。

维度ThreadLocalScopedValue
可变性可 set / remove,随时可变绑定后不可变
生命周期跟随线程,需要手动清理跟随作用域,结束即失效
继承InheritableThreadLocal 在创建线程时复制结构化子任务 fork 时继承绑定
虚拟线程开销每个线程一份副本共享不可变值,按作用域绑定
忘记清理可能泄漏或串请求不存在 remove,作用域结束自动恢复
JDK 21 状态正式预览
ScopedValue 解决的是「上下文传递」问题,不是「每线程可变状态」问题。计数器、缓存、可变的 per-thread buffer 仍然应该用 ThreadLocal 或显式状态。

ScopedValue 是带作用域的只读绑定02

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

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

void handle() {
    System.out.println(USER.orElse("guest")); // guest

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

        ScopedValue.where(USER, "bob").run(() ->
                System.out.println(USER.get())); // bob

        System.out.println(USER.get()); // alice —— 内层退出后恢复
    });

    System.out.println(USER.isBound()); // false
}
  • get() 在未绑定时抛 NoSuchElementException;需要默认值用 orElse,需要抛业务异常用 orElseThrow。
  • where(...).call(Callable) 可以返回值,并允许受检异常;run(Runnable) 用于无返回值场景。
  • 键通常声明为 static final 字段;不要按请求动态创建 ScopedValue 键。
  • 绑定的值最好是不可变对象(record、String、不可变集合),否则「不可变」只约束了引用。
把 ScopedValue 想成「只读的、带括号的上下文参数」:括号内可见,括号外消失;嵌套括号可以遮蔽,但不能修改外层绑定。

谁继承绑定:结构化子任务会,手工线程不会03

这是 ScopedValue 最重要的行为边界:StructuredTaskScope.fork 创建的子任务会继承当前绑定;但手工 Thread.ofVirtual().start(...) 创建的线程不会。原因不是「虚拟线程不继承」,而是绑定传播由结构化作用域在 fork 时完成。

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

ScopedValue.where(USER, "alice").run(() -> {
    try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
        var subtask = scope.fork(() -> USER.get());
        scope.join().throwIfFailed();
        System.out.println("structured=" + subtask.get()); // alice
    }

    Thread manual = Thread.ofVirtual().start(
            () -> System.out.println("manual=" + USER.orElse("<unbound>")));
    manual.join(); // manual=<unbound>
});
structured=alice
manual=<unbound>
执行位置能读到绑定吗说明
当前线程的作用域内能直接 get / orElse
嵌套 where 内能,内层遮蔽外层退出内层恢复外层
StructuredTaskScope.fork能fork 时继承当前绑定快照
Thread.ofVirtual().start不能新线程从空绑定开始
ExecutorService.submit不能复用线程,不保证继承
作用域结束后不能绑定已恢复

如果任务必须看到上下文,有两个选择:把它放进 StructuredTaskScope;或者把值作为显式参数传进去。不要假设 lambda 捕获了变量,就能在另一个线程里通过 USER.get() 读到绑定——读的是线程当前的绑定,不是闭包里的值。

这条差异在 JDK 21 和 JDK 25 上都成立。它是设计选择:结构化并发负责传播上下文,非结构化线程不自动获得父作用域的上下文。
01 API 形状与 JDK 21 的预览差异02 绑定如何随作用域和虚拟线程移动03 从 ThreadLocal 迁移:映射与保留场景

API 形状与 JDK 21 的预览差异01

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> R call(Callable<? extends R>) throws Exception;
    public void run(Runnable);
}
static final ScopedValue<String> USER = ScopedValue.newInstance();
static final ScopedValue<String> TENANT = ScopedValue.newInstance();

ScopedValue.where(USER, "alice")
        .where(TENANT, "acme")
        .run(() -> {
            System.out.println(USER.get() + " @ " + TENANT.get());
            // 不进入作用域也能读 Carrier 上的临时绑定
            System.out.println(ScopedValue.where(USER, "bob").get(USER));
        });

JDK 21 还提供了 runWhere、callWhere、getWhere 三个静态便捷方法,但它们在后续预览中已被移除。面向 25 的代码只应使用 ScopedValue.where(...).run/call 这一套稳定形状。

APIJDK 21JDK 25
where(key, value).run/call预览正式
runWhere / callWhere / getWhere存在已移除
Carrier.call(Callable)预览改为允许受检异常的 CallableOp,lambda 源码基本兼容
isBound / get / orElse / orElseThrow预览正式
预览 API 的签名会变。不要把 runWhere/callWhere/getWhere 写进长期代码;它们在 JDK 25 已经不存在。

绑定如何随作用域和虚拟线程移动02

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

  1. where(USER, "alice") 创建一个 Carrier,记录键值对。
  2. run 在当前线程安装这个 Carrier 对应的绑定快照。
  3. lambda 内 get() 沿快照链查找;嵌套 where 安装更短的链,优先命中内层。
  4. lambda 正常返回或抛异常,run 都会恢复进入前的快照。
  5. StructuredTaskScope.fork 在创建子任务时捕获当前快照,子任务从该快照开始执行。
  6. 手工 Thread.ofVirtual().start 没有这个捕获步骤,新线程从空绑定开始。

对虚拟线程来说,绑定快照属于线程/续体的执行状态;虚拟线程挂起再恢复时,绑定仍然有效。这也是为什么作用域内的 get() 不需要额外同步。

操作发生什么代价
where创建 Carrier 链,不修改线程很轻
run/call安装快照,执行,恢复一次保存/恢复
嵌套 where叠加一层,内层优先与嵌套深度相关
结构化 fork复制当前快照引用很轻
手工线程启动不复制快照新线程无绑定
ScopedValue 没有 set,也没有 remove。这不是 API 缺失,而是设计目标:让上下文只在一个明确的动态作用域内存在。

从 ThreadLocal 迁移:映射与保留场景03

旧写法新写法说明
TL.set(v) + finally remove()ScopedValue.where(K, v).run(...)作用域结束自动恢复
TL.get()K.get() / K.orElse(...)未绑定行为不同
InheritableThreadLocalStructuredTaskScope.fork由结构化作用域传播
线程池里复用 ThreadLocal每个请求一个作用域避免串请求
可变计数器 / 缓存继续用 ThreadLocal 或显式状态ScopedValue 不可变
// Before
private static final ThreadLocal<User> USER = new ThreadLocal<>();

void handle(Request req) {
    USER.set(req.user());
    try { service.handle(req); }
    finally { USER.remove(); }
}

// After
private static final ScopedValue<User> USER = ScopedValue.newInstance();

void handle(Request req) throws Exception {
    ScopedValue.where(USER, req.user())
            .run(() -> service.handle(req));
}

日志 MDC 是最容易被忽略的迁移点:很多日志框架的 MDC 底层仍然是 ThreadLocal。ScopedValue 不会自动填充 MDC;如果日志需要 traceId,要在绑定作用域时同步设置/清理 MDC,或使用支持 ScopedValue 的日志适配层。

迁移时先问「这个值会变吗」。会变的状态继续留在 ThreadLocal 或显式对象里;只读的请求上下文才适合 ScopedValue。
01 模式:在请求边界绑定上下文02 可运行示例:嵌套、恢复与手工线程03 可运行示例:结构化子任务继承绑定04 常见坑

模式:在请求边界绑定上下文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,否则作用域会碎片化。
  • 绑定值用不可变对象;不要绑定可变实体,然后在作用域内修改它。
  • 异步任务如果必须携带上下文,用 StructuredTaskScope;不要把手工作业的线程当成自动继承。
  • 需要默认值的读取用 orElse,需要强制认证的读取用 orElseThrow。
ScopedValue 的键是全局身份,不是字符串 key。用 static final 持有键,避免把用户输入拼成键名。

可运行示例:嵌套、恢复与手工线程02

import java.util.concurrent.*;

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");
    }
}
javac --enable-preview --release 21 ScopedValueDemo.java
java --enable-preview ScopedValueDemo
before -> <unbound>
inside -> alice
nested -> bob
restored -> alice
child virtual -> <unbound>
after -> <unbound>

输出验证了三件事:嵌套绑定会遮蔽外层;退出内层恢复外层;手工启动的虚拟线程看不到父作用域的绑定。

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

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 = new StructuredTaskScope.ShutdownOnFailure()) {
                var inherited = scope.fork(ScopedStructuredDemo::current);
                scope.join().throwIfFailed();
                System.out.println("structured subtask=" + inherited.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 --enable-preview --release 21 ScopedStructuredDemo.java
java --enable-preview ScopedStructuredDemo
structured subtask=alice
manual virtual thread=<unbound>
call returns bob

如果业务必须让所有并发子任务都看到上下文,就把它们放进一个 StructuredTaskScope,而不是手工创建线程或提交到共享线程池。

常见坑04

  • 以为手工线程会继承:Thread.ofVirtual().start、ExecutorService.submit 都不会自动继承;只有结构化 fork 会。
  • 绑定可变对象:ScopedValue 只保证绑定不可变,不保证对象内部状态不可变。
  • 在领域逻辑里到处 where:作用域应当由请求/任务边界统一管理,否则很难推理生命周期。
  • 依赖 JDK 21 的预览便捷方法:runWhere/callWhere/getWhere 在 25 已移除。
  • 忘记 MDC:日志框架的 MDC 仍是 ThreadLocal,ScopedValue 不会自动同步。
  • 把 ScopedValue 当线程池使用:它只传递上下文,不创建、不调度任务。
升级到 JDK 25 后 ScopedValue 转正,但 21 编译的预览 class 仍然不能在 25 上运行。生产用 21 时请把预览代码隔离,并锁定运行时版本。
01 先猜再看02 费曼自检:作用域与传播

先猜再看01

先猜再看ScopedValue 绑定后,子任务能修改这个值吗?
不能。ScopedValue 没有 set/remove;子任务只能读取当前绑定。需要新值就开一个新的 where 作用域,内层会遮蔽外层。
先猜再看StructuredTaskScope 的子任务能读到父作用域的绑定吗?手工启动的虚拟线程呢?
结构化子任务能,fork 时会继承当前绑定快照;手工启动的虚拟线程不能,它从空绑定开始。
先猜再看ScopedValue 能完全替代 ThreadLocal 吗?
不能。它替代的是只读上下文的传递;可变的 per-thread 状态(计数器、缓存、buffer)仍然需要 ThreadLocal 或显式状态。

费曼自检:作用域与传播02

01. ThreadLocal 在虚拟线程场景下的两个主要问题是什么?
每个虚拟线程一份副本会放大内存开销;需要手动 remove,忘记清理会泄漏或把上一个请求的值带到下一个请求。
02. ScopedValue 的绑定生命周期由什么决定?
由 where(...).run/call 的作用域决定。进入时安装,退出时恢复;没有 remove,作用域结束绑定自动失效。
03. 未绑定时 get() 与 orElse() 的行为有什么区别?
get() 抛 NoSuchElementException;orElse(default) 返回默认值。需要抛业务异常时用 orElseThrow。
04. 为什么 StructuredTaskScope 子任务能继承绑定,而手工虚拟线程不能?
结构化 fork 在创建子任务时捕获当前绑定快照;手工线程没有这个捕获步骤,新线程从空绑定开始。
05. 迁移到 ScopedValue 时,日志 MDC 需要处理吗?
需要。很多日志框架的 MDC 仍基于 ThreadLocal,ScopedValue 不会自动写入 MDC;要在绑定作用域时同步设置和清理,或使用适配层。
06. JDK 21 的 ScopedValue 能直接拿到 JDK 25 运行吗?
不能。21 的 ScopedValue 是预览 API,class 文件带 preview minor version,只能在同一 JDK 版本加 --enable-preview 运行;25 需要使用正式 API 重新编译。
学习进度 记录保存在本机浏览器 · 不上传

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

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