Java——ScopedValue 替代 ThreadLocal
传递”当前请求是谁发的”这类数据,过去的标准答案是
ThreadLocal。它在同步阻塞的世界里很好用,但遇到线程池和虚拟线程就开始漏水:值会跨请求残留、每个线程都要存一份、子线程继承要靠另一套 API。ScopedValue在 JDK 25 转正(JEP 506,Status: Closed / Delivered、Release: 25),它是”单向、不可变、生命周期跟着代码块走”的上下文传递方式。
这篇只讲四件事:怎么用、和线程池一起用会怎样、内存是谁按住的、以及什么情况下不该迁移。
实验环境
1 | $ java -version |
ScopedValue 在 JDK 25 已经是正式特性,不需要 --enable-preview;而同期的 StructuredTaskScope 还是预览 API,需要 --enable-preview。 两个强绑定的 API 处于不同的成熟度阶段,写代码时注意源码文件的编译参数不能混。
1 | # 只用 ScopedValue:直接编译 |
一、ThreadLocal 的三个设计缺陷
JEP 506 把 ThreadLocal 的问题总结成三条:
Unconstrained mutability — Every thread-local variable is mutable: Any code that can call the
getmethod of a thread-local variable can call thesetmethod of that variable at any time.
Unbounded lifetime — Once a thread’s copy of a thread-local variable is set via thesetmethod, the value to which it was set is retained for the lifetime of the thread, or until code in the thread calls theremovemethod.
Expensive inheritance — the child thread has to allocate storage for every thread-local variable previously written in the parent thread.
三条我都用实测确认过。
1.1 不可变性的缺失
ThreadLocal 的调用方和被调用方共享同一个可写句柄:任何一层代码都能 set,而读取方无从得知这个值是不是自己期望的那个”请求上下文”。这是可见性失控。
1.2 生命周期跟着线程走:32MB 被谁按住
写一个典型的”框架 set、业务 get”的场景,然后让任务结束、把所有引用丢掉:
1 | static final ThreadLocal<byte[]> TL = new ThreadLocal<>(); |
1 | 堆上限 = 512MB |
任务早就结束了,32MB 还挂着——因为持有它的是线程池里那个长期存活的线程。ThreadLocal.remove() 是唯一的解药,而”忘记 remove”是线上最经典的内存泄漏之一。换成 ScopedValue,出作用域那一刻引用就断了,没有任何需要记住的清理动作。
1.3 继承要另起炉灶,而且时机敏感
InheritableThreadLocal 的名字暗示”子线程能继承”,但继承发生在线程创建的那一刻:
1 | static final InheritableThreadLocal<String> ITL = new InheritableThreadLocal<>(); |
1 | 池线程(早于 set 创建)读到 InheritableThreadLocal = null |
同一段代码,同样的 set,结果一个是 null、一个是 bob,差别只在池线程是什么时候被创建的。这种”值是否可见取决于线程池预热情况”的语义,在生产环境里就是随机故障。
二、ScopedValue 的用法
2.1 声明与绑定
1 | static final ScopedValue<String> REQUEST_ID = ScopedValue.newInstance(); |
where() 返回一个 Carrier,run() 执行代码块并在此期间完成绑定;Carrier 是不可变的(javadoc 原文:A Carrier is immutable and thread-safe. The where method returns a new Carrier object, it does not mutate an existing mapping.),想绑多个值就链式叠加:
1 | ScopedValue.where(REQUEST_ID, "req-001").where(TENANT, "租户A").run(() -> { ... }); |
2.2 未绑定时会发生什么
这是选 API 时的第一个实际问题:读不到值是抛异常还是给默认值?两者都支持。
1 | System.out.println("作用域外 isBound=" + REQUEST_ID.isBound()); |
1 | 作用域外 isBound=false |
get():未绑定抛NoSuchElementException(不是NullPointerException,也不是IllegalStateException)orElse(v):给默认值。注意 JDK 25 收紧了一处——orElse不再接受null作为参数(JEP 506 原文列出的唯一变化)orElseThrow(supplier):自定义异常,适合”某些上下文必须存在”的场景
2.3 重绑定只对内层可见
1 | ScopedValue.where(REQUEST_ID, "req-001").run(() -> { |
1 | 绑定后 isBound=true |
重绑定只影响嵌套的内层,退出内层后自动恢复外层值,退出最外层后回到未绑定。JEP 原文的表述是”the body of bar cannot change the binding seen by that method itself but can change the binding seen by its callees”——你可以给下游换个值,但改不了自己看到的值。这保证了共享值的生命周期在语法结构上一眼可见。
2.4 call:需要返回值时
1 | String r = ScopedValue.where(REQUEST_ID, "req-003").call(() -> "由 " + REQUEST_ID.get() + " 处理完成"); |
call 与 run 的唯一区别是有返回值,并且可以抛受检异常(CallableOp<T, X extends Throwable>)。
三、继承规则:只认 fork
这是全文最容易踩的一条。我在同一个作用域里用七种方式新建线程,让每个线程都去读父线程绑定的值:
| 子线程的创建方式 | 读到的值 |
|---|---|
StructuredTaskScope.fork(...) |
alice |
Thread.ofVirtual().start(...) |
未绑定 |
Thread.ofPlatform().start(...) |
未绑定 |
Thread.startVirtualThread(...) |
未绑定 |
Executors.newVirtualThreadPerTaskExecutor() |
未绑定 |
Executors.newFixedThreadPool(1) |
未绑定 |
ForkJoinPool.commonPool() |
未绑定 |
也就是说,只有结构化并发的子任务能继承绑定。JEP 506 原文对此有明确解释:
Scoped values in the parent thread are automatically inherited by child threads created with
StructuredTaskScope. … Legacy thread management classes such asForkJoinPooldo not support inheritance of scoped values because they cannot guarantee that a child thread forked from some parent thread scope will exit before the parent leaves that scope.
JDK 25 的 javadoc 补了一句更精确的:绑定是在创建 StructuredTaskScope 时被捕获,随后由该作用域中所有通过 fork 启动的线程继承。
所以下面这个非常自然的写法是错的:
1 | ScopedValue.where(USER, "alice").run(() -> { |
1 | Exception in thread "main" java.lang.RuntimeException: java.util.concurrent.ExecutionException: |
正确写法是用 fork()(需要 --enable-preview):
1 | ScopedValue.where(USER, "alice").run(() -> { |
1 | 子任务1(虚拟线程) 读到 alice |
结论:ScopedValue 和 StructuredTaskScope 是一对,拆开用会缺一半。单独的 ScopedValue 只能在同一个线程内传递;要跨线程传递,就得让线程从 fork 出来。
四、线程池里的残留对比
ThreadLocal 最经典的 bug 是”上一个请求的值被下一个请求读到”。把两种情况放在一起看:
1 | var pool = Executors.newFixedThreadPool(1); |
1 | 下一个任务在同一个线程上读到 ThreadLocal = alice(上一个请求留下的) |
ThreadLocal 的第一个任务没有 remove(),于是第二个任务读到的是 alice。只要不写 remove(),这就一定会发生。而 ScopedValue 的绑定出不了作用域,不存在忘记清理的可能——这也是它最大的价值:把一个纪律问题变成结构问题。
五、该不该迁移:一张决策表
JEP 506 的 Non-Goals 里明确写了”不要求迁移、不废弃 ThreadLocal”,所以这是个需要按场景判断的决定:
| 场景 | 建议 |
|---|---|
| 请求上下文(用户、租户、traceId)单向向下传递 | 迁移,这是 ScopedValue 的主场 |
| 框架层 set、深层业务 get,中间层不关心 | 迁移,省掉一层参数或一个 ThreadLocal |
| 需要跨线程传递 | 迁移 + 改造成结构化并发(只迁 ScopedValue 不解决问题) |
缓存的昂贵对象(如 SimpleDateFormat) |
不迁移,线程级缓存仍是 ThreadLocal 更合适 |
| 双向传递(深层代码 set、远处调用方读取) | 不迁移,ScopedValue 没有 set |
| 需要在作用域外长期保留值 | 不迁移,生命周期由作用域决定正是它的设计 |
JEP 还提示了一个替代方案:很多 SimpleDateFormat 缓存场景可以直接换成可共享的 DateTimeFormatter(放在 static final 里),根本不需要线程级存储。
六、性能与调优
ScopedValue.get() 的开销很小——JEP 原文的说法是”often as fast as reading a local variable, regardless of the stack distance between caller and callee”,读取走的是每线程一份的小缓存,而不是哈希查找。
我尝试用”10 万个虚拟线程各自读一次值”来测量 ThreadLocal 与 ScopedValue 的每线程开销差,结论是这个实验在这个规模下测不出稳定信号(两次运行的堆增差值是负的,落在 GC 噪声里)。诚实的说法是:单线程的 ThreadLocal 开销本来就很小,主要差别在 1.2 节测到的生命周期语义(32MB 持有 vs 立即释放),每线程几十字节的存储差异可以忽略。
JDK 25 提供了两个与缓存相关的系统属性(来自 javadoc 的 Implementation Note):
1 | # 每线程的 scoped-value 缓存大小:默认 16 个条目,可调 2~16,必须是 2 的幂 |
java.lang.ScopedValue.cacheSize:默认 16。这是一个”空间换速度”的旋钮——绑定数量极少时调小可以省内存,绑定多且访问频繁时保持默认jdk.preserveScopedValueCache:默认true,控制虚拟线程阻塞时是否保留每线程缓存。默认值对绝大多数场景是对的,改它之前先用 JFR 确认瓶颈真的在这里
绑定数量超过缓存容量不会报错,只是退化成更慢的查找路径——所以”绑定特别多”的场景值得测一下这个旋钮。
七、从 JDK 21 到 25:四次 API 变化
ScopedValue 从孵化到转正走了几年,其中预览期改了三次:
| JDK | JEP | 状态 | API 形态 |
|---|---|---|---|
| 21 | JEP 446 | 预览 | ScopedValue.where(V, v).run(...),另有 getWhere |
| 22 | JEP 464 | 预览 | 第二次预览 |
| 23 | JEP 481 | 预览 | 改用 runWhere / callWhere,删除 getWhere |
| 24 | JEP 487 | 预览 | 删除 callWhere / runWhere,只留 fluent 的 Carrier.run/call |
| 25 | JEP 506 | 正式 | 唯一变化:orElse 不再接受 null |
判断”是不是正式特性”有三条独立证据,我逐条核对过 openjdk.org 与 JDK 25 的 javadoc:
- JEP 506 标题没有
(Preview)后缀,Summary 里没有This is a preview API.这句话(21/23/24 三个版本的 Summary 都有) - History 章节原文:
We here propose to finalize the scoped values API in JDK 25 - javadoc 里类声明为
public final class ScopedValue<T>,页脚Since: 25,且页面没有 preview 标记——同一页面上引用到的StructuredTaskScope和StructureViolationException都带着[PREVIEW]标记,说明”没标记”是实质结论而不是样式问题
还有一个跨篇的坑:JEP 506 正文里的示例代码还在用 new StructuredTaskScope.ShutdownOnFailure() 这种旧 API(JEP 487 时代的写法),照抄到 JDK 25 上会编译失败。JEP 正文的示例不一定跟着最终 API 更新,写代码时以 javadoc 和实际编译结果为准。
总结
ScopedValue把绑定的生命周期交给代码结构:出作用域即失效,不存在忘记remove()的可能- 实测对照:ThreadLocal 在池线程上残留 32MB 且任务早已结束;ScopedValue 出作用域后堆占用从 34MB 回落到 1MB
InheritableThreadLocal的可见性取决于线程创建时刻:早于set创建读到null,晚于set创建读到值- 继承只认
fork():七种新建线程的方式里,只有StructuredTaskScope.fork能读到父作用域的绑定,虚拟机执行器、平台线程池、公共 ForkJoinPool 都读不到 get()未绑定抛NoSuchElementException;需要默认值用orElse(JDK 25 起不接受null),需要报错用orElseThrow- 重绑定只影响嵌套内层,退出即恢复——这让”这个值在这段代码里是什么”可以纯靠读代码回答
- 迁移决策看数据流向:单向向下传递就迁移,需要
set或需要线程级缓存就别迁 - 该用哪个版本的 API 取决于目标 JDK:21 与 24 的写法互不兼容,25 才是正式版本
参考资料
- JEP 506: Scoped Values
- JEP 487: Scoped Values (Fourth Preview)
- JEP 481: Scoped Values (Third Preview)
- JEP 446: Scoped Values (Preview)
- JDK 25 API:ScopedValue
系列索引:Java 系列,语言特性与运行时的长文集







