ThreadLocal:正确的惯用法,错误的时代
Java EE Servlet 容器(约 1999 年):一个线程处理一个请求。线程从请求开始到结束都只处理这一个请求,然后终止。ThreadLocal 以当前线程为键存储值。在“一线程一请求”的模型下,ThreadLocal 中的值正好属于这一个请求。该惯用法是正确的。
线程池改变了契约。一个线程处理请求 A,将主体 A 存入 ThreadLocal,完成请求 A 后返回线程池。线程池不会重置线程状态。ThreadLocal.remove() 可以清理,但需要显式调用。当这种纪律缺失时,请求 B 复用同一线程,就会读取到 ThreadLocal 中残留的主体 A。
5 步泄露过程:
1. 请求 A 到达。服务器分配 Thread-7。
2. Thread-7 在请求开始时执行 ThreadLocal.set(principal_A)。
3. 请求 A 完成。Thread-7 返回线程池。未调用 ThreadLocal.remove()。
4. 请求 B 到达。服务器分配 Thread-7(线程池复用)。
5. Thread-7 调用 ThreadLocal.get():返回 principal_A。请求 B 以错误的身份运行。
为什么测试无法发现
单元测试在隔离环境中运行:没有线程池,也没有线程复用。集成测试使用新线程或在测试间重置状态。负载测试在低并发且正确用户下预热。该缺陷仅在存在线程池复用且请求重叠时才会显现,而这种条件只在生产环境正常流量下出现,任何检查该问题的测试配置都不会触发。
安全后果
用户 A 的身份信息泄露到用户 B 的请求中。不是崩溃,也不是异常,而是一次静默的安全边界违规:用户 B 以用户 A 的身份执行操作、读取用户 A 的数据,或绕过用户 B 的权限。系统不会产生任何错误。日志显示请求 B 已获得授权。一切看起来都正常。
五步流程
ThreadLocal 泄漏的五个步骤之所以重要,原因在于:缺陷并非在错误代码运行的那一刻发生,而是在更早的阶段——缺少清理步骤时就已经埋下隐患。
作用域附加值
ThreadLocal 将值附加到线程。线程的生命周期长于请求。两者不匹配。
作用域附加值将值附加到一个工作单元。当工作单元结束时,该值也随之结束。无需显式清理,也无需调用 remove() 来清除。
Java 21: ScopedValue
// ThreadLocal(缺陷载体)
static final ThreadLocal<Principal> PRINCIPAL = new ThreadLocal<>();
PRINCIPAL.set(principal); // 在请求开始时设置
// ... 请求处理 ...
PRINCIPAL.remove(); // 必须调用;常被遗忘
// ScopedValue(正确的载体)
static final ScopedValue<Principal> PRINCIPAL = ScopedValue.newInstance();
ScopedValue.where(PRINCIPAL, principal).run(() -> {
// ... 请求处理 ...
// run() 返回后值会自动消失
});
Go: context.Context
// context.Context 显式携带值;作用域 = 函数调用链
ctx := context.WithValue(r.Context(), principalKey, principal)
handleRequest(ctx) // ctx 被显式传递;函数返回后即失效
Python asyncio: contextvars.ContextVar
# ContextVar 仅作用于每个异步任务
PRINCIPAL: ContextVar[str] = ContextVar('principal')
token = PRINCIPAL.set(principal) # 仅为此任务设置
# ... 任务处理 ...
PRINCIPAL.reset(token) # 或者:随任务结束而失效
这些示例共有的特性:生命周期与工作单元一致。当请求结束(run() 返回、函数返回、任务完成),值也随之结束。无需担心遗漏清理,也不会污染对象池。
识别并替换
一个 Java EE 应用在请求开始时将租户 ID 存入 ThreadLocal。在高负载下,租户 A 的 ID 出现在租户 B 的请求中。租户 B 的查询返回了租户 A 的数据。没有抛出异常。该缺陷仅在生产负载测试中出现。