Java 基础体系 · 第 69/100 篇。示例统一以 Java 25 LTS 为语言和 JVM 基线;框架示例使用与其兼容的现代稳定版本。

Java 25 Scoped Value:上下文传播、不可变绑定与虚拟线程

ScopedValue 是 Java 25 中用于在线程执行范围内传递上下文值的 API。它解决的问题不是“如何让任意线程都能访问一个全局变量”,而是:

如何把一个只读上下文绑定到一段明确的动态执行范围,并让该范围内创建的子线程能够看到它。

典型上下文包括请求 ID、认证主体、租户标识、追踪信息、事务快照或日志上下文。Java 25 中的 Scoped Values 已成为正式能力,不再是预览 API。API 位于 java.lang 包,官方定义见 ScopedValue API


一、先区分“值”“绑定”和“上下文”

在讨论 API 之前,需要区分三个概念。

1. 值

值是业务对象本身,例如:

"alice"

或者:

new RequestContext("request-42", "tenant-a")

ScopedValue 不会复制值,也不会自动让值变得不可变。它只控制“哪个执行范围能够取得这个引用”。

2. 绑定

绑定是一个键和值之间的关系:

USER  ->  "alice"

其中 USER 是一个 ScopedValue<String> 实例,"alice" 是本次绑定的值。

3. 动态执行范围

动态执行范围是由 runcall 包围的一段执行过程:

ScopedValue.where(USER, "alice").run(() -> {
    // 这里处于 USER -> "alice" 的绑定范围内
});

进入 runcall 时,绑定生效;执行离开时,绑定失效。它的生命周期由调用栈上的执行范围决定,而不是由某个显式的 setremove 操作决定。

可以把它抽象成一个按执行范围变化的环境:

进入 scope A:{} 
绑定 USER=alice:{USER=alice}
进入嵌套 scope B:{USER=alice, REQUEST_ID=req-42}
离开 scope B:{USER=alice}
离开 scope A:{}

因此,Scoped Value 的核心不是“一个特殊的全局变量”,而是“绑定在动态作用域中的只读上下文”。


二、ScopedValue 的基本 API

一个 Scoped Value 通常声明为类级别的 static final 字段:

private static final ScopedValue<String> USER =
        ScopedValue.newInstance();

这里的 USER 是上下文键。调用 newInstance() 创建的是一个新的键,而不是创建一个已经有值的变量。

绑定值时使用:

ScopedValue.where(USER, "alice").run(() -> {
    System.out.println(USER.get());
});

输出:

alice

where 不会修改 USER,也不会返回一个新的 ScopedValue<String>。它返回一个绑定载体 ScopedValue.Carrier,随后由 runcall 执行代码。

常用方法可以概括为:

API 作用
ScopedValue.newInstance() 创建新的上下文键
ScopedValue.where(key, value) 创建包含一条绑定的 Carrier
Carrier.where(key, value) 在已有 Carrier 上追加绑定
Carrier.run(Runnable) 在绑定范围内执行无返回值任务
Carrier.call(Callable) 在绑定范围内执行有返回值任务
get() 取得当前范围内的绑定值
isBound() 判断当前范围是否存在绑定
orElse(value) 未绑定时返回默认值
orElseThrow() 未绑定时抛出异常

get() 要求当前上下文确实存在绑定:

private static final ScopedValue<String> USER =
        ScopedValue.newInstance();

public static void main(String[] args) {
    System.out.println(USER.isBound()); // false

    try {
        USER.get();
    } catch (java.util.NoSuchElementException e) {
        System.out.println("USER 未绑定");
    }
}

如果一个上下文是必需的,例如认证用户,那么直接调用 get() 通常更合适,因为缺失上下文应该尽早失败。如果上下文确实是可选的,可以使用:

String user = USER.orElse("anonymous");

不要无条件使用默认值掩盖配置或传播错误。例如,缺失认证主体时返回 "anonymous",可能会把本应拒绝的请求当作匿名请求继续处理。


三、绑定是不可变的:没有 set,也没有 remove

Scoped Value 的“不可变绑定”包含两个层面。

1. 绑定关系不可修改

下面的代码不是修改原绑定,而是创建一个嵌套的新绑定:

private static final ScopedValue<String> USER =
        ScopedValue.newInstance();

public static void main(String[] args) {
    ScopedValue.where(USER, "alice").run(() -> {
        System.out.println(USER.get()); // alice

        ScopedValue.where(USER, "bob").run(() -> {
            System.out.println(USER.get()); // bob
        });

        System.out.println(USER.get()); // alice
    });
}

执行过程如下:

外层进入:USER=alice
内层进入:USER=bob,覆盖外层对 USER 的可见值
内层退出:恢复看到 USER=alice
外层退出:USER 不再绑定

内层绑定并没有改变外层 Carrier,也没有修改 USER 对象。它只是让内层动态范围在查找 USER 时优先看到 "bob"

这类似于词法环境中的遮蔽,但它的生效范围由运行时调用路径决定,因此称为动态作用域。

2. 值本身不一定不可变

下面的绑定关系不可变,但值引用指向的对象仍然可能可变:

record RequestContext(String requestId) {}

ScopedValue<RequestContext> CONTEXT =
        ScopedValue.newInstance();

record 的字段引用不可重新赋值,但如果绑定的是一个普通可变对象:

class MutableContext {
    String requestId;
}

那么:

ScopedValue.where(CONTEXT, mutableContext).run(() -> {
    // 绑定不会改变,但 mutableContext 仍可能被其他代码修改
});

因此,Scoped Value 保证的是“上下文绑定不会被重新设置”,不是“绑定对象发生了深层复制”,也不是“对象自动线程安全”。如果需要真正的只读上下文,应使用不可变类型,或确保对象在绑定后不再修改。


四、嵌套绑定的形式化模型

可以用一个简单的环境栈描述 Scoped Value 的查找规则。

设某线程当前的动态绑定栈为:

E = [C1, C2, ..., Cn]

其中 Cn 是最内层、最近进入的绑定范围。对于键 klookup(E, k)CnC1 逆序查找:

lookup(E, k) =
    Cj[k],其中 j 是满足 Cj 包含 k 的最大下标

如果不存在这样的 j,则 get() 抛出未绑定异常。

例如:

C1 = {USER=alice}
C2 = {REQUEST_ID=req-42}
C3 = {USER=bob}

查找结果是:

lookup(E, USER)       = bob
lookup(E, REQUEST_ID) = req-42

离开 C3 后,环境变为:

[C1, C2]

此时:

lookup(E, USER) = alice

这个模型解释了两个重要事实:

  1. 同一个 ScopedValue 可以在嵌套范围中重新绑定。
  2. 退出内层范围后,外层值自动恢复,不需要显式 finally 清理。

五、一次绑定多个上下文值

Carrier 可以通过链式 where 追加多条绑定:

private static final ScopedValue<String> USER =
        ScopedValue.newInstance();

private static final ScopedValue<String> REQUEST_ID =
        ScopedValue.newInstance();

ScopedValue.where(USER, "alice")
        .where(REQUEST_ID, "req-42")
        .run(() -> {
            System.out.println(USER.get());
            System.out.println(REQUEST_ID.get());
        });

输出:

alice
req-42

这段调用可以理解为:

Carrier C1 = {USER=alice}
Carrier C2 = C1 + {REQUEST_ID=req-42}
在 C2 的范围内运行任务

Carrier 本身可以被理解为一组不可变绑定的组合结果。追加绑定时得到新的 Carrier,而不是修改已有 Carrier。这使得一个 Carrier 可以作为上下文模板被安全地复用,但每次执行仍然具有独立的动态生命周期。

应避免把所有上下文塞进一个可变 Map<String, Object>

ScopedValue<Map<String, Object>> CONTEXT = ...;

这种写法虽然能工作,但会重新引入并发可见性、键名拼写、类型转换和对象可变性问题。多个具有明确类型的 Scoped Value 通常更容易验证:

ScopedValue<String> REQUEST_ID;
ScopedValue<Principal> PRINCIPAL;
ScopedValue<Locale> LOCALE;

六、完整可运行示例:请求上下文与虚拟线程传播

下面的程序可以使用 JDK 25 编译运行:

import java.lang.ScopedValue;

public class ScopedValueDemo {
    private static final ScopedValue<String> USER =
            ScopedValue.newInstance();

    private static final ScopedValue<String> REQUEST_ID =
            ScopedValue.newInstance();

    public static void main(String[] args) throws Exception {
        var requestContext = ScopedValue.where(USER, "alice")
                .where(REQUEST_ID, "req-42");

        requestContext.call(() -> {
            System.out.println("主任务:");
            printContext();

            ScopedValue.where(USER, "bob").run(() -> {
                System.out.println("嵌套任务:");
                printContext();
            });

            System.out.println("嵌套范围结束后:");
            printContext();

            Thread virtualThread = Thread.ofVirtual()
                    .name("request-worker")
                    .start(() -> {
                        System.out.println("虚拟线程:");
                        printContext();
                    });

            virtualThread.join();
            return null;
        });

        System.out.println("外部范围:");
        System.out.println("USER.isBound() = " + USER.isBound());
        System.out.println("REQUEST_ID.isBound() = " + REQUEST_ID.isBound());
    }

    private static void printContext() {
        System.out.printf(
                "thread=%s, user=%s, requestId=%s%n",
                Thread.currentThread().getName(),
                USER.get(),
                REQUEST_ID.get()
        );
    }
}

一种可能的输出是:

主任务:
thread=main, user=alice, requestId=req-42
嵌套任务:
thread=main, user=bob, requestId=req-42
嵌套范围结束后:
thread=main, user=alice, requestId=req-42
虚拟线程:
thread=request-worker, user=alice, requestId=req-42
外部范围:
USER.isBound() = false
REQUEST_ID.isBound() = false

输出中的线程名称取决于创建线程的方式,但值的关系应当满足:

主任务看到:alice / req-42
嵌套任务看到:bob / req-42
嵌套任务结束后:恢复 alice / req-42
在外层范围中创建的虚拟线程:看到 alice / req-42
离开外层范围后:主线程不再绑定

每一步成立的原因如下:

  1. requestContext 包含两条绑定:USER -> "alice"REQUEST_ID -> "req-42"
  2. call 建立动态执行范围,因此 printContext() 可以调用 get()
  3. 内层 where(USER, "bob") 只遮蔽 USER,没有改变 REQUEST_ID
  4. 内层 run 返回后,内层绑定被移除,外层 USER -> "alice" 再次可见。
  5. 虚拟线程是在外层绑定范围内创建的,因此它可以继承创建时可见的 Scoped Value 绑定。
  6. 外层 call 返回后,主线程的动态范围结束,绑定不再可见。

这里使用 call 是因为示例中的 Thread.join() 可能抛出 InterruptedExceptioncall 的函数式接口允许受检异常,而 run 适合不需要返回值且不需要直接抛出受检异常的代码。


七、Scoped Value 如何传播到子线程

Scoped Value 的传播不是“所有线程都从一个全局表读取值”,而是子线程在合适的创建时机获得父线程当前的逻辑绑定环境。

可以用下面的时序表示:

sequenceDiagram
    participant P as 父线程
    participant S as ScopedValue 绑定范围
    participant V as 虚拟子线程

    P->>S: where(USER, "alice").run(...)
    S-->>P: USER 可见
    P->>V: 在绑定范围内创建并启动
    V-->>V: 继承创建时可见的 USER=alice
    V->>V: USER.get() 得到 alice
    P->>S: run 返回,父线程绑定结束
    V-->>V: 子线程的继承绑定按自身执行范围继续可见

这里有两个容易混淆的点。

1. 子线程继承的是绑定环境,不是父线程后续变化

Scoped Value 没有 set,所以父线程不会在子线程运行期间把值改成另一个值。若父线程进入一个新的嵌套绑定,已经创建的子线程不会因此自动切换到这个新绑定。

例如:

ScopedValue.where(USER, "alice").run(() -> {
    Thread child = Thread.startVirtualThread(() -> {
        System.out.println(USER.get());
    });

    ScopedValue.where(USER, "bob").run(() -> {
        System.out.println(USER.get());
    });

    child.join();
});

嵌套范围中的当前线程输出 "bob",子线程看到的则是它创建时继承的 "alice"。不能把 Scoped Value 当作一个会实时广播变化的共享变量。

2. 传播与任务提交线程不是一回事

下面的场景不能假设 Scoped Value 会自动传播:

ExecutorService pool = Executors.newFixedThreadPool(4);

ScopedValue.where(USER, "alice").run(() -> {
    pool.submit(() -> {
        System.out.println(USER.get());
    });
});

线程池中的工作线程通常早已存在。提交任务只是把一个 Runnable 放入队列,并没有在当前 Scoped Value 范围内创建新的子线程。因此,工作线程可能执行 USER.get() 时发现没有绑定,并抛出 NoSuchElementException

这也是 Scoped Value 与线程池任务上下文之间的边界:

在 scope 内创建子线程:可以继承绑定
向已有线程池提交任务:不能仅凭提交动作自动继承绑定

如果必须把上下文提交到一个已有线程池,应显式设计任务包装或使用支持上下文传递的执行框架。但包装逻辑必须明确绑定的生命周期,不能简单复制一个可变上下文 Map 后任意长期保存。


八、为什么 Scoped Value 特别适合虚拟线程

虚拟线程的典型使用模型是“一项任务一个线程”。线程数量可以很多,任务生命周期通常较短,并且任务之间具有清晰的父子或调用关系。

Scoped Value 与这个模型的契合点在于:

  1. 上下文绑定按任务范围建立
    请求开始时建立绑定,请求处理结束时自动退出。

  2. 值只读,避免跨线程写入
    子任务可以读取请求主体、请求 ID 等上下文,但不能通过 Scoped Value 改写父任务的绑定。

  3. 生命周期与动态范围一致
    请求处理抛异常时,runcall 仍会退出范围,不需要依赖线程池线程的清理纪律。

  4. 适合结构化的子任务
    一个请求可以创建多个虚拟子线程,这些子线程共享逻辑上下文,同时各自执行独立任务。

不过,虚拟线程并不意味着 Scoped Value 的所有使用都天然安全。若绑定值本身是可变对象,多个虚拟线程仍可能同时访问和修改它。Scoped Value 解决的是“绑定关系的传播和修改方式”,不是任意对象的并发控制。


九、与 ThreadLocal 的根本差异

ThreadLocalScopedValue 都可以让方法参数列表之外的代码取得上下文,但它们的语义不同。

ThreadLocal 的模型

private static final ThreadLocal<String> USER =
        new ThreadLocal<>();

USER.set("alice");
try {
    serviceCall();
} finally {
    USER.remove();
}

ThreadLocal 的核心关系可以表示为:

线程 -> 可变槽位 -> 当前值

代码可以在任意位置执行:

USER.set("bob");
USER.remove();

因此,值的变化依赖调用者是否正确清理,尤其是在复用线程的线程池中。如果忘记 remove(),下一个任务可能读取到上一个任务遗留的值。

ScopedValue 的模型

ScopedValue.where(USER, "alice").run(() -> {
    serviceCall();
});

Scoped Value 的关系更接近:

动态执行范围 -> 不可变绑定 -> 值

调用者不能在范围内部随意执行 set 修改当前绑定。需要不同值时,只能进入嵌套范围:

ScopedValue.where(USER, "bob").run(...);

退出后自动恢复原范围。

两者并不是简单的“新 API 完全替代旧 API”:

需求 更合适的工具
请求主体、请求 ID、只读追踪信息 ScopedValue
当前线程独有且需要反复修改的状态 ThreadLocal
在线程池任务之间传递上下文 显式任务上下文或专门的传播机制
需要对子任务进行生命周期管理 结构化并发相关 API
共享可变状态 并发集合、锁、原子类等专门工具

ThreadLocal 允许可变线程本地状态;Scoped Value 强调不可变绑定和动态生命周期。选择依据是语义,而不是 API 新旧。


十、不可变绑定如何降低故障路径

考虑一个请求处理函数:

void handle(Request request) {
    ScopedValue.where(REQUEST_ID, request.id()).run(() -> {
        authenticate();
        callBusinessService();
        writeResponse();
    });
}

如果 callBusinessService() 深层调用了日志组件:

void log(String message) {
    System.out.printf(
            "[request=%s] %s%n",
            REQUEST_ID.orElse("unknown"),
            message
    );
}

日志组件不需要接收 requestId 参数,也不需要依赖某个线程池线程之前执行过什么任务。只要它在绑定范围内运行,就能取得当前请求 ID。

如果 authenticate() 抛出异常,动态范围仍然会结束:

进入 handle scope
  authenticate()
  抛出 AuthenticationException
退出 handle scope
requestId 不再绑定
异常向上层传播

没有异常路径需要额外写:

try {
    REQUEST_ID.set(...);
} finally {
    REQUEST_ID.remove();
}

这并不表示异常处理自动完成。Scoped Value 只负责绑定生命周期;请求是否返回正确的 HTTP 状态码、异常是否记录、子任务是否取消,仍由上层代码负责。


十一、使用私有键避免上下文冲突

Scoped Value 的键应当由拥有它的组件私有持有:

final class RequestContext {
    private static final ScopedValue<String> REQUEST_ID =
            ScopedValue.newInstance();

    static void run(String requestId, Runnable action) {
        ScopedValue.where(REQUEST_ID, requestId).run(action);
    }

    static String currentRequestId() {
        return REQUEST_ID.get();
    }
}

这样做有两个作用:

  1. 外部代码不能凭字符串名称伪造同一个上下文键。
  2. 不同模块即使都使用 "requestId" 这样的概念,也不会因为键名冲突而互相覆盖。

Scoped Value 的键是对象实例。工程上通常把它声明为模块内部的 private static final 字段,而不是公开一个可被任意代码重新绑定的通用键。


十二、异常、未绑定访问和中断处理

1. 未绑定访问

static void requireRequestId() {
    String requestId = REQUEST_ID.get();
    // 只有在请求范围内才能执行到这里
}

如果这个方法被错误地用于后台任务、启动流程或测试代码,失败表现通常是:

java.util.NoSuchElementException

诊断时应检查调用链中是否真的经过了:

ScopedValue.where(REQUEST_ID, value).run(...)

而不是只检查当前线程是否是某个特定线程。

2. 使用 isBound() 进行可选逻辑

if (REQUEST_ID.isBound()) {
    audit(REQUEST_ID.get());
} else {
    auditWithoutRequest();
}

isBound() 只能说明当前执行范围是否有绑定,不能说明绑定值是否符合业务规则。绑定了空字符串、过期对象或错误租户,仍然是业务层问题。

3. 使用 call 处理受检异常

String result = ScopedValue.where(REQUEST_ID, "req-42")
        .call(() -> {
            return performOperation();
        });

call 返回任务结果,并允许调用体抛出受检异常。无返回值、没有受检异常传播需求时使用 run 更直接。

4. 虚拟线程被中断

虚拟线程调用 join、阻塞 I/O 或其他可中断操作时,仍必须按照普通 Java 线程规则处理 InterruptedException。Scoped Value 不会替任务取消、恢复中断标志或关闭资源:

try {
    child.join();
} catch (InterruptedException e) {
    Thread.currentThread().interrupt();
    throw new RuntimeException("等待子任务时被中断", e);
}

这里恢复中断标志是因为当前方法选择将中断转换为运行时异常继续向上抛出;如果上层有明确的取消协议,也可以采用对应的传播方式。


十三、一个常见反例:把 Scoped Value 当成可变上下文

下面的代码表面上使用了 Scoped Value,实际上把可变状态放进了共享对象:

class Context {
    String tenant;
}

private static final ScopedValue<Context> CONTEXT =
        ScopedValue.newInstance();

ScopedValue.where(CONTEXT, new Context()).run(() -> {
    Thread.startVirtualThread(() -> CONTEXT.get().tenant = "tenant-a");
    Thread.startVirtualThread(() -> CONTEXT.get().tenant = "tenant-b");
});

两个子线程继承的可能是同一个 Context 对象引用。于是它们对 tenant 的写入产生数据竞争,最后结果也不确定。

更合理的方式是绑定不可变对象:

record Context(String tenant) {}

private static final ScopedValue<Context> CONTEXT =
        ScopedValue.newInstance();

ScopedValue.where(CONTEXT, new Context("tenant-a")).run(() -> {
    // 子线程只能读取 CONTEXT.get()
});

如果子任务需要不同租户,应创建新的值并进入嵌套范围:

ScopedValue.where(CONTEXT, new Context("tenant-b")).run(() -> {
    // 当前动态范围看到 tenant-b
});

关键区别是:

错误思路:共享对象,然后修改对象字段
正确思路:创建新的不可变值,然后建立新的绑定范围

十四、另一个反例:绑定范围没有覆盖真正的任务

下面的代码先创建任务,再离开绑定范围,最后才让线程执行:

Thread worker;

ScopedValue.where(USER, "alice").run(() -> {
    worker = Thread.ofVirtual().unstarted(() -> {
        System.out.println(USER.get());
    });
});

// 在这里启动,已经不处于绑定范围内
worker.start();

这种写法不能把“创建对象”与“启动执行”混为一谈。Scoped Value 的传播依赖子线程在绑定范围内被创建和启动的生命周期语义;生产代码应让任务的创建、启动和等待关系保持清晰,最好直接写成:

ScopedValue.where(USER, "alice").run(() -> {
    Thread worker = Thread.ofVirtual().start(() -> {
        System.out.println(USER.get());
    });

    try {
        worker.join();
    } catch (InterruptedException e) {
        Thread.currentThread().interrupt();
        throw new RuntimeException(e);
    }
});

如果使用结构化并发,则应在结构化任务范围内创建子任务,并在该范围内等待、取消和收集结果。Java 25 中结构化并发相关 API 的具体状态需要以对应 JDK 25 文档为准,不能把预览 API 当作无条件稳定接口。


十五、Scoped Value 与结构化并发的关系

Scoped Value 解决的是:

子任务如何看到父任务的只读上下文

结构化并发解决的是:

父任务如何管理子任务的创建、等待、失败和取消

两者组合时,请求处理流程可以抽象成:

flowchart TD
    A[接收请求] --> B[建立 Scoped Value 绑定]
    B --> C[创建虚拟子任务]
    C --> D1[子任务读取请求上下文]
    C --> D2[子任务读取请求上下文]
    D1 --> E[等待并收集结果]
    D2 --> E
    E --> F[写回响应]
    F --> G[退出绑定范围]

如果只使用 Scoped Value 而不管理子任务生命周期,仍可能出现:

父任务已经返回
子任务仍在运行
子任务继续访问外部资源
请求级资源已经关闭

因此,Scoped Value 不等价于结构化并发,也不负责子任务取消、失败聚合或资源关闭。它只是让上下文传播具有清晰的只读语义。


十六、生产边界与诊断方法

1. 不要依赖“当前线程名称”推断上下文

在虚拟线程模型中,同一类业务任务可能运行在大量不同线程上,线程名称不是请求身份。应直接检查 Scoped Value 是否绑定,并输出请求 ID:

System.out.printf(
        "thread=%s, requestId=%s%n",
        Thread.currentThread(),
        REQUEST_ID.orElse("unbound")
);

2. 不要把上下文长期存入静态集合

虽然绑定值可以是对象引用,但把它们保存到全局缓存、队列或长期任务中,会使上下文的生命周期超过原请求,导致:

  • 请求对象被长期持有;
  • 敏感信息扩散到不应访问的任务;
  • 子任务执行时读取到已经失效的业务状态。

Scoped Value 的推荐使用方式是绑定短生命周期、不可变、可安全读取的上下文对象。

3. 不要从 ThreadLocal 自动推断 Scoped Value 已传播

已有框架可能把请求信息放在 ThreadLocal、日志 MDC 或自定义上下文容器中。迁移到虚拟线程时,即使 Scoped Value 可以传播,也不会自动把其他机制中的数据转换过来。迁移必须明确每种上下文的来源和传播边界。

4. 出错时检查四个位置

遇到 NoSuchElementException 或上下文值不正确时,可以按以下顺序检查:

  1. 是否使用了同一个 ScopedValue 键实例,而不是重新调用 newInstance()
  2. get() 调用是否真的位于 runcall 的动态范围内;
  3. 任务是否是在已有线程池中执行,而不是在绑定范围内创建的子线程;
  4. 子线程是否在创建后进入了另一个嵌套绑定,或者绑定值对象本身被修改。

例如,下面两个键不是同一个键:

ScopedValue<String> key1 = ScopedValue.newInstance();
ScopedValue<String> key2 = ScopedValue.newInstance();

ScopedValue.where(key1, "value").run(() -> {
    key2.get(); // key2 从未绑定
});

即使两个变量的泛型和业务含义相同,它们也代表不同的上下文键。


十七、规范保证、实现细节与工程建议

需要把三类判断分开。

Java 25 API 的语义保证

Java SE 25 的 ScopedValue API 明确提供:

  • 创建 Scoped Value 键;
  • 在动态执行范围内建立绑定;
  • 读取当前绑定;
  • 嵌套绑定和范围退出;
  • runcall 两种执行形式;
  • 判断绑定是否存在;
  • 向符合条件的子线程传播上下文。

不应当依赖的实现细节

不应依赖以下内容:

  • 某个具体 JDK 实现内部如何存储绑定;
  • 一个绑定到底复制了多少对象;
  • Scoped Value 一定比 ThreadLocal 快多少;
  • 虚拟线程数量、上下文查找时间或内存占用的固定数字;
  • 线程池一定会以某种方式传播上下文。

这些属于实现、工作负载或版本相关因素,必须通过目标 JDK 和实际压测验证。

稳妥的工程取舍

适合使用 Scoped Value 的前提是:

上下文主要用于读取
上下文生命周期与一次调用或一次任务一致
子任务需要继承父任务上下文
不希望调用方显式层层传递参数

如果状态需要频繁修改、必须绑定到线程而不是调用范围,或者需要在线程池任务之间显式排队传播,那么 ThreadLocal、显式参数、任务包装器或其他并发机制可能更合适。


总结

Java 25 的 Scoped Value 可以用四个关键词概括:

键:ScopedValue 实例
绑定:where(key, value)
范围:run 或 call 的动态执行范围
传播:在范围内创建的子线程继承可见上下文

它的核心性质是:

  • 绑定不可通过 set 原地修改;
  • 嵌套绑定会遮蔽外层值,退出后自动恢复;
  • get() 读取的是当前动态范围中最近的绑定;
  • 绑定值本身不会自动深拷贝或变成线程安全对象;
  • 虚拟线程可以在创建时继承父任务上下文;
  • 已有线程池线程不会因为提交任务而自动获得当前绑定;
  • Scoped Value 负责上下文传播,不负责子任务取消、共享对象同步或异常聚合。

因此,Scoped Value 最适合表达“这次请求、这次调用、这组子任务都只读地使用同一份上下文”。当它与虚拟线程和结构化的任务生命周期配合时,可以在不依赖线程复用状态、不要求层层传递参数的情况下,保持上下文传播的边界清晰。


系列导航与关联阅读

官方资料

本文依据 Java、Spring 与相关项目官方文档重新梳理;正文、示例与生产清单由 WR BLOG 编写。