从 Java 21 到 Java 25:ThreadLocal 以外的选择—— ScopedValue

240 阅读9分钟

前言

书接上文。

上一篇(从 JDK1.2 到 JDK21:ThreadLocal的进化解决了什么问题)写了从 JDK 1.2 到 JDK 21,ThreadLocal 是怎么一步步演进到现在这个形态的。

到了 Java 21 之后,一个意想不到的事情发生了:

ThreadLocal 本身不再有任何变化。

从 Java 21 到 Java 25,ThreadLocal 的公开 API 基本没有变化:

get()
set()
remove()
initialValue()
withInitial()

Java 21 是这些方法,Java 25 还是这些方法。

但这并不意味着 JDK 不再解决 ThreadLocal 的问题。

恰恰相反。

JDK 选择了另一条路:

与其继续给 ThreadLocal 打补丁,不如重新设计一个更适合现代 Java 并发模型的上下文传递机制。

这个东西就是:

ScopedValue

它从 Java 21 开始 Preview,经过 Java 22、23、24 连续调整,最终在 Java 25 正式转正

Java 21:线程变了

Java 21 最大的变化之一是:

Virtual Thread

正式发布。

也就是 JEP 444。

以前 Java 服务可能只有:

200
500
1000

个线程。

Virtual Thread 出现之后,一个应用同时存在几十万甚至上百万个虚拟线程都成为可能。

这时候 ThreadLocal 的一个问题被重新放大了:

ThreadLocal 是 per-thread 的。

每一个线程都有自己的:

ThreadLocalMap

如果你有:

1000 个平台线程

ThreadLocal 仅占用一点内存,问题可能不明显。

但如果变成:

1000000 个虚拟线程

每个虚拟线程都保存一份 ThreadLocal 状态,情况就完全不同了。

JEP 444 因此明确提醒:

Virtual Thread 支持 ThreadLocal,但在大量虚拟线程场景下应该谨慎使用。

尤其不能再用 ThreadLocal 做这种事情:

ThreadLocal<Connection>
ThreadLocal<ByteBuffer>
ThreadLocal<ExpensiveObject>

然后试图达到:

一个线程缓存一份昂贵资源

因为 Virtual Thread 的设计理念已经从:

少量线程复用大量任务

变成:

一个任务一个线程

这两个模型完全不同。

一个需要修正的认知:虚拟线程并没有让 ThreadLocal 更简单

很多人第一次看到 Virtual Thread,会有一个直觉:

虚拟线程生命周期短,所以 ThreadLocal 的问题是不是自然消失了?

是,也不是,只对了一半。

传统线程池:

请求 A
  ↓
Thread-1
  ↓
ThreadLocal.set(A)

请求结束
  ↓
忘记 remove()

请求 B
  ↓
继续复用 Thread-1

这种因为线程长期复用导致的数据残留问题,在“一任务一虚拟线程”的模式下确实会明显减弱。

因为任务结束:

VirtualThread
    ↓
生命周期结束
    ↓
ThreadLocalMap 一起变成可回收对象

但是另一边的问题又出现了:

ThreadLocal 是每线程状态

Virtual Thread 最大的特点恰恰是:

线程特别多

所以:

线程复用导致的长期驻留问题弱化了,但 per-thread 状态的规模问题反而被放大了。

这也是为什么 JDK 不再继续单纯优化 ThreadLocal,而是转头开始设计 ScopedValue。

Java 21:ScopedValue 第一次 Preview

Java 21 中,JEP 446 将 ScopedValue 作为 Preview API 引入。

它并没有试图去解决线程本地变量的问题,而是处理了 ThreadLocal 长期承担的另一个职责:

上下文传递

比如:

UserContext
TraceId
TenantId
SecurityContext
RequestContext

我们过去经常这样写:

private static final ThreadLocal<UserContext> CONTEXT =
        new ThreadLocal<>();

请求进入:

CONTEXT.set(context);

业务深处:

UserContext context = CONTEXT.get();

请求结束:

try {
    service();
} finally {
    CONTEXT.remove();
}

问题就在最后这一步:

remove()

必须由业务代码自己进行处理。

一旦遗漏,就可能留下隐患。

ScopedValue 换了一个思路。

Java 21 最初的写法是:

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

ScopedValue.runWhere(
        CONTEXT,
        context,
        () -> service()
);

业务深处依然可以:

UserContext context = CONTEXT.get();

但最大的区别是:

service() 开始
    ↓
CONTEXT = context
    ↓
执行
    ↓
service() 结束
    ↓
绑定自动失效

无论正常返回还是抛异常,作用域结束之后绑定都会自动恢复。

没有 remove()。

ScopedValue 真正解决了 ThreadLocal 哪三个问题?

Oracle 在 Java 25 的 API 文档里直接列出了 ThreadLocal 用于“单向上下文传递”时的三个问题。

1. 生命周期没有边界

ThreadLocal:

CONTEXT.set(context);

service();

service() 执行结束之后:

CONTEXT

依然存在。

除非:

CONTEXT.remove();

ScopedValue:

ScopedValue.where(CONTEXT, context)
        .run(() -> service());

它的生命周期天然就是:

run() 的动态作用域

run 结束:

绑定结束

所以它从机制上就直接避免了:

因为忘记 remove() 导致 ThreadLocal value 长期驻留的问题。

2. ThreadLocal 可以被任意下游代码修改

ThreadLocal:

CONTEXT.set(userA);

调用:

service();

但是 service 深处的某段代码完全可以:

CONTEXT.set(userB);

于是上层的上下文就会被更改。

ScopedValue 没有:

set()

读取方只能:

CONTEXT.get();

不能修改当前绑定。

如果真的需要一个新的值,只能创建新的嵌套作用域:

ScopedValue.where(CONTEXT, userB)
        .run(() -> serviceB());

结束以后又自动恢复:

userA
   ↓
进入子作用域
   ↓
userB
   ↓
退出
   ↓
userA

所以 ScopedValue 更接近 隐式 final 参数,而不是 线程全局变量

3. InheritableThreadLocal 的继承成本太高

如果要让子线程继承 ThreadLocal,通常会使用:

InheritableThreadLocal

但它创建子线程时,还得处理父线程的 ThreadLocal 状态。

大量线程场景下,这种方式并不理想。

ScopedValue 从一开始就是为了:

Virtual Thread
+
Structured Concurrency

设计的。

配合 StructuredTaskScope 时,子任务可以继承当前 ScopedValue。

Java 25 文档描述它的实现成本,本质上接近 复制一个指针

而不是给每个子线程复制整套 ThreadLocal 的映射。

Java 22:ScopedValue 第二次 Preview

Java 22 对应:

JEP 464

这一次没有修改 API。

官方给出的原因是要继续 Preview,收集更多使用反馈。

也就是说 Java 22 的重点不是继续加功能,而是验证这个模型到底靠不靠谱。

到这里 JDK 的方向已经很明显:

ThreadLocal
    ↓
仍然保留

上下文传递
    ↓
逐渐交给 ScopedValue

6. Java 23:开始打磨异常处理

Java 23:

JEP 481

ScopedValue 第三次 Preview。

这次出现了一个非常 Java 的调整。

新增:

ScopedValue.CallableOp<T, X extends Throwable>

它允许:

callWhere()

更准确地保留 Lambda 抛出的异常类型。

例如:

String result = ScopedValue.callWhere(
        CONTEXT,
        context,
        () -> loadUser()
);

loadUser() 如果抛:

IOException

编译器可以更准确地推导异常,而不是把异常处理搞得过于宽泛。

这个变化说明 ScopedValue 已经正式进入 API 的打磨阶段。

Java 24:接近最终形态

Java 24:

JEP 487

第四次 Preview。

这次最大的变化如下:

删除 runWhere()callWhere()

之前:

ScopedValue.runWhere(
        CONTEXT,
        context,
        () -> service()
);

变成:

ScopedValue.where(CONTEXT, context)
        .run(() -> service());

有返回值:

User user = ScopedValue.where(CONTEXT, context)
        .call(() -> loadUser());

API 完全改成 Fluent 风格。

这个设计确实更容易理解:

where(...)
    ↓
绑定什么
    ↓
run / call
    ↓
在哪个作用域执行

如果有多个上下文:

ScopedValue.where(USER, user)
        .where(TRACE_ID, traceId)
        .where(TENANT, tenant)
        .run(() -> service());

整个作用域一目了然。

不得不赞叹 JDK 团队的深厚内力。

Java 25:ScopedValue 正式转正

Java 25:

JEP 506

ScopedValue 结束了从 Java 21 开始的 Preview 阶段,成为正式 API。

最终 API 基本就是 Java 24 的形态:

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

ScopedValue.where(CONTEXT, context)
        .run(() -> service());

获取:

CONTEXT.get();

判断:

CONTEXT.isBound();

默认值:

CONTEXT.orElse(defaultContext);

Java 25 最后还做了一个很小的调整:

orElse(null)

不再被允许。

到这里 ScopedValue 的 API 基本就定型了。

ThreadLocal 和 ScopedValue 的本质区别

最重要的区别在于生命周期模型。

ThreadLocal:

Thread
│
├── ThreadLocalMap
│     │
│     ├── Context A
│     ├── Context B
│     └── Context C
│
└── 一直存在到 remove() 或线程结束

ScopedValue:

Thread
│
└── Dynamic Scope
      │
      ├── bind context
      │
      ├── service()
      │     └── service2()
      │           └── context.get()
      │
      └── scope end
            ↓
         自动解绑

ThreadLocal 的思想是:

这个数据属于线程

ScopedValue 的思想是:

这个数据属于这段调用链

这才是两者真正的区别。

一个典型的实践

以前:

public class UserContextHolder {

    private static final ThreadLocal<UserContext> CONTEXT =
            new ThreadLocal<>();

    public static void set(UserContext context) {
        CONTEXT.set(context);
    }

    public static UserContext get() {
        return CONTEXT.get();
    }

    public static void remove() {
        CONTEXT.remove();
    }
}

调用:

try {
    UserContextHolder.set(context);

    orderService.createOrder();
} finally {
    UserContextHolder.remove();
}

改成 Java 25 后:

public class UserContextHolder {

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

    public static UserContext get() {
        return CONTEXT.get();
    }

    public static void run(
            UserContext context,
            Runnable runnable) {

        ScopedValue.where(CONTEXT, context)
                .run(runnable);
    }
}

调用:

UserContextHolder.run(
        context,
        () -> orderService.createOrder()
);

最大的变化就是:

set()
remove()

全部消失了。

生命周期已经包含在代码结构里。

11. Structured Concurrency 才是 ScopedValue 的完整形态

如果只是单线程调用链:

Controller
   ↓
Service
   ↓
Repository

ScopedValue 已经很好用。

但真正体现它设计价值的是:

Virtual Thread
+
ScopedValue
+
Structured Concurrency

例如 Java 25 中:

ScopedValue.where(CONTEXT, context).run(() -> {

    try (var scope = StructuredTaskScope.open()) {

        scope.fork(() -> loadUser());

        scope.fork(() -> loadOrder());

        scope.fork(() -> loadCoupon());

        scope.join();
    }
});

这些由 StructuredTaskScope 创建的子任务可以继承父作用域中的 ScopedValue 绑定。

于是上下文传播变成:

Request
   │
   └── ScopedValue
          │
          ├── VirtualThread A
          │
          ├── VirtualThread B
          │
          └── VirtualThread C

不需要:

context.set()
context.copy()
context.remove()

需要注意:

Java 25 中 ScopedValue 已正式发布,但 Structured Concurrency 仍然是 Preview API。

对于想体验结构化并发,甚至想在 Java 8 中使用结构化并发的朋友们,不妨来看看我的 ThreadForge ,在 Structured Concurrency 正式发布前不失为一个选择。

传送门:github.com/wuuJiawei/T…

ThreadLocal 会被 ScopedValue 淘汰吗?

不会。

因为它们解决的是不同的问题。

如果需要的是:

当前线程拥有一份可变状态

ThreadLocal 依然合理。

例如某些真正意义上的:

thread-local state

但是如果需求是:

把一个上下文沿调用链向下传递

例如:

UserContext
TenantId
TraceId
RequestContext
Security Context

Java 25 的官方 API 文档已经明确建议:

对于不通过方法参数进行“单向数据传递”的场景,应优先考虑 ScopedValue,而不是 ThreadLocal。

所以它们未来更合理的分工应该是:

ThreadLocal
    ↓
线程局部可变状态

ScopedValue
    ↓
调用链上下文

直接看完整变化

Java 版本变化解决的问题
Java 21Virtual Thread 正式发布Thread 模型发生变化,ThreadLocal 的规模问题被放大
Java 21ScopedValue 第一次 Preview解决 ThreadLocal 做上下文传递时生命周期无边界、可修改等问题
Java 22ScopedValue 第二次 PreviewAPI 不变,继续验证设计
Java 23引入 CallableOp改善异常类型推导
Java 24删除 runWhere/callWhereAPI 改成统一 Fluent 风格
Java 25ScopedValue 正式发布上下文传递方案正式稳定
Java 25orElse(null) 不再允许收紧最终 API 语义

ThreadLocal 下一步可能是什么?

如果从 JDK 1.2 一直看到 Java 25,会发现 ThreadLocal 的演进其实分成了两个阶段。

第一阶段:

JDK 1.2
   ↓
解决线程之间如何隔离变量

然后不断解决:

GC
内存泄漏
哈希冲突
清理机制
易用性

但到了 Virtual Thread 时代,问题发生变化了。

大家大量使用 ThreadLocal,其实已经不只是为了:

Thread Local

而是在拿它做:

Context Propagation

于是 JDK 没有继续往 ThreadLocal 里面塞功能。

而是重新抽象出:

ScopedValue

所以从 Java 21 到 Java 25,真正发生的变化是:

JDK 开始把本来就不应该属于 ThreadLocal 的能力,从 ThreadLocal 身上拆出去。

最终形成:

ThreadLocal
    ↓
线程局部状态

ScopedValue
    ↓
调用链上下文

Virtual Thread
    ↓
一个任务一个线程

Structured Concurrency
    ↓
管理任务之间的父子关系

这几块组合起来,才是 Loom 之后 Java 新并发模型真正想解决的问题。

从这个角度来理解,就能够看出来,ScopedValue 并不是 ThreadLocal 2.0。

它更像是 JDK 在用了二十多年 ThreadLocal 之后,对 “上下文到底应该属于线程,还是属于一次调用” 这一话题给出的新答案。