QUICKREF · CONCURRENCY · JEP 506
Scoped Values:作用域内的不可变上下文
JDK 25 · 速查主题 01 / 06 · 预计 30 分钟
一句话ScopedValue 把上下文从「每个线程一份可变副本」变成「一段执行路径上的只读绑定」:进入作用域时安装,退出时恢复,子任务只在结构化 fork 时继承。JDK 25 转正,API 与 21 预览版有差异。
学完你要能自己回答
- ScopedValue 与 ThreadLocal 的成本差异在哪里?为什么虚拟线程放大了 ThreadLocal 的问题?
- 嵌套 where、异常退出、手工线程与结构化子任务,分别是什么语义?
- 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 在创建线程时复制,语义与结构化任务并不一致。
| 维度 | ThreadLocal | ScopedValue |
|---|---|---|
| 可变性 | 可 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(...) | 未绑定行为不同 |
InheritableThreadLocal | StructuredTaskScope.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 | 状态 | 说明 |
|---|---|---|
| ScopedValue | 21–24 预览 / 25 正式 | JEP 506;25 起无需 --enable-preview。 |
| `ScopedValue.orElse(null)` | 25 抛 NPE | JEP 506 明确不再接受 null 参数。 |
| `runWhere / callWhere / getWhere` | 25 已移除 | 21 的静态便捷方法在后续预览中被移除。 |
| `Carrier.call` | 25 使用 CallableOp | lambda 源码基本兼容,可抛受检异常。 |
| 结构化 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 本身不能跨版本运行,必须重新编译。
学习进度
记录保存在本机浏览器 · 不上传