Ohhnews

分类导航

$ cd ..
foojay原文

我为何在Exeris内核中禁用ThreadLocal(以及替代方案)

#threadlocal#scoped values#jep 506#虚拟线程#高性能

目录

法医分析:ThreadLocal 的三宗罪

缺失的环节:与结构化并发不兼容

案例A:零浪费解决方案(JEP 506)

案例B:“展示,而非讲述”——Exeris 实现

范式转变

探索 Exeris 内核

在一个为 1-VT-per-Stream 密度设计的零拷贝运行时中,ThreadLocal 是性能的连环杀手。本文将进行法医分析,并展示 JEP 506 作用域值如何改变一切。


当我开始设计 Exeris 内核 ——一个为 Java 26+ 构建的下一代零拷贝运行时——我制定了一条不可妥协的架构法则:“零浪费计算”

在一个旨在通过将每个虚拟线程精确映射到每个网络流(1-VT-per-Stream)来处理极端密度的系统中,每一字节内存和每一个 CPU 周期都必须是有意为之的。

但很快,我撞上了 遗留架构的墙

在标准的企业 Java 生态中,当你需要将 SecurityContextTenantIdTransactionID 传递到数据库层,而又不想污染数十个方法签名时,你会使用一个可信赖的工具:ThreadLocal。二十多年来,ThreadLocal 一直是 Java 框架魔法的支柱。但在 Project Loom(JEP 444)和结构化并发的时代,这位老朋友变成了 性能连环杀手

这就是为什么我在 Exeris 中严格执行了内核级的 ThreadLocal 全面禁用令,以及采用 JEP 506(作用域值) 如何完全改变了高性能架构的游戏规则。

*** ** * ** ***## 法医分析:ThreadLocal 的三宗罪 将虚拟线程当作操作系统线程来对待,会丢弃它们大部分的可扩展性优势——尤其是在上下文传播和分配行为方面。当你在高度并发的每线程每请求架构中使用 ThreadLocal 时,会引入三个关键缺陷:

1. 意大利面条状态(不受约束的可变性)

调用栈深处任何能读取 ThreadLocal 的代码,也可以调用它的 .set() 方法。如果某个嵌套库中途修改了 SecurityContext,那么追踪谁修改了它以及何时修改的,将是一场调试噩梦。数据流变得完全不可预测。 [LOADING...]

2. 内存泄漏陷阱(无界生命周期)

ThreadLocal 会一直存活,直到线程死亡或有人显式调用 .remove()。在遗留线程池中,忘记清理意味着安全上下文会泄露到下一个用户的请求中。

3. 继承税(内存杀手)

这是致命一击。为了与子线程共享上下文,框架使用 InheritableThreadLocal。当父线程创建子线程时,JVM 必须 急切地克隆 父线程的 ThreadLocalMap。根据负载因子和键分布,这在堆上通常为每个条目分配 32 到 128 字节

现在想象一下,一个 HTTP 请求中,你的逻辑分叉出 50 个并发子任务(虚拟线程)来获取数据。你刚刚触发了 50 次昂贵的映射分配。再乘以 10,000 个并发请求,你的垃圾收集器就会因为清理无用的上下文克隆而阻塞应用程序。这变成了一种 纯 GC 税,毫无业务价值[LOADING...]

*** ** * ** ***## 缺失的环节:与结构化并发不兼容 除了性能之外,ThreadLocal 从根本上与结构化并发不兼容。StructuredTaskScope 依赖于确定性的、树状的执行,其中子任务的生命周期严格绑定到父任务。而 ThreadLocal 是非确定性的,并且在树的任何层级都可以完全修改,这完全打破了这种模型。

如果任何叶子节点都能秘密地修改分支的全局状态,你就无法构建一个可靠、快速失败的并发树。

*** ** * ** ***## 案例A:零浪费解决方案(JEP 506) 为了承载数百万个虚拟线程,我们需要一种 不可变时间上有限继承成本几乎为零 的机制。这就是 作用域值 登场的时候。

与全局可变的变量不同,ScopedValue 定义了一个 动态作用域。它将一个值绑定到特定的代码块(以及该块内调用的所有方法)。一旦代码块结束,绑定就会消失。

评分表

ThreadLocalScopedValue
不可变性可变(任何人都可以覆盖)不可变(对被调用方只读)
生命周期无界(需要手动清理)词法限定(绑定到 .run() 代码块)
继承成本O(N) 内存复制O(1) 常量时间继承,分配成本可忽略

*** ** * ** ***## 案例B:“展示,而非讲述”——Exeris 实现 在 Exeris 内核中,上下文传播被严格分离。安全模块负责认证,持久化模块应用行级安全。它们从不直接通信。它们完全通过一个使用 ScopedValue“隐形墙” 进行通信。 [LOADING...]

以下是在网关层注入身份的方式。注意完全没有 .set() 方法:

// 1. 直接从堆外内存解码令牌(零分配)
AuthenticationResult result = securityProvider.authenticate(tokenBuffer);

// 2. 打开一个词法限定、不可变的动态作用域
// 注意:链式 .where() 调用创建高效嵌套作用域
ScopedValue
    .where(KernelProviders.PRINCIPAL_CONTEXT, result.principal())
    .where(KernelProviders.STORAGE_CONTEXT,   result.storage())
    .run(() -> {
        // 在此代码块内,上下文是安全的
        // 它将被任何通过 StructuredTaskScope 生成的虚拟线程继承
        dispatchRequest(request);
    });

// 3. 作用域自动关闭。无需 .remove()。零泄漏。

后来,在持久化模块深处,TransactionOrchestrator 需要知道租户 ID 以将其附加到 SQL 查询中。它只需查询活动作用域:

public class TransactionOrchestrator {

    private static StorageContext resolveStorageContext() {
        // 零 ThreadLocal,完全虚拟线程安全(JEP 506)
        // isBound() 是 O(1) 检查
        if (KernelProviders.STORAGE_CONTEXT.isBound()) {
            return KernelProviders.STORAGE_CONTEXT.get();
        }
        // 回退到系统上下文,无需分配对象
        return ImmutableStorageContext.system();
    }

    // ... 事务执行逻辑
}

由于 ScopedValue 是不可变的,词法作用域和不可变性保证了 TransactionOrchestrator 读取的 StorageContext 正是网关设置的那个,不会被沿途任何拦截器篡改。

*** ** * ** ***## 范式转变 通过将 ThreadLocal 从内核中剥离,我们消除了整整一类内存泄漏和 GC 压力。当系统生成 1,000,000 个虚拟线程时,“复制映射一百万次”与“常量时间共享指针”之间的差异,就是服务器崩溃与基础设施稳定之间的差异。

Java 26 不仅仅是“带 var 的 Java 8”。像 Project Loom、Panama (FFM) 和作用域值这样的特性,要求我们在系统架构上进行 根本性转变。如果我们继续使用 2014 年的模式来构建框架,我们将永远无法解锁现代硬件的真正性能。

你愿意重构你的应用程序,放弃 ThreadLocal 并拥抱 ScopedValue 吗? 请在评论中告诉我。

*** ** * ** ***## 探索 Exeris 内核 本文描述的零分配架构不仅仅是理论——它是正在运行的代码。Exeris 是一个为极端密度构建的开源核心、后容器云内核。如果你厌倦了 GC 暂停,并想看看原生 I/O、Panama FFM 和虚拟线程编排在实践中的样子,请探索 Exeris 内核:

🔗 GitHub 仓库: exeris-systems/exeris-kernel

本文 Why I Banned ThreadLocal from the Exeris Kernel (And What Replaced It) 最初发布在 foojay 上。