承接的问题
7.1 的 ThreadLocal 把值绑在线程上,所以要手动 remove;7.2 的 InheritableThreadLocal 想自动传给子线程,却只在线程创建时复制一次,在线程池上必然串值。
两者其实共享同一个错误前提:上下文的生命周期应该由代码结构决定,而不是由线程的创建和销毁决定。
ScopedValue 换了一种模型:值绑定在一段词法作用域上——where(...).run(...) 的大括号里。进入作用域时绑定,离开时自动解绑,不需要 remove;子任务能不能看到,由它是不是在这个作用域里派生决定,而不是由线程是谁决定。
这一节先纠正一个版本事实,再讲 API、不可变语义、与结构化并发的关系,最后和 ThreadLocal 做一次逐项对比。
先确认:JDK 25 上它还是 preview 吗
很多资料(包括本书早期大纲)都把 ScopedValue 和 StructuredTaskScope 一起标成 preview。在本机 Temurin 25.0.5 上实测,这个说法只对后者成立。
只用 ScopedValue 的代码,--release 25 直接编译通过,运行时也不需要任何额外参数:
export JAVA_HOME=~/.jdks/jdk-25.0.5+8/Contents/Home
$JAVA_HOME/bin/javac --release 25 -d out2 ScopeOnly.java
$JAVA_HOME/bin/java -cp out2 ScopeOnly
compile_exit=0
x
run_exit=0
换成 StructuredTaskScope,同一个 javac 立刻报错:
$ javac --release 25 -d out2 ScopedDemo.java
ScopedDemo.java:1: 错误: StructuredTaskScope 是预览 API,默认情况下处于禁用状态。
import java.util.concurrent.StructuredTaskScope;
^
(请使用 --enable-preview 以启用预览 API)
用 javap -v 看类文件注解可以交叉验证:java.lang.ScopedValue 上没有 @PreviewFeature,java.util.concurrent.StructuredTaskScope 上有。所以本节的写法是:
| API | JDK 25 状态 | 编译/运行参数 |
|---|---|---|
ScopedValue | 已转正 | 无需 --enable-preview |
StructuredTaskScope | 仍是 preview | --enable-preview --release 25(编译)、--enable-preview(运行) |
结论以编译器为准,不以博客为准。 如果你手上是 JDK 24 或更早,ScopedValue 确实需要 --enable-preview;跨版本写代码前先跑一次上面那两条命令。
最小模型:where / run / call
ScopedValue 没有 set,也没有 remove。它只有三类操作:
static final ScopedValue<String> MEMBER = ScopedValue.newInstance();
| 方法 | 作用 |
|---|---|
ScopedValue.newInstance() | 创建一个 key,通常是 static final |
ScopedValue.where(key, value) | 返回一个 Carrier,描述「绑定什么」 |
Carrier.run(Runnable) | 在这组绑定下执行,无返回值 |
Carrier.call(CallableOp) | 在这组绑定下执行,有返回值,可抛受检异常 |
Carrier.where(key, value) | 再绑一个 key,形成链 |
value.get() | 读绑定;未绑定抛 NoSuchElementException |
value.isBound() / orElse(v) / orElseThrow(s) | 安全读取 |
完整示例:
public class ScopedDemo {
static final ScopedValue<String> MEMBER = ScopedValue.newInstance();
static final ScopedValue<String> REQUEST = ScopedValue.newInstance();
static String lookupMember() {
return "member-" + (MEMBER.isBound() ? MEMBER.get() : "unbound");
}
public static void main(String[] args) throws Exception {
// 1. run:无返回值
ScopedValue.where(MEMBER, "A-1024").run(() -> {
System.out.println("run : " + MEMBER.get() + " bound=" + MEMBER.isBound());
});
// 2. call:有返回值
String r = ScopedValue.where(MEMBER, "B-2048").call(ScopedDemo::lookupMember);
System.out.println("call : " + r);
// 3. 出了作用域就解绑
System.out.println("after : bound=" + MEMBER.isBound() + " orElse=" + MEMBER.orElse("<none>"));
// 4. 嵌套:内层遮蔽,退回外层恢复
ScopedValue.where(MEMBER, "outer").run(() -> {
System.out.println("outer : " + MEMBER.get());
ScopedValue.where(MEMBER, "inner").run(() -> {
System.out.println("inner : " + MEMBER.get());
});
System.out.println("back : " + MEMBER.get());
});
// 5. 多值绑定:Carrier.where 链
ScopedValue.where(MEMBER, "C-4096")
.where(REQUEST, "req-7")
.run(() -> System.out.println("pair : " + MEMBER.get() + "/" + REQUEST.get()));
// 6. 未绑定时 get 抛异常
try {
MEMBER.get();
} catch (Exception e) {
System.out.println("unbound: " + e.getClass().getName());
}
}
}
本机输出(Temurin 25.0.5,无需 preview 参数):
run : A-1024 bound=true
call : member-B-2048
after : bound=false orElse=<none>
outer : outer
inner : inner
back : outer
pair : C-4096/req-7
unbound: java.util.NoSuchElementException
三个细节值得注意:
call的签名是<R, X extends Throwable> R call(CallableOp<? extends R, X>) throws X。它比java.util.concurrent.Callable更宽松:可以抛任意Throwable子类型,包括受检异常。- 未绑定时
get()抛NoSuchElementException,不是返回null。这逼着调用方处理「值不存在」,而不是让null一路传下去。 orElse只在未绑定时返回默认值。如果显式绑定了null,isBound()为true,get()返回null,orElse也返回null——绑定null和没绑定是两件事。
bound=true get=null orElse=<null>
绑定是不可变的
ScopedValue 故意不提供 set。写了也编译不过:
ScopeEdge.java:15: 错误: 找不到符号
try { V.set("z"); } catch (Exception e) { ... }
^
符号: 方法 set(String)
位置: 类型为ScopedValue<String>的变量 V
1 个错误
那「运行中要换一个值」怎么办?答案是重新开一个作用域,而不是修改当前绑定:
ScopedValue.where(MEMBER, "outer").run(() -> {
ScopedValue.where(MEMBER, "inner").run(() -> {
System.out.println(MEMBER.get()); // inner
});
System.out.println(MEMBER.get()); // outer
});
内层 where 不是「修改」,是遮蔽:它创建了一个新的绑定链,作用域退出后自动回到外层。这带来两个好处:
- 读到的值在一次
run/call内永远不变,不需要考虑「谁在什么时候改的」; - 绑定关系随调用栈变化,天然可嵌套,不会出现 7.2 里「父线程改了、子线程没跟上」的半更新。
同一个 Carrier 链上重复绑定同一个 key 也不会报错,后者覆盖前者(实测输出 y),但这只发生在构建 Carrier 时,运行期依然没有任何写入口。
作用域怎么结束
结束条件只有一个:run/call 返回或抛出异常。没有别的路径,也就不存在「忘了 remove」。
实测异常路径:
try {
ScopedValue.where(V, "x").run(() -> { throw new IllegalStateException("boom"); });
} catch (IllegalStateException e) {
System.out.println("after throw bound=" + V.isBound());
}
after throw bound=false
绑定在异常展开时被撤销,等价于 finally 的语义。这一点比 ThreadLocal 强:ThreadLocal 需要你手写 try/finally remove,漏写就是泄漏;ScopedValue 把 finally 内建进了 API。
代价是不能把绑定带出作用域。下面这种写法拿不到值:
// 错误:作用域结束后才读
ScopedValue.where(MEMBER, "A").run(() -> { /* 什么都不做 */ });
String v = MEMBER.orElse("<none>"); // <none>
也不能把 ScopedValue 当成可变的请求缓存:它只读、只在作用域内有效。
结构化并发下的传递
这是 ScopedValue 与 InheritableThreadLocal 最大的区别。
在绑定作用域内用 StructuredTaskScope.fork 派生子任务,子任务自动看到父任务的绑定:
ScopedValue.where(MEMBER, "E-16384").run(() -> {
try (var scope = StructuredTaskScope.<String>open()) {
var t1 = scope.fork(() -> "sub1=" + MEMBER.orElse("<none>"));
var t2 = scope.fork(() -> "sub2=" + MEMBER.orElse("<none>"));
scope.join();
System.out.println("scope : " + t1.get() + " " + t2.get());
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
scope : sub1=E-16384 sub2=E-16384
而普通线程不继承,虚拟线程也不继承:
ScopedValue.where(MEMBER, "D-8192").run(() -> {
Thread vt = Thread.ofVirtual().start(() -> {
System.out.println("vt : virtual=" + Thread.currentThread().isVirtual()
+ " sees=" + MEMBER.orElse("<none>"));
});
vt.join();
System.out.println("parent: " + MEMBER.get());
});
vt : virtual=true sees=<none>
parent: D-8192
线程池也一样:
pool: <none>
这三个输出合起来说明一件事:ScopedValue 的传播单位是「结构化派生的子任务」,不是「新建的线程」。普通 Thread.start() 和 ExecutorService.submit() 都只是「另一个线程」,不在作用域树里,因此看不到绑定。
这正是想要的行为。回想 7.2 的事故:池线程由请求 A 创建,于是永远带着 A 的上下文。ScopedValue 把这条路堵死了——池线程不是当前作用域派生的,读不到任何绑定,只能读到 orElse 的默认值。串值从「可能发生」变成「不可能发生」。
代价是:非结构化的异步代码(自己 submit 到线程池、反应式链路)必须显式传参,不能指望 ScopedValue 自动穿透。第 13.3 节会讲这种情况怎么包装。
与 ThreadLocal 的逐项对比
| 维度 | ThreadLocal | ScopedValue |
|---|---|---|
| 值的归属 | 线程(ThreadLocalMap) | 词法作用域(Carrier 链) |
| 写入方式 | set,随时可改 | 只能 where 绑定,作用域内不可变 |
| 解绑 | 手动 remove,漏了会泄漏 | 作用域退出自动撤销,异常也撤销 |
| 跨线程传递 | 默认不传;InheritableThreadLocal 创建时复制一次 | 结构化子任务自动继承,普通线程不继承 |
| 线程池行为 | 复用导致串值/泄漏 | 池线程不在作用域内,读不到 |
| 内存模型 | 每线程一张表,条目只增不减 | 绑定在栈式链上,退出即释放 |
| 未绑定读取 | get() 返回 null | get() 抛 NoSuchElementException |
| 与虚拟线程 | 每线程一份,数量大时成本可观 | 绑定不随线程复制,适合大量虚拟线程 |
| 版本状态(JDK 25) | 正式 API | 已转正(StructuredTaskScope 仍是 preview) |
一句话概括取舍:ThreadLocal 适合「线程私有的可变状态」,ScopedValue 适合「一次调用链内只读的上下文」。前者像线程的字段,后者像隐式参数。
在 libraryd-conc 里落地
把 7.2 的 RequestContext 从方法参数改成作用域值,同时保留显式参数作为可选入口:
public final class ScopedLoanService implements LoanService {
public static final ScopedValue<RequestContext> CTX = ScopedValue.newInstance();
private final BookRepository books;
private final LoanRepository loans;
@Override
public Reservation reserve(String isbn, String memberId) {
RequestContext ctx = new RequestContext(
UUID.randomUUID().toString(), memberId, "default");
return ScopedValue.where(CTX, ctx).call(() -> doReserve(isbn));
}
private Reservation doReserve(String isbn) {
try (var scope = StructuredTaskScope.<Object>open()) {
var bookTask = scope.fork(() -> books.findByIsbn(isbn)
.orElseThrow(() -> new LoanException(ErrorCode.BOOK_NOT_FOUND, isbn)));
var quotaTask = scope.fork(() -> quotaOf(CTX.get().memberId()));
scope.join();
Book book = bookTask.get();
int quota = quotaTask.get();
if (quota <= 0) {
throw new LoanException(ErrorCode.QUOTA_EXCEEDED, CTX.get().memberId());
}
if (book.stock() <= 0) {
throw new LoanException(ErrorCode.OUT_OF_STOCK, isbn);
}
books.save(new Book(book.isbn(), book.title(), book.stock() - 1));
Reservation r = new Reservation(
UUID.randomUUID().toString(), isbn, CTX.get().memberId(),
Instant.now().plusSeconds(86400));
loans.save(new Loan(r.loanId(), isbn, CTX.get().memberId(), r.dueAt()));
return r;
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new LoanException(ErrorCode.INTERRUPTED, "reserve 被中断");
}
}
private int quotaOf(String memberId) {
return 5; // 默认配额,实际从 MemberRepository 读取
}
}
这段代码需要 preview 参数,因为用了 StructuredTaskScope:
export JAVA_HOME=~/.jdks/jdk-25.0.5+8/Contents/Home
mvn -q compile -Dmaven.compiler.enablePreview=true
java --enable-preview --source 25 -cp target/classes com.plume.conc.core.Main
两个子任务都通过 CTX.get() 读取会员上下文,没有传参,也没有 ThreadLocal。scope 关掉时子任务一定结束,绑定一定撤销——不存在「任务还在跑、上下文已经没了」的窗口。
如果某个子任务需要不同的上下文(比如模拟另一个租户),不要改绑定,而是在它内部再开一层 where。
常见错误与边界
一、不要在作用域外读值。 run 返回后绑定就没了。需要把结果带出来,用 call 的返回值,而不是事后再 get。
二、不要试图跨线程池传递。 submit 到 ExecutorService 的任务读不到绑定(实测输出 <none>)。这不是缺陷,是设计:非结构化任务不应该隐式继承上下文。要传就显式捕获,见 7.2 的包装器。
三、绑定值本身仍可能可变。 ScopedValue 保证「引用不变」,不保证「对象不可变」。绑一个 ArrayList 进去,作用域内照样能被改。绑定上下文请用 record 或不可变对象。
四、不能在 run 里再 set。 没有 set。想换值就嵌套 where,想改内容就改对象——但后者会破坏只读假设。
五、性能不是零成本。 每次 where(...).run(...) 都要建立快照、跑完恢复,深度嵌套会有开销。它适合「一次请求绑定一次」这种粒度,不适合在紧循环里反复绑定。
六、preview 风险要按 API 分开评估。 ScopedValue 在 JDK 25 已转正,可以按稳定 API 对待;StructuredTaskScope 仍是 preview,编译和运行都要 --enable-preview,未来版本 API 可能变化。把两者混在一起说「都是 preview」会误导读者,也会让构建脚本多带不必要的参数。
小结
- 本机实测:JDK 25 上
ScopedValue已转正,--release 25直接编译、运行无需--enable-preview;StructuredTaskScope仍是 preview,两者状态不同。 - API 只有
newInstance、where、Carrier.run/call、get/isBound/orElse/orElseThrow;没有set、没有remove。 - 绑定在作用域内不可变;换值靠嵌套
where遮蔽,退出后自动恢复。 - 作用域在
run/call返回或抛异常时结束,异常路径同样解绑(实测bound=false),不需要finally。 - 结构化
fork的子任务自动继承绑定(实测两个子任务都读到E-16384);普通线程、虚拟线程、线程池任务都不继承(实测sees=<none>)。 - 与
ThreadLocal的分工:线程私有的可变状态用ThreadLocal,一次调用链内只读的上下文用ScopedValue。
第 8 章转入无锁编程,从 CAS 指令讲起,看原子类如何在不用锁的前提下保证复合操作的原子性。
阅读导航:上一节 7.2 InheritableThreadLocal 与线程池断裂 | 下一节 8.1 CAS 原理与 ABA
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。