Scoped Values 内部:绑定快照、作用域与传递边界
学完你要能自己回答
- ThreadLocal 的三个设计缺陷是什么?虚拟线程为什么把它们放大?
- ScopedValue 的绑定在嵌套、异常、结构化 fork、手工线程下分别如何变化?
- Carrier 链与 run/call 的安装、恢复过程是怎样的?为什么不需要 remove?
- JDK 25 的 ScopedValue API 与 21 预览版有哪些不兼容变化?
- 哪些场景仍然应该使用 ThreadLocal,而不是 ScopedValue?
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。
动态作用域:绑定跟着括号走,不跟着线程走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 | 不能 | 复用线程,不保证继承 |
| 作用域结束后 | 不能 | 绑定已恢复 |
USER.get() 读的是当前线程当前的绑定,不是闭包里的值。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 21 | JDK 25 |
|---|---|---|
where(...).run/call | 预览 | 正式 |
runWhere / callWhere / getWhere | 存在 | 已移除 |
Carrier.call 的参数 | Callable | CallableOp,允许受检异常 |
orElse(null) | 允许 | 抛 NullPointerException |
--enable-preview | 编译/运行都需要 | 不需要 |
CallableOp 是函数式接口,lambda 源码通常不用改;但方法签名从 Callable 变成了可抛受检异常的 CallableOp,依赖精确签名的反射或方法引用需要重新编译。ScopedValue 本身没有静态 call 方法,返回值只能通过 Carrier.call。
安装、恢复与 fork:一条不可变快照链02
可以把当前线程的 ScopedValue 绑定理解成一个不可变快照链:每次 where 生成新的 Carrier 节点,记录键、值和前一个节点;进入 run/call 时安装,退出时在 finally 中恢复旧快照。因为快照只读,嵌套、恢复和跨线程传递都不需要加锁。
where(USER, "alice")创建一个 Carrier,记录键值对和前一个 Carrier。run在当前线程安装这个 Carrier 对应的绑定快照。- lambda 内
get()在当前快照中查找;嵌套 where 安装更短的链,优先命中内层。 - lambda 正常返回或抛异常,
run都会恢复进入前的快照。 StructuredTaskScope.fork在创建子任务时捕获当前快照,子任务从该快照开始执行。- 手工
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,因为不存在需要手动清理的线程局部槽位。
模式:在请求边界绑定上下文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 的适配层。
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 当线程池使用:它只传递上下文,不创建、不调度任务。
合上页面,先回答01
先猜再看ScopedValue 的值是每个线程复制一份吗?
先猜再看作用域结束后,绑定对象会被「清空」吗?
01. 为什么 ScopedValue 适合虚拟线程?
02. 手工启动的虚拟线程为什么读不到父作用域的绑定?
03. JDK 25 对 orElse(null) 做了什么改变?
04. 哪些场景仍应使用 ThreadLocal?
版本与边界一页表02
| 事项 | JDK 25 状态 | 要记住的边界 |
|---|---|---|
| ScopedValue | 正式(JEP 506) | 不再需要 --enable-preview |
| runWhere / callWhere / getWhere | 已移除 | 改用 where(...).run/call |
| Carrier.call | CallableOp | 允许受检异常,lambda 源码基本兼容 |
| orElse(null) | 抛 NPE | 用真实默认值或 orElseThrow |
| 结构化 fork 继承 | 正式语义 | 只有 fork 继承,手工线程不继承 |
| JDK 21 预览 class | 不能运行 | 65.65535 与 69.65535 不兼容,必须重编译 |
如果只能记一句话:ScopedValue 约束的是可见范围,不是线程身份。它把「谁知道这个值」变成代码结构问题,而不是线程生命周期问题。