Scoped Values:有作用域的不可变上下文
学完你要能自己回答
- ThreadLocal 在虚拟线程时代为什么变贵?ScopedValue 解决了哪一部分问题?
- ScopedValue 的绑定为什么不可变?嵌套 where 与作用域结束后分别是什么语义?
- StructuredTaskScope 的子任务会继承绑定吗?手工启动的虚拟线程呢?
- ScopedValue 能完全替代 ThreadLocal 吗?哪些场景仍然需要 ThreadLocal?
- JDK 21 与 JDK 25 的 ScopedValue API 有哪些边界差异?
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 的值会跟着虚拟线程走。它并没有「错」,只是把「每个线程一份可变状态」的成本和清理责任放大了。
| 维度 | ThreadLocal | ScopedValue |
|---|---|---|
| 可变性 | 可 set / remove,随时可变 | 绑定后不可变 |
| 生命周期 | 跟随线程,需要手动清理 | 跟随作用域,结束即失效 |
| 继承 | InheritableThreadLocal 在创建线程时复制 | 结构化子任务 fork 时继承绑定 |
| 虚拟线程开销 | 每个线程一份副本 | 共享不可变值,按作用域绑定 |
| 忘记清理 | 可能泄漏或串请求 | 不存在 remove,作用域结束自动恢复 |
| JDK 21 状态 | 正式 | 预览 |
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、不可变集合),否则「不可变」只约束了引用。
谁继承绑定:结构化子任务会,手工线程不会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() 读到绑定——读的是线程当前的绑定,不是闭包里的值。
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 这一套稳定形状。
| API | JDK 21 | JDK 25 |
|---|---|---|
where(key, value).run/call | 预览 | 正式 |
runWhere / callWhere / getWhere | 存在 | 已移除 |
Carrier.call(Callable) | 预览 | 改为允许受检异常的 CallableOp,lambda 源码基本兼容 |
isBound / get / orElse / orElseThrow | 预览 | 正式 |
runWhere/callWhere/getWhere 写进长期代码;它们在 JDK 25 已经不存在。绑定如何随作用域和虚拟线程移动02
可以把当前线程的 ScopedValue 绑定理解成一个不可变快照链:每次 where 生成新的绑定快照;进入 run/call 时安装,退出时在 finally 中恢复旧快照。因为快照只读,嵌套、恢复和跨线程传递都不需要加锁。
where(USER, "alice")创建一个 Carrier,记录键值对。run在当前线程安装这个 Carrier 对应的绑定快照。- lambda 内
get()沿快照链查找;嵌套 where 安装更短的链,优先命中内层。 - lambda 正常返回或抛异常,
run都会恢复进入前的快照。 StructuredTaskScope.fork在创建子任务时捕获当前快照,子任务从该快照开始执行。- 手工
Thread.ofVirtual().start没有这个捕获步骤,新线程从空绑定开始。
对虚拟线程来说,绑定快照属于线程/续体的执行状态;虚拟线程挂起再恢复时,绑定仍然有效。这也是为什么作用域内的 get() 不需要额外同步。
| 操作 | 发生什么 | 代价 |
|---|---|---|
where | 创建 Carrier 链,不修改线程 | 很轻 |
run/call | 安装快照,执行,恢复 | 一次保存/恢复 |
| 嵌套 where | 叠加一层,内层优先 | 与嵌套深度相关 |
| 结构化 fork | 复制当前快照引用 | 很轻 |
| 手工线程启动 | 不复制快照 | 新线程无绑定 |
set,也没有 remove。这不是 API 缺失,而是设计目标:让上下文只在一个明确的动态作用域内存在。从 ThreadLocal 迁移:映射与保留场景03
| 旧写法 | 新写法 | 说明 |
|---|---|---|
TL.set(v) + finally remove() | ScopedValue.where(K, v).run(...) | 作用域结束自动恢复 |
TL.get() | K.get() / K.orElse(...) | 未绑定行为不同 |
InheritableThreadLocal | StructuredTaskScope.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 的日志适配层。
模式:在请求边界绑定上下文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。
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 当线程池使用:它只传递上下文,不创建、不调度任务。