传递”当前请求是谁发的”这类数据,过去的标准答案是 ThreadLocal。它在同步阻塞的世界里很好用,但遇到线程池和虚拟线程就开始漏水:值会跨请求残留、每个线程都要存一份、子线程继承要靠另一套 API。
ScopedValue 在 JDK 25 转正(JEP 506Status: Closed / DeliveredRelease: 25),它是”单向、不可变、生命周期跟着代码块走”的上下文传递方式。
这篇只讲四件事:怎么用、和线程池一起用会怎样、内存是谁按住的、以及什么情况下不该迁移。

实验环境

1
2
3
4
$ java -version
java 25 2025-09-16 LTS
Java(TM) SE Runtime Environment (build 25+37-LTS-3491)
Java HotSpot(TM) 64-Bit Server VM (build 25+37-LTS-3491, mixed mode, sharing)

ScopedValue 在 JDK 25 已经是正式特性,不需要 --enable-preview;而同期的 StructuredTaskScope 还是预览 API,需要 --enable-preview 两个强绑定的 API 处于不同的成熟度阶段,写代码时注意源码文件的编译参数不能混。

1
2
3
4
5
# 只用 ScopedValue:直接编译
javac --release 25 ScopedValueDemo.java

# 同时用到结构化并发:整个文件都要加预览开关
javac --enable-preview --release 25 MixedDemo.java

一、ThreadLocal 的三个设计缺陷

JEP 506 把 ThreadLocal 的问题总结成三条:

Unconstrained mutability — Every thread-local variable is mutable: Any code that can call the get method of a thread-local variable can call the set method of that variable at any time.
Unbounded lifetime — Once a thread’s copy of a thread-local variable is set via the set method, the value to which it was set is retained for the lifetime of the thread, or until code in the thread calls the remove method.
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
2
3
4
5
6
static final ThreadLocal<byte[]> TL = new ThreadLocal<>();

var pool = Executors.newFixedThreadPool(1);
pool.submit(() -> TL.set(new byte[32 * 1024 * 1024])).get();
System.out.println("任务结束、引用已丢弃,线程池存活期间的占用 = " + usedMB() + "MB");
pool.shutdown();
1
2
3
4
5
6
7
8
9
堆上限 = 512MB
基线占用 = 1MB

=== ThreadLocal:值挂在线程池的线程上 ===
任务结束、引用已丢弃,线程池存活期间的占用 = 34MB(基线 +33MB,值被池线程的 ThreadLocalMap 持有)

=== ScopedValue:值随作用域结束释放 ===
作用域内占用 = 34MB
出作用域后占用 = 1MB(回到基线附近)

任务早就结束了,32MB 还挂着——因为持有它的是线程池里那个长期存活的线程。ThreadLocal.remove() 是唯一的解药,而”忘记 remove”是线上最经典的内存泄漏之一。换成 ScopedValue,出作用域那一刻引用就断了,没有任何需要记住的清理动作。

1.3 继承要另起炉灶,而且时机敏感

InheritableThreadLocal 的名字暗示”子线程能继承”,但继承发生在线程创建的那一刻

1
2
3
4
5
6
7
8
9
10
static final InheritableThreadLocal<String> ITL = new InheritableThreadLocal<>();

var pool = Executors.newFixedThreadPool(1);
pool.submit(() -> {}).get(); // 先把唯一的线程建出来
ITL.set("alice"); // 之后再设值
System.out.println(pool.submit(() -> ITL.get()).get());

var pool2 = Executors.newFixedThreadPool(1);
ITL.set("bob");
System.out.println(pool2.submit(() -> ITL.get()).get());
1
2
池线程(早于 set 创建)读到 InheritableThreadLocal = null
池线程(晚于 set 创建)读到 InheritableThreadLocal = bob

同一段代码,同样的 set,结果一个是 null、一个是 bob,差别只在池线程是什么时候被创建的。这种”值是否可见取决于线程池预热情况”的语义,在生产环境里就是随机故障。

二、ScopedValue 的用法

2.1 声明与绑定

1
2
3
4
5
6
static final ScopedValue<String> REQUEST_ID = ScopedValue.newInstance();
static final ScopedValue<String> TENANT = ScopedValue.newInstance();

ScopedValue.where(REQUEST_ID, "req-001").run(() -> {
System.out.println(handle()); // 直接调用,不需要把 requestId 当参数传下去
});

where() 返回一个 Carrierrun() 执行代码块并在此期间完成绑定;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
2
3
4
5
System.out.println("作用域外 isBound=" + REQUEST_ID.isBound());
try { REQUEST_ID.get(); } catch (Exception e) { System.out.println("作用域外 get() 抛: " + e); }
System.out.println("orElse 兜底: " + REQUEST_ID.orElse("默认值"));
try { REQUEST_ID.orElseThrow(() -> new IllegalStateException("缺少 requestId")); }
catch (Exception e) { System.out.println("orElseThrow 兜底: " + e.getMessage()); }
1
2
3
4
作用域外 isBound=false
作用域外 get() 抛: java.util.NoSuchElementException: ScopedValue not bound
orElse 兜底: 默认值
orElseThrow 兜底: 缺少 requestId
  • get():未绑定抛 NoSuchElementException(不是 NullPointerException,也不是 IllegalStateException
  • orElse(v):给默认值。注意 JDK 25 收紧了一处——orElse 不再接受 null 作为参数(JEP 506 原文列出的唯一变化)
  • orElseThrow(supplier):自定义异常,适合”某些上下文必须存在”的场景

2.3 重绑定只对内层可见

1
2
3
4
5
6
7
8
9
10
ScopedValue.where(REQUEST_ID, "req-001").run(() -> {
System.out.println(handle()); // req-001
ScopedValue.where(TENANT, "租户A").run(() -> {
System.out.println(handle()); // req-001 + 租户A
ScopedValue.where(REQUEST_ID, "req-002").run(() ->
System.out.println(handle())); // req-002 + 租户A
System.out.println(handle()); // 回到 req-001
});
System.out.println(handle()); // 租户A 已不可见
});
1
2
3
4
5
6
7
绑定后 isBound=true
requestId=req-001 tenant=(未绑定)
嵌套内层: requestId=req-001 tenant=租户A
再嵌套(重绑定 requestId): requestId=req-002 tenant=租户A
回到内层: requestId=req-001 tenant=租户A
回到外层(TENANT 已不可见): requestId=req-001 tenant=(未绑定)
作用域结束后 isBound=false

重绑定只影响嵌套的内层,退出内层后自动恢复外层值,退出最外层后回到未绑定。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
2
String r = ScopedValue.where(REQUEST_ID, "req-003").call(() -> "由 " + REQUEST_ID.get() + " 处理完成");
// call() 返回: 由 req-003 处理完成

callrun 的唯一区别是有返回值,并且可以抛受检异常(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 as ForkJoinPool do 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
2
3
4
5
6
ScopedValue.where(USER, "alice").run(() -> {
try (var pool = Executors.newVirtualThreadPerTaskExecutor()) {
var f = pool.submit(() -> USER.get()); // 抛 NoSuchElementException: ScopedValue not bound
System.out.println(f.get());
} catch (Exception e) { throw new RuntimeException(e); }
});
1
2
Exception in thread "main" java.lang.RuntimeException: java.util.concurrent.ExecutionException:
java.util.NoSuchElementException: ScopedValue not bound

正确写法是用 fork()(需要 --enable-preview):

1
2
3
4
5
6
7
8
9
ScopedValue.where(USER, "alice").run(() -> {
try (var scope = StructuredTaskScope.open()) {
var a = scope.fork(() -> "子任务1(" + whoAmI() + ") 读到 " + USER.get());
var b = scope.fork(() -> "子任务2(" + whoAmI() + ") 读到 " + USER.get());
scope.join();
System.out.println(a.get());
System.out.println(b.get());
} catch (Exception e) { throw new RuntimeException(e); }
});
1
2
子任务1(虚拟线程) 读到 alice
子任务2(虚拟线程) 读到 alice

结论:ScopedValueStructuredTaskScope 是一对,拆开用会缺一半。单独的 ScopedValue 只能在同一个线程内传递;要跨线程传递,就得让线程从 fork 出来。

四、线程池里的残留对比

ThreadLocal 最经典的 bug 是”上一个请求的值被下一个请求读到”。把两种情况放在一起看:

1
2
3
4
var pool = Executors.newFixedThreadPool(1);
pool.submit(() -> TL.set("alice")).get();
System.out.println(pool.submit(() -> "下一个任务读到 ThreadLocal = " + TL.get()).get());
System.out.println(pool.submit(() -> "ScopedValue 在这里 isBound = " + USER.isBound()).get());
1
2
下一个任务在同一个线程上读到 ThreadLocal = alice(上一个请求留下的)
ScopedValue 在这里 isBound = false(跨作用域天然读不到)

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
2
# 每线程的 scoped-value 缓存大小:默认 16 个条目,可调 2~16,必须是 2 的幂
java -Djava.lang.ScopedValue.cacheSize=8 -cp app.jar Main
  • 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:

  1. JEP 506 标题没有 (Preview) 后缀,Summary 里没有 This is a preview API. 这句话(21/23/24 三个版本的 Summary 都有)
  2. History 章节原文:We here propose to finalize the scoped values API in JDK 25
  3. javadoc 里类声明为 public final class ScopedValue<T>,页脚 Since: 25,且页面没有 preview 标记——同一页面上引用到的 StructuredTaskScopeStructureViolationException 都带着 [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 才是正式版本

参考资料

系列索引:Java 系列,语言特性与运行时的长文集