Scoped Values 实战:Java 25 如何替代 ThreadLocal 传递请求上下文
在一个 Web 请求中,链路追踪 ID、当前用户、租户信息等上下文,经常需要穿过多层方法,最终到达日志、数据访问或远程调用组件。
如果每一层都显式增加参数,方法签名会迅速被这些“旁观者参数”占领;如果使用 ThreadLocal,又要面对线程池污染、忘记清理、嵌套覆盖和跨线程传递等问题。
Java 25 正式发布的 Scoped Values,为其中一类问题提供了更清晰的模型:在一段有明确边界的执行过程中,把不变的上下文从调用者单向传给被调用者。
它不是给 ThreadLocal 增加几个新方法,也不是一种可以随意读写的全局上下文容器。它真正改变的是上下文的生命周期和数据流:
绑定上下文
↓
执行一个操作
↓
操作直接、间接调用的方法可以读取上下文
↓
操作正常返回或抛出异常
↓
绑定自动消失,或恢复为外层绑定
本文会从实际问题出发,完整介绍 Java 25 Scoped Values 的 API、动态作用域、嵌套重绑定、跨线程继承、Spring MVC 接入、测试方法、性能边界与迁移策略。
版本说明:ScopedValue 在 Java 20 中孵化,Java 21~24 中预览,Java 25 中成为正式 API。本文核心示例基于 Java 25。结构化并发在 Java 25 中仍是预览特性,相关示例会单独标注,不能与 ScopedValue 的正式状态混为一谈。
一、先说结论:什么时候该用,什么时候不该用
如果只想先记住最实用的判断,可以看下面几条:
一张表可以快速选型:
| 在一次同步请求调用链中传递只读上下文 | Scoped Value |
| 在结构化子任务中继承只读上下文 | Scoped Value + StructuredTaskScope |
| 深层方法需要反向修改调用者的状态 | 显式返回值、显式参数,必要时才考虑 ThreadLocal |
| 在线程整个生命周期内缓存昂贵的非线程安全对象 | ThreadLocal 或更合适的对象池;优先考虑线程安全替代类 |
| Reactor 异步数据流中的上下文 | Reactor Context 及框架原生机制 |
| 普通线程池中的任务上下文 | 提交任务时显式捕获并重新绑定,或使用框架传播组件 |
| 跨进程、跨服务传递上下文 | 消息头、HTTP Header、RPC 元数据等显式协议 |
二、为什么需要“隐式方法参数”
2.1 显式参数永远是首选基线
假设订单处理链路如下:
OrderController
↓
OrderService
↓
OrderRepository
↓
AuditLogger
AuditLogger 需要记录 traceId,最直观的办法是逐层传递:
void handleOrder(long orderId, String traceId) {
orderService.process(orderId, traceId);
}
void process(long orderId, String traceId) {
// 中间方法本身可能完全不使用 traceId
auditLogger.record(orderId, traceId);
}
这种方式有一个非常重要的优点:依赖清楚、类型明确、测试方便。
如果一个值只有两三层的固定传递路径,或者它本身就是业务方法的核心输入,显式参数通常仍是最好的方案,不必为了使用新 API 而引入上下文容器。
2.2 真正麻烦的是横切上下文
当调用链很长,而且 traceId、用户信息、租户信息需要经过许多并不关心它们的方法时,问题就出现了:
- 中间方法被迫声明自己并不使用的参数;
- 新增一种上下文会修改大量方法签名;
- 框架需要在调用业务代码后,再从业务回调中取得框架上下文;
- 业务层与基础设施层因为上下文参数产生额外耦合。
ThreadLocal 和 Scoped Value 都可以在这时充当一种隐式方法参数。
区别不在于“能不能少写参数”,而在于这个隐式参数是否可以被随时修改、活多久,以及如何进入子线程。
三、ThreadLocal 的三个结构性问题
3.1 传统请求上下文写法
先用一个典型的 ThreadLocal 实现作为对照:
public record RequestContext(String traceId, String userId) {
}
public final class RequestContexts {
private static final ThreadLocal<RequestContext> CONTEXT =
new ThreadLocal<>();
private RequestContexts() {
}
public static void set(RequestContext context) {
CONTEXT.set(context);
}
public static RequestContext current() {
RequestContext context = CONTEXT.get();
if (context == null) {
throw new IllegalStateException("当前线程没有请求上下文");
}
return context;
}
public static void clear() {
CONTEXT.remove();
}
}
Web 入口通常要这样使用:
RequestContexts.set(context);
try {
handleRequest();
} finally {
RequestContexts.clear();
}
这段代码可以正常工作,但它把三件关键事情交给了开发者自觉:
- 谁能修改上下文;
- 什么时候清理上下文;
- 子任务如何继承上下文。
3.2 问题一:可修改性没有边界
任何能够访问 CONTEXT 的代码,都可以在任意位置调用:
CONTEXT.set(anotherContext);
即使 RequestContext 是不可变 record,也只是对象本身不可变,ThreadLocal 中的引用仍然可以被替换。
这会带来两个问题:
- 很难通过代码结构判断上下文在哪里被改变;
- 深层代码可以影响后续完全不相关的调用,数据流不再只是“调用者传给被调用者”。
如果上下文涉及用户或租户身份,不受约束的替换还可能成为安全隐患。
3.3 问题二:生命周期依赖手动清理
下面代码少了 finally:
RequestContexts.set(context);
handleRequest();
RequestContexts.clear();
一旦 handleRequest() 抛出异常,清理就不会执行。
即使写了 try/finally,封装不当、异步边界复杂或遗漏入口,仍然可能留下旧值。
在固定线程池中,工作线程会被多个任务复用:
任务 A:绑定用户 A 的上下文
任务 A:异常结束,没有清理
任务 B:复用同一线程
任务 B:某段代码忘记初始化上下文,却读到了用户 A
这既可能产生错误日志,也可能造成越权或数据串租户。
长期不清理还会让线程继续持有上下文对象及其引用图,延迟垃圾回收。
需要补充一个边界:在“一请求一个短命虚拟线程”的模型中,线程结束会释放其 ThreadLocal 数据,因此长期线程池污染的问题会减轻。 但这并没有解决任意修改、继承成本和异步传播问题,也不能成为省略清理的通用理由。
3.4 问题三:可继承不等于低成本、可控制
InheritableThreadLocal 可以在创建子线程时复制父线程的可继承值,但它有几个限制:
- 继承发生在子线程创建时,不是任务提交时;
- 已经存在的池化工作线程不会因为收到新任务而重新继承;
- 创建子线程需要处理父线程中的可继承 ThreadLocal 数据;
- 父子线程拿到的是各自可修改的绑定,不是一套有统一生命周期的只读绑定。
因此,下面这种想法通常是错误的:
父线程已经设置 InheritableThreadLocal
↓
向固定线程池提交任务
↓
池中的旧线程自动看到父线程的新值
固定线程池的线程往往早已创建,任务提交与线程创建不是同一时刻。
四、Scoped Value 的核心模型
4.1 它不是“只能 set 一次的 ThreadLocal”
Scoped Value 更准确的定义是:
一个可以在当前线程的某段动态作用域中绑定值,并让这段执行中的直接、间接被调用者读取该值的键。
它有三个关键词:
- 绑定(binding):把某个 ScopedValue 键与一个值关联;
- 动态作用域(dynamic scope):由运行时调用过程决定的可见范围;
- 自动恢复(revert):操作结束后,绑定消失或恢复为外层值。
基本形式如下:
private static final ScopedValue<String> TRACE_ID =
ScopedValue.newInstance();
ScopedValue.where(TRACE_ID, "trace-001").run(() -> {
System.out.println(TRACE_ID.get());
businessMethod();
});
在 run 执行期间:
- 当前 lambda 可以读取 TRACE_ID;
- businessMethod() 可以读取;
- businessMethod() 继续调用的方法也可以读取;
- 当前线程中这段操作之外的代码不能读取这份绑定;
- 其他线程不会因此获得绑定。
run 返回后,绑定自动解除,不需要 remove()。
4.2 什么是动态作用域
Java 局部变量的作用域通常是词法作用域:看源代码中的 {},基本就能知道变量在哪里可用。
Scoped Value 使用的是动态作用域:不只看代码写在哪里,还看代码在哪个执行过程中被调用。
private static final ScopedValue<String> NAME =
ScopedValue.newInstance();
static void printName() {
System.out.println(NAME.orElse("未绑定"));
}
static void demo() {
ScopedValue.where(NAME, "外层请求").run(() -> {
printName();
});
printName();
}
输出为:
外层请求
未绑定
printName() 的源代码没有改变,两次调用读取结果不同,因为第一次发生在绑定操作的动态作用域内,第二次不在。
再看一个回调例子:
Runnable callback = () -> {
System.out.println(NAME.isBound() ? NAME.get() : "未绑定");
};
ScopedValue.where(NAME, "hello").run(callback);
callback.run();
输出同样是:
hello
未绑定
所以,不要把“lambda 的源代码位置”误认为作用域本身。真正决定绑定是否存在的是:当前代码是否正运行在 where(…).run/call 所执行的操作内部。
五、第一个完整示例:传递请求上下文
下面是一个只依赖 Java 25 标准库的完整示例,不需要启用预览特性:
public class ScopedValueDemo {
record RequestContext(String traceId, long userId) {
}
private static final ScopedValue<RequestContext> CONTEXT =
ScopedValue.newInstance();
static String loadOrder(long orderId) {
RequestContext context = CONTEXT.get();
return "order=" + orderId
+ ", user=" + context.userId()
+ ", trace=" + context.traceId();
}
static void handleRequest(long userId) {
var context = new RequestContext("trace-20261102-001", userId);
ScopedValue.where(CONTEXT, context).run(() -> {
System.out.println(loadOrder(1001L));
System.out.println("作用域内是否绑定:" + CONTEXT.isBound());
});
System.out.println("作用域外是否绑定:" + CONTEXT.isBound());
}
public static void main(String[] args) {
handleRequest(42L);
}
}
运行结果:
order=1001, user=42, trace=trace-20261102-001
作用域内是否绑定:true
作用域外是否绑定:false
这段代码展示了推荐的整体结构:
注意,示例中的 CONTEXT 是 private。这不仅是一种封装习惯,也与访问控制有关。
六、Java 25 正式 API 详解
6.1 newInstance:创建初始未绑定的键
private static final ScopedValue<RequestContext> CONTEXT =
ScopedValue.newInstance();
ScopedValue<T> 本身不是上下文值,而是一个类型安全的键。
刚创建时,它在所有线程中都处于未绑定状态。
通常应当声明为 static final:
- static:让同一套协作代码共享同一个键;
- final:避免键引用被替换;
- 尽量 private:限制哪些代码可以读取或绑定它。
把键声明为 public static final,相当于把读取和绑定能力暴露给所有能访问它的代码。
6.2 where + run:执行无返回值操作
ScopedValue.where(CONTEXT, context).run(() -> {
processOrder();
});
where 不会立刻在当前位置建立长期绑定。它返回一个 ScopedValue.Carrier,随后由 run 在操作执行期间建立绑定。
run 接收 Runnable,适合无返回值操作:
static void runWithContext(RequestContext context) {
ScopedValue.where(CONTEXT, context).run(() -> {
System.out.println(CONTEXT.get().traceId());
});
}
操作正常结束或抛出异常,绑定都会被清理。
6.3 where + call:执行有返回值操作
有返回值时使用 call:
String result = ScopedValue.where(CONTEXT, context).call(() -> {
return loadOrder(1001L);
});
可以省略显式的 return 写法取决于 lambda 体;上面的多行形式需要 return。
call 使用 ScopedValue.CallableOp<T, X extends Throwable>,可以保留受检异常:
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
private static final ScopedValue<Path> INPUT_FILE =
ScopedValue.newInstance();
static String readMessage() throws IOException {
return ScopedValue
.where(INPUT_FILE, Path.of("message.txt"))
.call(() -> Files.readString(INPUT_FILE.get()));
}
这里不需要把 IOException 强行包装成 RuntimeException。
如果操作没有返回值但会抛出受检异常,也可以让 lambda 返回 null,使用 call;如果只有运行时异常,直接使用 run 即可。
6.4 一次绑定多个值
可以连续调用 where:
private static final ScopedValue<String> TRACE_ID =
ScopedValue.newInstance();
private static final ScopedValue<Long> USER_ID =
ScopedValue.newInstance();
ScopedValue
.where(TRACE_ID, "trace-001")
.where(USER_ID, 42L)
.run(() -> {
System.out.println(TRACE_ID.get());
System.out.println(USER_ID.get());
});
这比嵌套多层 run 更直观:
// 可读性较差,不推荐用来组织一组固定上下文
ScopedValue.where(TRACE_ID, "trace-001").run(() -> {
ScopedValue.where(USER_ID, 42L).run(() -> {
// …
});
});
不过,在实际项目中,如果多个值总是一起出现,通常更推荐聚合成一个不可变对象:
public record RequestContext(
String traceId,
long userId,
long tenantId) {
}
private static final ScopedValue<RequestContext> CONTEXT =
ScopedValue.newInstance();
这样可以减少键的数量,也能避免只绑定了一半上下文。
6.5 get:读取当前线程的绑定
RequestContext context = CONTEXT.get();
如果当前线程没有绑定,默认抛出:
java.util.NoSuchElementException
下面代码会在 run 结束后抛异常:
ScopedValue.where(CONTEXT, context).run(() -> {
System.out.println(CONTEXT.get());
});
CONTEXT.get(); // NoSuchElementException
不要把 get() 当成 ThreadLocal.get():后者在未设置时通常返回 null,前者在未绑定时会明确失败。
6.6 isBound:判断当前线程是否绑定
if (CONTEXT.isBound()) {
System.out.println(CONTEXT.get());
}
适合上下文确实可选的代码,例如:
static String currentTraceId() {
return CONTEXT.isBound()
? CONTEXT.get().traceId()
: "no-request";
}
但不要为了减少异常而到处使用 isBound + orElse。
如果订单服务必须在用户上下文中运行,缺少上下文通常说明入口装配错误。此时直接失败,比悄悄使用默认用户更安全。
6.7 orElse:未绑定时提供默认值
String traceId = TRACE_ID.orElse("no-trace");
只有“未绑定”时才使用默认值。
Java 25 的一个重要变化是:orElse 不再接受 null 作为参数。因此下面写法不适用于最终版本:
// Java 25 中不要这样写
String value = TRACE_ID.orElse(null);
如果业务确实允许 null,应当先使用 isBound() 判断,或者重新考虑是否真的需要绑定一个可空值。
6.8 orElseThrow:自定义未绑定异常
RequestContext context = CONTEXT.orElseThrow(
() -> new IllegalStateException("当前线程未绑定请求上下文")
);
它比默认的 NoSuchElementException 更容易表达业务语义,也便于统一封装。
6.9 绑定 null 与未绑定不是一回事
Java 25 的 where(key, value) 允许 value 为 null:
private static final ScopedValue<String> OPTIONAL_VALUE =
ScopedValue.newInstance();
ScopedValue.where(OPTIONAL_VALUE, null).run(() -> {
System.out.println(OPTIONAL_VALUE.isBound()); // true
System.out.println(OPTIONAL_VALUE.get()); // null
System.out.println(OPTIONAL_VALUE.orElse("默认")); // null
});
System.out.println(OPTIONAL_VALUE.isBound()); // false
System.out.println(OPTIONAL_VALUE.orElse("默认")); // 默认
因此:
- isBound() 判断的是绑定是否存在;
- get() 返回的是绑定值,绑定值可以为 null;
- orElse() 只处理未绑定,不会把已绑定的 null 替换成默认值。
6.10 Carrier 是什么
where 返回的 ScopedValue.Carrier 是一组“键到值”的映射:
var bindings = ScopedValue
.where(TRACE_ID, "trace-001")
.where(USER_ID, 42L);
bindings.run(() -> {
// 执行任务
});
Carrier 本身不可变且线程安全。继续调用 where 会返回新的 Carrier,而不是修改原对象:
var first = ScopedValue.where(TRACE_ID, "trace-001");
var second = first.where(USER_ID, 42L);
// first 仍然只包含 TRACE_ID,second 包含两个映射
Carrier 还有一个容易误解的 get(key):
String value = bindings.get(TRACE_ID);
这里读取的是 Carrier 映射中保存的值,不是“当前线程动态作用域中的值”。它与 TRACE_ID.get() 不是一回事。
日常请求上下文代码通常直接使用 where(…).run/call,不需要显式操作 Carrier。
七、自动清理与嵌套重绑定
7.1 异常也会解除绑定
try {
ScopedValue.where(TRACE_ID, "trace-error").run(() -> {
throw new IllegalStateException("模拟业务失败");
});
} catch (IllegalStateException e) {
System.out.println(e.getMessage());
}
System.out.println(TRACE_ID.isBound()); // false
无论操作正常返回还是异常退出,绑定都会解除。
这消除了请求入口中单纯为了 ThreadLocal.remove() 而存在的 try/finally,但不会替业务资源完成清理。数据库连接、文件流、锁等资源仍然需要各自正确的生命周期管理。
7.2 嵌套作用域可以重新绑定
Scoped Value 没有 set(),但允许在内部操作中为同一个键建立新绑定:
private static final ScopedValue<String> TENANT =
ScopedValue.newInstance();
ScopedValue.where(TENANT, "tenant-a").run(() -> {
System.out.println(TENANT.get()); // tenant-a
ScopedValue.where(TENANT, "tenant-b").run(() -> {
System.out.println(TENANT.get()); // tenant-b
});
System.out.println(TENANT.get()); // tenant-a
});
System.out.println(TENANT.isBound()); // false
绑定关系可以画成:
未绑定
↓ where(TENANT, tenant-a).run
tenant-a
↓ where(TENANT, tenant-b).run
tenant-b
↓ 内层操作结束,恢复
tenant-a
↓ 外层操作结束,恢复
未绑定
这称为重绑定(rebinding)。
它不是修改原来的绑定,而是在嵌套动态作用域中建立一个新绑定。内层退出后,外层值自动恢复,不需要手动保存旧值。
7.3 为什么仍然不算随意 set
下面这种语义是不存在的:
ScopedValue.where(TENANT, "tenant-a").run(() -> {
// 当前作用域不能把 TENANT.set("tenant-b")
// 从而影响本作用域后续所有读取
});
能够重绑定的代码必须拥有键,并且要明确开启一个新的 run 或 call 边界。这个边界让数据流重新变得可见。
八、“不可变”到底保证了什么
8.1 不可变的是绑定关系,不是自动冻结对象
Scoped Value 没有 set(),不代表下面的对象不可变:
public class MutableContext {
public String userId;
}
如果把这种对象绑定后共享给多个子线程,子线程仍然可以同时修改它。
官方文档因此明确要求:跨线程共享的值应当是不可变对象,或者所有访问都经过适当同步。
8.2 record 也不一定深度不可变
下面记录看起来不可变,但列表内容仍然可以被修改:
record BadContext(List<String> permissions) {
}
var permissions = new java.util.ArrayList<String>();
permissions.add("order:read");
var context = new BadContext(permissions);
permissions.add("order:delete"); // record 中的列表也发生了变化
更稳妥的做法是 defensive copy:
import java.util.List;
public record RequestContext(
String traceId,
String userId,
List<String> permissions) {
public RequestContext {
permissions = List.copyOf(permissions);
}
}
如果上下文包含其他对象,也要继续检查这些对象是否真正不可变。
8.3 不要把连接、锁和会话状态随手塞进上下文
Scoped Value 适合传递数据快照,不适合借此绕过资源生命周期:
- JDBC Connection 有事务状态、线程约束和关闭要求;
- 锁必须明确获取和释放;
- 可写会话会引入并发修改;
- 大型缓存对象可能延长生命周期并增加内存占用。
“绑定会自动解除”只意味着键不再能通过当前作用域读取,不等于连接自动关闭,也不等于对象立即被垃圾回收。
如果业务代码把 CONTEXT.get() 得到的对象保存到静态集合、缓存或异步任务中,该对象仍然会继续存活。
九、跨线程继承:必须与 StructuredTaskScope 一起理解
9.1 不是创建任何子线程都会继承
Java 25 的 Scoped Value 跨线程共享有明确限制:
子线程必须在父线程的有界执行过程中启动,并且在父作用域结束前完成。
StructuredTaskScope 正好提供了这种结构。
下面代码可以传递上下文:
import java.util.concurrent.StructuredTaskScope;
import java.util.concurrent.StructuredTaskScope.Subtask;
public class StructuredContextDemo {
private static final ScopedValue<String> TRACE_ID =
ScopedValue.newInstance();
static String load(String resource) {
return resource + " -> " + TRACE_ID.get();
}
public static void main(String[] args) throws Exception {
String result = ScopedValue.where(TRACE_ID, "trace-001").call(() -> {
try (var scope = StructuredTaskScope.open()) {
Subtask<String> user = scope.fork(() -> load("用户信息"));
Subtask<String> order = scope.fork(() -> load("订单信息"));
scope.join();
return user.get() + System.lineSeparator() + order.get();
}
});
System.out.println(result);
}
}
预期输出:
用户信息 -> trace-001
订单信息 -> trace-001
在 Java 25 中,结构化并发仍是预览 API,需要使用与运行版本一致的 JDK 25 编译并启用预览:
javac –release 25 –enable-preview StructuredContextDemo.java
java –enable-preview StructuredContextDemo
如果只使用 ScopedValue,不涉及 StructuredTaskScope,则不需要这些预览参数。
9.2 为什么子任务能安全继承
结构化作用域保证了:
父任务绑定上下文
↓
打开 StructuredTaskScope
↓
fork 子任务,继承打开 scope 时捕获的绑定
↓
join / close 等待子任务结束
↓
父任务退出上下文绑定
子任务不会在外层 run/call 已经结束后继续带着继承绑定运行,因此生命周期仍然是明确的。
close 会等待子任务线程结束;如果子任务不响应中断或阻塞在不可中断操作上,关闭也可能被拖延。上下文模型不能替代任务取消与超时设计。
9.3 捕获发生在打开 scope 时
Java 25 文档明确说明:打开 StructuredTaskScope 时捕获当前线程的 Scoped Value 绑定;fork 时还会检查当前绑定是否与打开 scope 时匹配。
所以正确顺序是:
ScopedValue.where(TRACE_ID, "trace-001").run(() -> {
try (var scope = StructuredTaskScope.open()) {
scope.fork(() -> doWork());
scope.join();
}
});
不要反过来:
// 错误示范:scope 打开后才建立临时绑定
try (var scope = StructuredTaskScope.open()) {
ScopedValue.where(TRACE_ID, "trace-too-late").run(() -> {
scope.fork(() -> doWork());
scope.join();
});
}
后一种写法让 fork 时的绑定与打开 scope 时不一致,不符合结构化继承的约束。
9.4 子任务中的重绑定只影响自己的动态作用域
子任务继承后仍然可以嵌套重绑定:
scope.fork(() -> {
System.out.println(TRACE_ID.get()); // 继承值
ScopedValue.where(TRACE_ID, "child-trace").run(() -> {
System.out.println(TRACE_ID.get()); // child-trace
});
System.out.println(TRACE_ID.get()); // 恢复继承值
});
这不会修改父线程或兄弟子任务看到的绑定。
如果继承值本身是可变对象,情况则不同:绑定是隔离的,对象仍可能被共享。因此再次强调,跨线程上下文应优先使用深度不可变数据。
9.5 普通虚拟线程不会自动继承 Scoped Value
下面代码不能靠“虚拟线程很轻量”推导出上下文继承:
ScopedValue.where(TRACE_ID, "trace-001").run(() -> {
Thread.startVirtualThread(() -> {
// 普通 Thread.startVirtualThread 不提供 ScopedValue 继承
System.out.println(TRACE_ID.isBound());
});
});
同样,以下机制也不会自动复制提交线程的 Scoped Value:
- ExecutorService.submit();
- CompletableFuture.supplyAsync();
- 并行流;
- 普通 ForkJoinPool;
- 任意回调式异步 API。
它们不是 StructuredTaskScope,也没有同样的父子生命周期保证。
9.6 旧线程池必须显式捕获、显式重绑定
如果旧代码暂时不能迁移到结构化并发,可以在提交任务的线程捕获上下文,再在工作线程中建立新绑定:
RequestContext snapshot = CONTEXT.get();
executor.submit(() -> {
ScopedValue.where(CONTEXT, snapshot).run(() -> {
doWork();
});
});
关键点是:
这不是自动继承,而是一个明确的适配器。
如果任务会长时间排队,快照及任务引用会一直被保留;如果业务要求上下文随请求取消,也还需要额外的取消机制。
十、Scoped Value 与虚拟线程是什么关系
两者经常一起出现,但解决的问题不同:
| 虚拟线程 | 大量并发阻塞任务不必占用同样数量的操作系统线程 |
| Scoped Value | 只读上下文如何在有界调用及结构化子任务中安全共享 |
| Structured Concurrency | 如何约束子任务生命周期、失败传播、取消和汇合 |
可以把它们组合成:
一个请求一个虚拟线程
↓
请求入口绑定 Scoped Value
↓
请求内部使用 StructuredTaskScope 拆分 I/O 子任务
↓
子任务继承只读上下文
↓
所有子任务完成后,请求上下文解除
但不要得出两个极端结论:
- “用了虚拟线程,就必须全部替换 ThreadLocal”——错误;
- “用了 Scoped Value,就能解决所有异步上下文传播”——同样错误。
虚拟线程让线程数量可以非常大,因此每线程数据的内存成本和生命周期更值得重视;Scoped Value 则提供了更适合只读上下文的一种数据流模型。
十一、Spring Boot / Spring MVC 接入示例
本节以传统 Spring MVC 同步请求模型为例,不讨论 WebFlux 的响应式上下文。
11.1 定义不可变上下文
import java.util.Objects;
public record RequestContext(String traceId, String userId) {
public RequestContext {
Objects.requireNonNull(traceId, "traceId 不能为空");
Objects.requireNonNull(userId, "userId 不能为空");
}
}
这里的 userId 应来自可信认证结果,不能直接信任客户端随意传入的用户 Header。
11.2 封装键,避免把绑定能力暴露给业务代码
public final class RequestContexts {
private static final ScopedValue<RequestContext> CONTEXT =
ScopedValue.newInstance();
private RequestContexts() {
}
public static RequestContext current() {
return CONTEXT.orElseThrow(
() -> new IllegalStateException("当前线程未绑定请求上下文")
);
}
public static boolean isBound() {
return CONTEXT.isBound();
}
public static void run(RequestContext context, Runnable action) {
ScopedValue.where(CONTEXT, context).run(action);
}
public static <R, X extends Throwable> R call(
RequestContext context,
ScopedValue.CallableOp<? extends R, X> action) throws X {
return ScopedValue.where(CONTEXT, context).call(action);
}
}
业务层只能看到 current() 等公开方法,无法取得私有键并任意重绑定。
11.3 在 Servlet Filter 中包围整个后续调用链
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
import java.util.Optional;
import java.util.UUID;
public class RequestContextFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(
HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
String userId = Optional.ofNullable(request.getRemoteUser())
.orElse("anonymous");
var context = new RequestContext(
UUID.randomUUID().toString(),
userId
);
try {
RequestContexts.call(context, () -> {
filterChain.doFilter(request, response);
return null;
});
} catch (IOException | ServletException | RuntimeException | Error e) {
throw e;
} catch (Exception e) {
throw new ServletException("执行请求处理链失败", e);
}
}
}
这段边界代码比业务代码稍长,原因是 FilterChain.doFilter 声明了受检异常,而 Filter 方法只能按 Servlet 契约抛出指定异常。这里保留原有异常类型,其他受检异常再包装为 ServletException。
实际项目还应明确:
- Filter 注册顺序;
- 认证信息在哪一层产生;
- 是否接受外部传入的 trace ID;
- 外部 trace ID 的长度、字符集与日志注入校验;
- 异步请求如何处理。
示例中的 anonymous 只是展示上下文传递,不能作为授权判断依据。这个 Filter 也不会自动替代 Spring Security 的 SecurityContextHolder。
11.4 深层业务代码读取上下文
public String loadOrder(long orderId) {
RequestContext context = RequestContexts.current();
// 这里只演示读取;真实项目再调用订单仓储和日志组件
return "orderId=" + orderId
+ ", traceId=" + context.traceId()
+ ", userId=" + context.userId();
}
Controller 不需要把 traceId 逐层传下来:
public String getOrder(long orderId) {
return loadOrder(orderId);
}
11.5 异步边界不能靠这个 Filter 自动解决
下面场景需要单独验证上下文传播:
- Spring @Async;
- MVC Callable、DeferredResult;
- Servlet 异步 dispatch;
- 自定义线程池;
- 消息队列消费者;
- 定时任务;
- WebFlux / Reactor。
例如:
@Async
public void asyncTask() {
// 不能仅因为 Web 入口绑定过,就假设这里仍有 RequestContext
}
对于受管异步组件,应优先使用框架提供的任务装饰器或上下文传播机制;对于自定义线程池,可以使用前文“提交时捕获、执行时重绑定”的适配方式。
在 Reactor 中,请求处理可能跨多个线程,方法返回时异步流甚至可能尚未开始执行。围绕一次 Mono 或 Flux 的装配调用绑定 Scoped Value,不会自动覆盖后续异步执行阶段,应使用 Reactor Context 和相应框架机制。
十二、与 MDC 日志上下文如何配合
很多项目已经使用 SLF4J MDC 输出 %X{traceId}。
迁移 Scoped Value 后,不要默认日志框架会自动读取它。是否需要保留 MDC,取决于日志实现和现有组件:
- 业务代码改用 Scoped Value;
- 日志 Pattern 仍读取 MDC;
- 第三方库仍从 MDC 取 trace ID;
- 异步日志和异步任务另有传播配置。
过渡期可以在请求入口同时绑定两者:
ScopedValue.where(CONTEXT, context).run(() -> {
String previous = org.slf4j.MDC.get("traceId");
org.slf4j.MDC.put("traceId", context.traceId());
try {
handleRequest();
} finally {
if (previous == null) {
org.slf4j.MDC.remove("traceId");
} else {
org.slf4j.MDC.put("traceId", previous);
}
}
});
这里保存并恢复旧值,是为了兼容嵌套调用;简单粗暴地 remove() 可能把外层上下文一起清掉。
还要注意:StructuredTaskScope 子任务继承 Scoped Value,不等于 MDC 也自动继承。 如果子任务日志仍走 MDC,需要在子任务入口单独设置并清理,或者使用日志框架支持的传播方式。
长期是否能把 MDC 完全移除,要根据所用日志框架版本的真实支持情况决定,不应由 Scoped Value 的 API 状态直接推断。
十三、JUnit 5 测试:重点测试生命周期,而不只是读取值
Scoped Value 的风险主要集中在绑定边界,因此测试应覆盖:
- 作用域内可读;
- 作用域外未绑定;
- 异常后清理;
- 嵌套重绑定后恢复;
- 多线程场景是否按设计传播;
- 未绑定时的业务异常。
以下测试可以直接针对前面的键和 RequestContext 编写:
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
class ScopedValueContextTest {
record RequestContext(String traceId, long userId) {
}
private static final ScopedValue<RequestContext> CONTEXT =
ScopedValue.newInstance();
@Test
void shouldReadValueOnlyInsideScope() {
var context = new RequestContext("trace-test", 42L);
assertFalse(CONTEXT.isBound());
ScopedValue.where(CONTEXT, context).run(() -> {
assertTrue(CONTEXT.isBound());
assertEquals("trace-test", CONTEXT.get().traceId());
});
assertFalse(CONTEXT.isBound());
}
@Test
void shouldClearBindingAfterException() {
var context = new RequestContext("trace-error", 42L);
assertThrows(IllegalStateException.class, () -> {
ScopedValue.where(CONTEXT, context).run(() -> {
throw new IllegalStateException("测试异常");
});
});
assertFalse(CONTEXT.isBound());
}
@Test
void shouldRestoreOuterBindingAfterNestedScope() {
var outer = new RequestContext("trace-outer", 1L);
var inner = new RequestContext("trace-inner", 2L);
ScopedValue.where(CONTEXT, outer).run(() -> {
ScopedValue.where(CONTEXT, inner).run(() -> {
assertEquals("trace-inner", CONTEXT.get().traceId());
});
assertEquals("trace-outer", CONTEXT.get().traceId());
});
assertFalse(CONTEXT.isBound());
}
@Test
void shouldRejectReadOutsideScope() {
assertThrows(
java.util.NoSuchElementException.class,
CONTEXT::get
);
}
}
如果业务封装使用 RequestContexts.current(),还应测试它抛出的中文业务异常,而不是只测试底层 CONTEXT.get()。
跨线程测试不要仅断言“任务最终成功”,还要明确断言:
- 使用 StructuredTaskScope 时子任务读到预期值;
- 普通线程池未适配时不会错误假设继承;
- 适配器重绑定后任务结束,工作线程不保留绑定;
- 子任务重绑定不会修改父任务看到的值。
结构化并发测试在 Java 25 中同样需要为测试 JVM 启用对应预览特性。
十四、性能与内存:为什么高效,以及何时可能变慢
14.1 为什么适合大量结构化子线程
InheritableThreadLocal 的可继承数据需要在创建子线程时处理并复制相应映射,因为父子线程都可以独立 set()。
Scoped Value 的绑定在有界作用域内不可随意修改,结构化子线程可以共享绑定信息。官方实现说明将其继承成本概括为接近复制一个引用,而不是复制整张上下文表。
这尤其适合:
一个请求上下文
↓
大量结构化子任务
↓
共享同一个不可变上下文快照
但“继承便宜”不代表创建线程、执行业务和访问下游都没有成本。
14.2 get() 的快速路径依赖小型线程本地缓存
Java 25 实现文档说明:
因此不要把每个小字段都设计成独立 Scoped Value:
// 大量碎片键会增加管理和缓存压力
TRACE_ID
USER_ID
TENANT_ID
LOCALE
CLIENT_IP
REQUEST_TIME
// …
如果它们总是共同出现,聚合成一个不可变上下文通常更合适:
ScopedValue<RequestContext>
14.3 Java 25 中的实现调优参数不是常规业务开关
Java 25 参考实现提供:
| java.lang.ScopedValue.cacheSize | 控制每线程 Scoped Value 缓存大小 | 默认 16,可配置为 2~16 的 2 的幂 |
| jdk.preserveScopedValueCache | 虚拟线程阻塞时是否保留 Scoped Value 缓存 | 默认 true;关闭可能省内存,但恢复后需重建缓存 |
这些属于实现说明,不应视为所有 JDK、所有未来版本都保证不变的通用规范。
常规项目不应一上来就添加这些参数。只有在性能分析证明缓存查找或大量阻塞虚拟线程的内存占用确实是瓶颈后,才值得做对照实验。
14.4 不要用几个 System.nanoTime 就宣布性能结论
比较 ThreadLocal 与 Scoped Value 时,至少应区分:
- 单次读取延迟;
- 深层调用读取;
- 大量不同键导致的缓存竞争;
- 子任务创建与上下文继承成本;
- 百万级虚拟线程的内存占用;
- 上下文对象本身的大小;
- 吞吐量、尾延迟和 GC,而不仅是单次 get()。
微基准建议使用 JMH,并分别测量“读取”和“创建子任务”路径。业务收益则要在真实请求、真实线程模型和真实上下文大小下验证。
Scoped Value 的设计优势不等于每个应用迁移后都会更快。 对低并发、少量上下文的系统,最明显的收益可能只是代码更容易推理和测试。
十五、ThreadLocal 与 Scoped Value 全面对比
| 基本角色 | 每线程可变变量 | 动态作用域中的只读绑定键 |
| 写入方式 | set(),可在任意时刻调用 | where(…).run/call,必须有执行边界 |
| 当前作用域重新赋值 | 可以直接修改 | 不能 set,只能嵌套重绑定 |
| 默认未设置读取 | 通常返回 null | get() 抛 NoSuchElementException |
| 清理方式 | 手动 remove(),或等待线程结束 | 操作结束后自动解除或恢复外层绑定 |
| 嵌套上下文 | 手动保存旧值并恢复 | 自动恢复外层绑定 |
| 数据流 | 可双向、可跨任意方法修改 | 主要用于调用者向被调用者单向传递 |
| 跨线程继承 | InheritableThreadLocal 在线程创建时处理 | Java 25 通过 StructuredTaskScope 结构化继承 |
| 普通线程池任务 | 不自动按任务继承 | 同样不自动按任务继承 |
| 虚拟线程 | 支持 | 支持 |
| 每线程长期缓存 | 可以 | 不适合,绑定应随操作结束 |
| 异步流 | 需要框架适配 | 同样需要框架适配 |
| 值本身不可变 | 不保证 | 也不保证,需要自行设计 |
| Java 25 状态 | 长期正式 API | 正式 API |
最值得记住的不是“谁替代谁”,而是:
ThreadLocal 是通用的每线程可变存储;Scoped Value 是有边界的单向上下文传递。
两者能力集合不同。
十六、从 ThreadLocal 迁移的实用步骤
第一步:先给现有用途分类
搜索所有 ThreadLocal 和 InheritableThreadLocal,按用途分类:
| 请求 trace ID、只读用户快照 | 通常适合 |
| 只读租户上下文、语言环境 | 通常适合 |
| 递归检测、嵌套事务标记 | 通常适合,利用 isBound 或重绑定 |
| 深层被调用者通过 set 向调用者回传数据 | 不适合直接迁移,应先重构数据流 |
| 每线程缓存昂贵、可变对象 | 通常不适合 |
| 任意异步任务之间共享 | 不能只靠 Scoped Value 解决 |
| 为兼容老框架保存的 MDC、SecurityContext | 按框架支持逐步处理 |
第二步:把上下文改成真正不可变的数据快照
优先使用 record,并检查:
- 集合是否 copyOf;
- 日期时间是否使用不可变类型;
- 是否包含可写 POJO;
- 是否包含连接、流、锁等生命周期资源;
- 是否无意保存了整个 HTTP Request;
- 是否保存了过大的对象图。
第三步:把键封装起来
推荐:
private static final ScopedValue<RequestContext> CONTEXT;
通过少量公开方法控制读取和绑定,避免所有模块都能取得键。
第四步:先改同步入口
从一个明确的同步入口开始:
ScopedValue.where(CONTEXT, context).run(() -> {
// 整个同步业务调用链
});
验证正常返回、业务异常和系统异常后的绑定状态。
第五步:单独处理线程切换
列出所有:
- new Thread;
- 虚拟线程创建;
- 线程池;
- @Async;
- CompletableFuture;
- 并行流;
- 调度器;
- Reactor / RxJava;
- 消息回调。
能使用结构化并发的,明确父子生命周期后使用 StructuredTaskScope;必须使用旧线程池的,提交时捕获并重新绑定;响应式链路使用对应上下文机制。
第六步:保留必要桥接,再逐步移除旧代码
MDC、安全框架和第三方库不一定同步支持 Scoped Value。迁移期间允许桥接存在,但要有明确边界:
业务代码:Scoped Value
日志输出:暂时桥接 MDC
安全认证:继续使用安全框架自己的上下文
旧线程池:显式任务装饰器
不要一次重写整个系统的上下文体系。
第七步:补测试和观测
至少验证:
- 连续请求不会串上下文;
- 异常请求后没有残留;
- 嵌套调用恢复正确;
- 多用户并发不会串用户;
- 子任务行为符合设计;
- 日志 trace ID 与业务上下文一致;
- 内存、吞吐和尾延迟没有异常回退。
十七、最常见的误用与排查
误区 1:把 Java 21~23 示例直接复制到 Java 25
早期资料可能出现:
ScopedValue.runWhere(...);
ScopedValue.callWhere(...);
ScopedValue.getWhere(...);
Java 25 最终 API 应使用:
ScopedValue.where(KEY, value).run(...);
ScopedValue.where(KEY, value).call(...);
其中 runWhere、callWhere 在 Java 24 第四次预览时已移除,getWhere 更早被移除。
误区 2:在 run 外读取,疑惑为什么抛异常
ScopedValue.where(KEY, value).run(task);
KEY.get(); // 已解绑
应把读取放进操作内部,或通过 call 把结果返回:
var result = ScopedValue.where(KEY, value).call(() -> {
return buildResult(KEY.get());
});
误区 3:把 orElse 当成 get 的通用空值保护
KEY.orElse(defaultValue);
它只处理“未绑定”,不会处理“已绑定 null”,而且 Java 25 不允许 orElse(null)。
误区 4:用 orElse 掩盖必需的上下文缺失
long userId = USER_ID.orElse(0L);
如果 0 不是合法且有明确语义的匿名用户,这可能把入口错误变成错误数据。身份、租户等安全相关上下文通常应快速失败。
误区 5:认为虚拟线程天然继承
普通 Thread.startVirtualThread 不等于 StructuredTaskScope.fork,不会自动继承 Scoped Value。
误区 6:把任务提交到线程池,期待捕获提交现场
工作线程只看到自己执行时的绑定。必须在提交线程捕获值,再在工作线程重绑定。
误区 7:先打开 StructuredTaskScope,再临时绑定上下文
继承绑定在打开 scope 时捕获。应先 where(…).run/call,再在其内部打开 scope。
误区 8:只让 record 外壳不可变,内部集合仍可写
跨线程共享前应检查整个对象图,而不是只看字段是不是 final。
误区 9:公开 ScopedValue 键
键是一种访问能力。公开键意味着其他代码可以读取和嵌套重绑定。优先通过私有键与公开语义方法封装。
误区 10:把绑定自动清理理解为对象立即销毁
自动清理的是绑定关系。对象仍可能被调用栈、缓存、静态集合或排队任务引用。
误区 11:围绕异步流装配绑定,期待执行阶段可见
Mono、Flux、回调和 CompletableFuture 的实际执行可能发生在其他线程、其他时间,必须采用对应异步上下文传播方案。
误区 12:一次性替换所有 ThreadLocal
每线程缓存、双向数据流、非结构化生命周期并不符合 Scoped Value 的目标。强行替换只会让代码更绕。
误区 13:绑定几十个独立键
大量循环使用的 Scoped Value 可能降低小型缓存命中率。共同出现的字段优先聚合成一个不可变对象。
误区 14:把它当成授权边界
上下文可以被正确封装和传递,不代表用户已经认证,也不代表租户校验已经完成。授权仍要由可信认证结果和明确业务规则完成。
十八、适合与不适合的典型场景
适合:请求上下文
Web 入口创建不可变 RequestContext
↓
Controller / Service / Repository / Audit 读取
↓
请求结束后自动解绑
适合:递归或重入检测
if (IN_OPERATION.isBound()) {
throw new IllegalStateException("当前操作不允许重入");
}
ScopedValue.where(IN_OPERATION, Boolean.TRUE).run(() -> {
doOperation();
});
绑定只在操作期间存在,比手动维护布尔 ThreadLocal 更清晰。
适合:扁平化嵌套事务
外层已经绑定事务上下文时,内层操作可以选择加入外层,而不是创建独立事务。具体事务管理仍由事务框架负责,Scoped Value 只负责上下文可见性。
适合:结构化并发中的只读快照
多个子任务读取同一个不可变用户、租户或追踪快照,不需要复制每份上下文映射。
不适合:SimpleDateFormat 式每线程缓存
历史上常用 ThreadLocal 缓存非线程安全且创建昂贵的对象。Scoped Value 的有界绑定不适合这种长期缓存。
不过对于这个具体例子,更好的现代方案通常是直接使用不可变、线程安全的 DateTimeFormatter,而不是继续缓存 SimpleDateFormat。
不适合:可变累加器
如果多个子任务需要共同更新计数、列表或批处理状态,应使用明确的并发集合、原子变量、结果归并或其他并发设计,不要把可变对象绑进 Scoped Value 后当作同步工具。
不适合:任意生命周期的全局状态
如果数据无法回答“从哪个操作开始,到哪个操作结束”,它通常不符合 Scoped Value 的模型。
十九、为什么 API 没有选择 try-with-resources
看到“有作用域、自动清理”,很容易想象这样的 API:
// 这只是设想,不是 Java 25 ScopedValue API
try (var scope = CONTEXT.bind(context)) {
// …
}
JEP 506 解释了设计取舍:如果依赖用户调用 close(),就无法像 run/call 一样可靠保证完整性;即使在 try-with-resources 中按设计调用,也可能在关键位置遇到 StackOverflowError,使状态恢复变得复杂。
最终 API 选择接收 Runnable 或 CallableOp:
ScopedValue.where(CONTEXT, context).run(operation);
由库控制进入、执行和退出过程,从而可靠恢复绑定。
这也解释了为什么 Scoped Value 的代码天然呈现“围绕一个操作”的结构,而不是“先打开,之后在某处记得关闭”。
二十、版本演进与资料兼容性
| Java 20 | JEP 429 | 孵化 API,早期包名和使用方式与当前不同 |
| Java 21 | JEP 446 | 首次预览,进入标准预览 API 体系 |
| Java 22 | JEP 464 | 第二次预览,API 无变化 |
| Java 23 | JEP 481 | 第三次预览;调整 callWhere 的操作类型以支持受检异常推断,移除 getWhere |
| Java 24 | JEP 487 | 第四次预览;移除 runWhere、callWhere,统一为 Carrier 的流式 run/call |
| Java 25 | JEP 506 | 正式发布;orElse 不再接受 null |
因此,阅读旧文章时至少要检查三点:
Java 25 的 StructuredTaskScope 已采用 StructuredTaskScope.open() 等工厂方式,而且它仍是预览 API。不要把旧版 new StructuredTaskScope.ShutdownOnFailure() 示例直接当作 Java 25 最终写法。
二十一、生产落地检查清单
API 与版本
- 使用 JDK 25 编译和运行核心 Scoped Value 代码。
- 核心代码只使用 where(…).run/call,没有复制旧版静态便捷方法。
- 涉及 StructuredTaskScope 时,明确标注并启用 Java 25 预览。
- 没有把预览 API 的状态误写成正式特性。
数据设计
- 上下文对象真正不可变,包括内部集合和引用对象。
- 没有把连接、流、锁或整个 HTTP Request 塞进上下文。
- 多个共同出现的字段已合理聚合,没有创建大量碎片键。
- 可空字段与“未绑定”具有明确、不同的语义。
封装与安全
- ScopedValue 键尽量私有,不随 API 返回值泄露。
- 用户和租户信息来自可信认证与授权流程。
- 缺少必需上下文时快速失败,不使用危险默认值。
- 外部 trace ID 经过长度和字符校验,避免日志注入。
生命周期与并发
- 每个入口都有明确的 run/call 边界。
- 普通线程、虚拟线程和线程池没有被误认为会自动继承。
- StructuredTaskScope 在绑定之后打开。
- 旧线程池任务在提交线程捕获上下文,并在工作线程重绑定。
- @Async、Reactor、回调、消息队列等边界有单独方案。
- 子任务有超时、取消和响应中断设计。
框架与日志
- 没有假设 Scoped Value 自动替代 SecurityContext。
- MDC 是否需要桥接已经根据真实日志链路确认。
- 子任务中的 MDC 传播单独验证。
- 异步 dispatch 和异步任务不会意外读取匿名或默认上下文。
测试与性能
- 测试覆盖正常结束、异常结束、嵌套恢复和未绑定读取。
- 覆盖多用户并发、线程池复用和子任务继承。
- 迁移前后比较吞吐、尾延迟、内存和 GC。
- 没有用少量非正式计时得出“性能提升多少倍”的结论。
- 没有未经测量就调整 Scoped Value 内部缓存参数。
二十二、总结
Scoped Values 最有价值的地方,不是少写了一个 remove(),也不是简单的性能优化,而是把上下文传递变成了代码结构中可见的事实:
ScopedValue.where(CONTEXT, context).run(() -> {
// 在这段执行过程中,下游可以读取 context
});
它用三项约束换来了更清晰的数据流:
因此,最合理的迁移口号不是“用 Scoped Value 消灭 ThreadLocal”,而是:
显式业务数据优先用参数;只读横切上下文优先考虑 Scoped Value;每线程可变缓存继续选择合适工具;异步传播尊重具体执行模型。
当请求链路、虚拟线程和结构化并发组合使用时,Scoped Value 能让“这个上下文从哪里来、谁能看到、什么时候失效”变得前所未有的清楚。这才是它在现代 Java 工程中的核心价值。
资料说明:本文依据 OpenJDK 的 JEP 429、446、464、481、487、505、506,以及 Java SE 25 的 ScopedValue、ScopedValue.Carrier、ScopedValue.CallableOp、StructuredTaskScope 官方 API 文档整理。资料核对日期:2026-11-02。文中不包含外部链接;示例重点展示 Java 25 API 和工程边界,具体框架版本仍应结合项目实际兼容性验证。
网硕互联帮助中心






评论前必须登录!
注册