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

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

JDK 25 · 速查主题 01 / 06 · 预计 30 分钟
核心30 分钟JEP 506
一句话ScopedValue 把上下文从「每个线程一份可变副本」变成「一段执行路径上的只读绑定」:进入作用域时安装,退出时恢复,子任务只在结构化 fork 时继承。JDK 25 转正,API 与 21 预览版有差异。

学完你要能自己回答

  1. ScopedValue 与 ThreadLocal 的成本差异在哪里?为什么虚拟线程放大了 ThreadLocal 的问题?
  2. 嵌套 where、异常退出、手工线程与结构化子任务,分别是什么语义?
  3. JDK 21 的 runWhere / callWhere 在 25 还能用吗?orElse(null) 的行为变了吗?

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(); // 少写一次就泄漏
    }
}
  • 内存:每个虚拟线程一份副本;线程越多,ThreadLocalMap 总量越大。
  • 清理:没有编译器保证 remove;线程复用或长生命周期虚拟线程会串请求。
  • 可变性:任何被调用方都能 set,数据流难以推理。
  • 继承:InheritableThreadLocal 在创建线程时复制,语义与结构化任务并不一致。
维度ThreadLocalScopedValue
可变性可 set / remove,随时可变绑定后不可变
生命周期跟随线程,需要手动清理跟随作用域,结束即恢复
继承InheritableThreadLocal 创建线程时复制结构化 fork 时继承绑定快照
虚拟线程开销每个线程一份副本绑定按作用域/线程,不复制整张 ThreadLocalMap
忘记清理可能泄漏或串请求没有 remove,作用域结束自动恢复
JDK 25 状态正式正式(JEP 506)
ScopedValue 解决的是只读上下文传递,不是「每线程可变状态」。计数器、缓存、可变的 per-thread buffer 仍然应该用 ThreadLocal 或显式状态。

API:where / run / call02

ScopedValue.newInstance() 创建键,键通常声明为 static final。ScopedValue.where(key, value) 返回 Carrier;Carrier.run(Runnable) 无返回值,Carrier.call(CallableOp) 可以返回值并抛受检异常。

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
  • get() 未绑定抛 NoSuchElementException;需要默认值用 orElse,需要业务异常用 orElseThrow。
  • JDK 25 起 orElse(null) 直接抛 NullPointerException(JEP 506 的收口变化)。
  • Carrier.call 的 lambda 目标类型是 ScopedValue.CallableOp,源码上仍可写 () -> ...。
  • 键不要按请求动态创建;键是身份,不是字符串名。
把 ScopedValue 想成「只读的、带括号的上下文参数」:括号内可见,括号外消失;嵌套括号可以遮蔽,但不能修改外层绑定。

继承边界:结构化 fork 会,手工线程不会03

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

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())) {
                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 ScopedStructuredDemo.java
java --enable-preview ScopedStructuredDemo
structured subtask=alice
manual virtual thread=<unbound>
call returns bob
执行位置能读到绑定吗说明
当前线程的作用域内能直接 get / orElse
嵌套 where 内能,内层遮蔽外层退出内层恢复外层
StructuredTaskScope.fork能fork 时继承当前快照
Thread.ofVirtual().start不能新线程从空绑定开始
ExecutorService.submit不能复用线程,不保证继承
作用域结束后不能绑定已恢复
如果任务必须看到上下文,有两个选择:把它放进 StructuredTaskScope;或者把值作为显式参数传进去。不要假设 lambda 捕获了变量,就能在另一个线程通过 USER.get() 读到绑定。

生命周期:安装、恢复与异常04

实现上,当前绑定是一条不可变快照链:where 生成 Carrier,run/call 安装快照,退出时在 finally 中恢复旧快照。因为快照只读,嵌套、恢复和跨线程传递都不需要加锁。

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

    static String checked() throws Exception {
        return "checked " + USER.get();
    }

    public static void main(String[] args) throws Exception {
        System.out.println("carrier.get=" +
                ScopedValue.where(USER, "dave").get(USER));
        System.out.println("bound after carrier=" + USER.isBound());

        try {
            ScopedValue.where(USER, "x").run(() -> {
                throw new IllegalStateException("boom");
            });
        } catch (IllegalStateException e) {
            System.out.println("caught=" + e.getMessage());
        }
        System.out.println("bound after exception=" + USER.isBound());

        System.out.println(ScopedValue.where(USER, "eve")
                .call(ScopedExtra::checked));
    }
}
carrier.get=dave
bound after carrier=false
caught=boom
bound after exception=false
checked eve
  • 异常退出同样会恢复:run/call 用 try/finally 恢复旧快照,不需要你写 remove。
  • Carrier.get(key) 只读取 Carrier 上的临时绑定,不安装到线程;适合框架或测试。
  • 嵌套 where 安装更短的链,内层优先;内层退出后外层值恢复。
  • 绑定会持有值的强引用,直到作用域结束;不要在作用域里绑定不再需要的大对象。
「不可变」只约束绑定关系,不约束值对象本身。绑定 List 后仍然可以修改列表内容;需要真正不可变时用 List.copyOf 或 record。

落地:请求边界与迁移05

典型用法是在 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 currentUser() {
    return CURRENT_USER.orElseThrow(UnauthorizedException::new);
}
旧写法JDK 25 写法说明
TL.set(v) + finally remove()ScopedValue.where(K, v).run(...)作用域结束自动恢复
TL.get()K.get() / K.orElse(...)未绑定行为不同
InheritableThreadLocalStructuredTaskScope.fork由结构化作用域传播
线程池里复用 ThreadLocal每个请求一个作用域避免串请求
可变计数器 / 缓存继续用 ThreadLocal 或显式状态ScopedValue 不可变
  • 在入口绑定,在入口结束;不要在领域方法里随手 where,否则作用域碎片化。
  • 绑定值用不可变对象;不要绑定可变实体再在作用域内修改它。
  • 日志 MDC 底层通常仍是 ThreadLocal,ScopedValue 不会自动写入;需要适配层或手动同步。
  • 面向 25 的代码只使用 where(...).run/call,不要再用 21 的 runWhere/callWhere/getWhere。
JDK 21 编译的 ScopedValue 预览 class 不能在 25 上运行;升级到 25 后要用正式 API 重新编译。

版本边界BOUNDARY

这张表比 API 本身更值得记。写代码时先确认你的运行时版本。

特性 / API状态说明
ScopedValue21–24 预览 / 25 正式JEP 506;25 起无需 --enable-preview。
`ScopedValue.orElse(null)`25 抛 NPEJEP 506 明确不再接受 null 参数。
`runWhere / callWhere / getWhere`25 已移除21 的静态便捷方法在后续预览中被移除。
`Carrier.call`25 使用 CallableOplambda 源码基本兼容,可抛受检异常。
结构化 fork 继承绑定25 成立手工 Thread / Executor 不继承。
结构化并发25 第五次预览继承示例需要 --enable-preview。

费曼自检SELF CHECK

先合上页面,用自己的话回答,再展开对照。能讲出因果和反例,才算真正掌握。

01. ThreadLocal 在虚拟线程场景下的两个主要问题是什么?
每个虚拟线程一份副本会放大内存开销;需要手动 remove,忘记清理会泄漏或把上一个请求的值带到下一个请求。
02. ScopedValue 的绑定生命周期由什么决定?
由 where(...).run/call 的作用域决定。进入时安装快照,退出时恢复;没有 remove,作用域结束绑定自动失效。
03. 为什么 StructuredTaskScope 子任务能继承绑定,而手工虚拟线程不能?
结构化 fork 在创建子任务时捕获当前绑定快照;手工 Thread.ofVirtual().start 或 ExecutorService.submit 没有这个捕获步骤,新线程从空绑定开始。
04. JDK 21 的 ScopedValue 代码搬到 25,最常见的两个编译/运行问题是什么?
一是 runWhere/callWhere/getWhere 已移除,要改用 where(...).run/call;二是 orElse(null) 现在抛 NPE。另外 21 的预览 class 本身不能跨版本运行,必须重新编译。
学习进度 记录保存在本机浏览器 · 不上传

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

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