ThreadLocal 在线程池里的两副面孔:一次 1.2GB 堆膨胀与「串号」事故的根因与治理
一、现象:两个看似不相关的故障,指向同一个对象
去年 Q3,我们的风控服务(JDK 17.0.2、Spring Boot 3.1.4、Tomcat 10.1、4 台 16C32G)连续出现两个"怪"故障:
故障 A(内存):堆内存从基线 1.8GB 缓慢爬到 5.2GB(heap -Xmx6g),Full GC 频率从每天 1 次涨到每 40 分钟 1 次,STW 从 80ms 涨到 2.3s。MAT 分析 dominant set 发现 ThreadLocalMap.Entry 占用 1.24GB,持有者全是 Tomcat 的 http-nio-8080-exec-* 线程(共 200 个),每个线程的 Entry 里挂着一个 RiskContext(含 3 个 List,每个 List 平均 450 个元素,元素是 200 字节的 RiskEvent 对象)。
故障 B(脏数据):客服后台偶发"串号"——A 用户的查询结果里混入了 B 用户的风控标签。日志显示同一台机器上,http-nio-8080-exec-47 线程在处理 uid=100234 的请求时,RiskContext.getTenantId() 返回了上一个请求的 tenantId=8801。
两个故障相隔 3 周,团队最初以为是两个独立问题。我拉了线程 dump 和 MAT histogram 对齐后,确认根因是同一个:ThreadLocal 在线程池复用线程时,"设了值但没清"既造成内存泄漏(Entry 的 value 被强引用),又造成脏数据(下一个请求读到上一个请求的 value)。
二、根因:ThreadLocalMap 的弱引用只挂在 key 上,value 是强引用
很多人对 ThreadLocal 的理解停在"线程隔离",漏了一个关键机制——ThreadLocalMap 的 Entry 用的是"key 弱引用 + value 强引用"。
// ThreadLocal.java (JDK 17)
static class Entry extends WeakReference<ThreadLocal<?>> {
Object value; // ← 强引用!
Entry(ThreadLocal<?> k, Object v) {
super(k);
value = v;
}
}
在线程池场景下,问题链条是这样的:
- Tomcat 线程池(maxThreads=200)的线程是长期存活的,不会因请求结束而销毁;
- 业务代码在
Filter/Interceptor里RiskContext.set(ctx),在请求结束时没有remove(); - 线程存活 → ThreadLocal 对象(key)被业务类持有,弱引用不会触发 GC 回收 key;
- value(RiskContext)被 Entry 强引用,无法 GC → 内存泄漏;
- 下一个请求复用同一个线程 →
RiskContext.get()读到上一个请求的 value → 脏数据。
反直觉点:很多人以为"请求结束 → 局部变量 ctx 出作用域 → GC 能回收",但只要 ThreadLocalMap.Entry 还持有 value 的强引用,GC 就收不走。线程不死,Entry 就在,value 就活。
三、具体做法:四步治理,可复现
步骤 1:定位泄漏的 ThreadLocal
# jmap 导出堆 dump
jmap -dump:live,format=b,file=heap.hprof <pid>
# MAT 打开,Leak Suspects 报告
# 或在 MAT 里:OQL 查询
SELECT * FROM java.lang.ThreadLocal$Entry WHERE value.@class.name LIKE '%RiskContext%'
MAT 的 "Dominator Tree" 会直接显示哪些 Thread 持有最大的 ThreadLocalMap.Entry 集合。
步骤 2:在 Filter/Interceptor 的 finally 里强制 remove
@Component
public class RiskContextFilter implements Filter {
private static final ThreadLocal<RiskContext> CTX = new ThreadLocal<>();
@Override
public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
throws IOException, ServletException {
try {
RiskContext ctx = new RiskContext(parseTenant(req));
CTX.set(ctx);
chain.doFilter(req, res);
} finally {
CTX.remove(); // ← 关键!必须在 finally,不能在正常路径
}
}
}
为什么必须 finally:如果 chain.doFilter 抛异常,正常路径的 CTX.remove() 不会执行,线程归还池后 value 就泄漏了。
步骤 3:ThreadLocal 的 value 设计成"不可变 + 轻量"
// ❌ 反例:value 是一个可变的大对象
class RiskContext {
List<RiskEvent> events; // 450 × 200B = 90KB
Map<String, String> labels; // 无界
}
// ✅ 正例:value 只放"标识",大对象走参数传递
class RiskContext {
final long tenantId; // 8B
final String traceId; // ~40B
}
// 大对象(events/labels)作为方法参数传递,不挂 ThreadLocal
原则:ThreadLocal 适合放"线程级别的身份标识"(tenantId、traceId、用户 ID),不适合放"请求级别的业务数据"。业务数据应该走方法调用栈传递,或者用 Reactor 的 Context(响应式场景)。
步骤 4:加监控告警
// 定时任务:每 5 分钟检查一次 ThreadLocal 泄漏
@Scheduled(fixedRate = 300_000)
public void checkThreadLocalLeak() {
for (Thread t : Thread.getAllStackTraces().keySet()) {
if (t.getName().startsWith("http-nio-8080-exec-")) {
// 通过反射读 Thread.threadLocals(生产慎用,建议用 Arthas)
// 或用 Arthas:thread <id> 看栈,vmtool --action getInstances --className RiskContext
}
}
}
更实用的做法:用 Arthas 在线上直接查:
# Arthas 命令
vmtool --action getInstances --className com.risk.RiskContext --limit 5
# 如果实例数 > 0 且不在活跃请求中,说明泄漏了
四、踩过的坑与反直觉之处
坑 1:ThreadLocal.set(null) 不等于 remove()。set(null) 只是把 value 置空,Entry 本身还在 ThreadLocalMap 里(key 的弱引用指向已死的 ThreadLocal 时才会被清理,但如果 ThreadLocal 对象还被业务类持有,Entry 就一直在)。必须用 remove(),它会真正从 map 里删掉 Entry。
坑 2:继承 ThreadLocal 的 InheritableThreadLocal 在线程池下是灾难。父线程设值 → 子线程继承 → 子线程用完不 remove → 子线程归还池 → 下一个"父线程"复用这个子线程时读到上一个"父线程"的值。在线程池场景下,InheritableThreadLocal 的"继承"语义和线程复用的"复用"语义直接冲突。替代方案:用 TransmittableThreadLocal(阿里 TTL)+ TtlExecutors 包装线程池,它会在任务提交时快照、任务完成时恢复。
坑 3:Spring 的 RequestScopeBean 内部用了 ThreadLocal 存 request 上下文,如果你自己又用 ThreadLocal 存业务数据,两者共用同一个线程的 ThreadLocalMap,一个没清就会污染另一个的遍历(ThreadLocalMap 的 set 方法在遇到 key 为 null 的 stale entry 时会触发 expungeStaleEntry,遍历整个 table 清理,O(n) 且可能触发大量 GC)。这就是为什么"多个 ThreadLocal 共用一个线程"时,任何一个泄漏都会拖慢所有其他 ThreadLocal 的读写。
坑 4:GC 的"弱引用回收"有延迟。即使 ThreadLocal 对象(key)真的被 GC 了(弱引用断开),Entry 的 value 也不会立即被回收——它要等到下一次对该线程的 ThreadLocalMap 执行 set/get/remove 操作时,才会被 expungeStaleEntry 清理。如果线程长期空闲(线程池核心线程不回收),stale entry 里的 value 就一直活着。所以"等 GC 回收"在线程池场景下是不可靠的,必须主动 remove。
五、结论与可复用清单
一句话:在线程池场景下,ThreadLocal 的"线程隔离"语义被"线程复用"打破,set 了必须 remove,value 必须轻量,InheritableThreadLocal 要慎用。
可复用清单:
- 所有 ThreadLocal 的 set 必须在 finally 里配 remove,无例外;
- ThreadLocal 的 value 只放标识(ID、traceId),不放业务数据;业务数据走方法参数或响应式 Context;
- 线程池场景禁用 InheritableThreadLocal,用 TransmittableThreadLocal + TtlExecutors 替代;
- 监控 ThreadLocal 实例数(Arthas vmtool 或 MAT),设告警阈值(如 > 活跃请求数 × 2);
- Code Review 检查项:新增 ThreadLocal 时,必须同时 review 到对应的 remove 调用;
- 性能影响量化:ThreadLocalMap 的 stale entry 清理是 O(n) 遍历,n 是 table 容量(默认 16,扩容到 32/64/128),泄漏 10 个 stale entry 后,每次 set/get 多 10 次弱引用判空 + 可能的清理遍历——在高 QPS 下这不是"小开销",而是可测量的 RT 劣化(我们实测 QPS 5000 时,stale entry 从 0→10 导致 P99 涨 0.8ms,绝对值不大但不可忽视)。
参考:社区内已有超时与重试的治理讨论(postId=23),其中提到的"全链路只允许一处重试"与本文的"ThreadLocal 只在最外层 Filter set/remove"是同一类原则——边界唯一、职责单一、不依赖隐式清理。