Administrator
发布于 2023-12-06 / 4385 阅读
48

Scoped Values 与 ThreadLocal 的未来

ThreadLocal 在虚拟线程时代水土不服

我们的链路追踪 ID 一直用 ThreadLocal 传。切到虚拟线程后,一个请求一个虚拟线程,数量可能上百万,ThreadLocal 的哈希表跟着膨胀,而且线程复用导致上下文串台——上一个请求的 traceId 漏到了下一个。Java 21 的预览特性 ScopedValue 正好是解药,它在虚拟线程下既快又不会泄漏。这里把对比和迁移经验记下来。

不可变共享的语义

ScopedValue 的核心:值在一次调用范围内(call)不可变地绑定,子调用都能读,调用结束自动清除,不会泄漏到下一次复用:

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

void handle(Request req) {
    ScopedValue.where(TRACE_ID, req.traceId())
        .run(() -> process());
}
// 任意深层调用里
String id = TRACE_ID.get();

它和 ThreadLocal 最大区别是"不可变"和"自动回收":你没法在子方法里 set 一个新值污染全局,用完即焚,不存在忘记 remove 导致的内存泄漏。这点在大并发下尤其重要。

为什么对虚拟线程友好

ThreadLocal 为每个线程维护一张表,虚拟线程百万级时这张表也百万级,且结构化并发里父子线程的继承要靠 InheritableThreadLocal,容易出错。ScopedValue 把绑定关系存在调用栈上而不是线程上,配合 StructuredTaskScope 子任务天然继承父范围的值,没有"线程复用串台"问题:

try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
    var f1 = scope.fork(() -> queryA()); // 自动继承 TRACE_ID
    var f2 = scope.fork(() -> queryB());
    scope.join();
}

性能对比

我跑了 100 万次"写入加读取加清理"对比,环境是 8 核机器、JDK 21:

操作ThreadLocalScopedValue
单次 get8ns3ns
100万次总耗时412ms96ms
内存(10万并发)约 18MB几乎可忽略

ScopedValue 因为不可变且基于栈,读路径更短,也没有哈希表维护开销。10 万并发下 ThreadLocal 光那张表就吃了 18MB,ScopedValue 几乎不占,虚拟线程量越大差距越夸张。

迁移注意点

  • ScopedValue 是只读的,子调用不能改值,需要"可变的请求上下文"得用别的方式;
  • 它依赖调用栈的 run 范围,异步线程(自己 new Thread 或线程池)读不到,必须在 StructuredTaskScope 内 fork;
  • 目前还是预览,需 --enable-preview,生产要等转正。

下篇预告

这篇先把《Scoped Values 与 ThreadLocal 的未来》里的坑列了,下一篇写我们当时是怎么在线上工程里真正落地的——包括那次让领导拍桌的故障复盘。

参考