《Java 并发编程·线程与内存模型》7.3 ScopedValue:JDK 25 的作用域值

ScopedValue 用词法作用域取代线程绑定:绑定不可变、出了 run/call 自动解绑、结构化子任务自动继承、普通线程不继承。本机实测 JDK 25 上它已转正无需 preview,而 StructuredTaskScope 仍需 --enable-preview,附完整编译与运行输出。

承接的问题

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 上有。所以本节的写法是:

APIJDK 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 的逐项对比

维度ThreadLocalScopedValue
值的归属线程(ThreadLocalMap)词法作用域(Carrier 链)
写入方式set,随时可改只能 where 绑定,作用域内不可变
解绑手动 remove,漏了会泄漏作用域退出自动撤销,异常也撤销
跨线程传递默认不传;InheritableThreadLocal 创建时复制一次结构化子任务自动继承,普通线程不继承
线程池行为复用导致串值/泄漏池线程不在作用域内,读不到
内存模型每线程一张表,条目只增不减绑定在栈式链上,退出即释放
未绑定读取get() 返回 nullget() 抛 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

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《Java 消息中间件·事件驱动架构》附录 B 架构决策记录模板
  2. 《Java 消息中间件·事件驱动架构》附录 A 术语表
  3. 《Java 消息中间件·事件驱动架构》15.3 全书验收