2024 年我们一个 Web 服务出过一次很折磨人的内存问题。它不是那种一上来就崩的故障,而是慢性病:服务启动后内存看着正常,可运行个三五天,老年代就被一点点填满,Full GC 越来越频繁,最后抛出 OutOfMemoryError,只能靠定时重启硬扛。我们一开始怀疑是缓存没设上限、是连接没关,查了一圈都不是。最后把堆 dump 下来用 MAT 分析,才看到罪魁祸首——一大堆本该早就被回收的对象,被线程池里的工作线程,通过 ThreadLocal 死死地拽住不放。这是一个非常典型、却又非常容易被忽视的 ThreadLocal 内存泄漏。投了几天把 ThreadLocal 的原理和泄漏机制彻底搞清,本文复盘这次实战。
问题背景
业务:Spring Boot Web 服务,Tomcat 线程池处理请求
事故现象:
- 服务运行 3~5 天后内存持续上涨,Full GC 频繁
- 最终 OutOfMemoryError,靠每天定时重启续命
- 重启后内存立刻回落,过几天又涨上来
现场排查:
# 1. 看 GC,老年代只增不减
$ jstat -gcutil 2000
S0 S1 E O M YGC FGC FGCT
0.00 0.00 68.21 97.83 95.1 3201 142 88.4
# 老年代 O 长期 97%+,Full GC 回收不掉多少
# 2. dump 堆,用 MAT 分析
$ jmap -dump:format=b,file=heap.hprof
# MAT 的 Leak Suspects 报告:
# 数万个 UserContext 实例,被 Thread 对象持有,
# 引用链:Thread -> ThreadLocalMap -> Entry -> value
# 3. 看可疑代码:一个 ThreadLocal 工具类
public class UserContextHolder {
private static final ThreadLocal CTX
= new ThreadLocal<>();
public static void set(UserContext c) { CTX.set(c); }
public static UserContext get() { return CTX.get(); }
// 注意:没有 remove() 方法!没有任何地方清理
}
# 4. 调用处:拦截器里 set,但请求结束后从不 remove
preHandle: UserContextHolder.set(parseUser(request));
# afterCompletion / finally 里:什么都没做
根因:
1. ThreadLocal 设置了值,但请求处理完后从不 remove
2. Tomcat 线程池的线程是长期复用的,不会销毁
3. 线程不死 -> 它的 ThreadLocalMap 不死 -> value 一直被引用
4. 每来一个请求就 set 一个新 UserContext,旧的回收不掉
修复 1:先看清 ThreadLocal 到底存在哪
// === 一个常见的误解 ===
// 很多人以为:值是存在 ThreadLocal 对象里的。
// 错。值其实存在【每个线程自己】身上。
// === 真实结构 ===
// 每个 Thread 对象,内部都有一个字段:
// ThreadLocal.ThreadLocalMap threadLocals;
// 当你调用 threadLocal.set(value) 时,实际发生的是:
// 1. 拿到【当前线程】
// 2. 找到当前线程的那个 threadLocals 这个 Map
// 3. 以【这个 ThreadLocal 对象本身】为 key,
// value 为值,存进这个 Map
// 用伪代码表示 set 的本质:
public void set(T value) {
Thread t = Thread.currentThread();
ThreadLocalMap map = t.threadLocals; // 线程自己的 map
map.set(this, value); // key 是 this(ThreadLocal)
}
// === 由此得到两个关键认知 ===
// 1. ThreadLocal 实现"线程隔离",靠的是
// "每个线程查的是自己那张 map" —— 天然互不干扰。
// 2. value 的生命周期,挂在【线程】身上。
// 线程活多久,只要不 remove,这个 value 就活多久。
// ★ 这一条,正是内存泄漏的伏笔。
// === 典型用途 ===
// - 存当前登录用户(我们这次的 UserContext)
// - 存请求链路的 traceId
// - SimpleDateFormat 这种非线程安全对象,每线程一份
// - 事务/连接的线程绑定
修复 2:ThreadLocalMap 的弱引用设计
=== ThreadLocalMap 里的 Entry 长什么样 ===
ThreadLocalMap 内部是一个 Entry 数组。每个 Entry:
Entry extends WeakReference {
Object value; // 强引用!指向你存的值
}
Entry 的【key】(即 ThreadLocal 对象)是【弱引用】
Entry 的【value】(你存的值)是【强引用】
=== 为什么 key 要设计成弱引用 ===
设想 key 是强引用:那么只要线程还活着,
它的 map -> entry -> 强引用着 ThreadLocal 对象,
ThreadLocal 对象就永远回收不掉。
设计成弱引用后,一旦外部没有别的地方再引用这个
ThreadLocal,下次 GC 就能把它回收 ——
此时 entry 的 key 变成 null。
=== 但问题来了:key 没了,value 还在 ===
GC 回收了 key(ThreadLocal),entry 里:
key = null (被回收了)
value = 还在! (它是强引用,GC 动不了它)
于是出现一个 key 为 null、value 还占着内存的
"幽灵 entry"。这个 value:
- 你再也拿不到了(没有 key 了,get 不到)
- 它也回收不掉(被线程的 map 强引用着)
这就是 ThreadLocal 内存泄漏的【经典模型】。
=== ThreadLocal 自己的"补救" ===
ThreadLocalMap 在 set/get/remove 时,会"顺便"
清理一些遇到的 key=null 的 entry。
但这个清理是【不彻底、不及时】的 ——
它只清理"恰好被探测到"的那些,不能保证清干净。
所以,绝不能依赖它,必须自己 remove。
修复 3:为什么我们的服务会泄漏
// === 把链条串起来看我们的事故 ===
// 前提:Tomcat 用线程池处理请求。
// 线程池的线程是【长期复用】的 —— 一个请求处理完,
// 线程不销毁,放回池子里等下一个请求。
// 一个 Tomcat 线程,可能活到服务重启那一刻。
// === 我们的代码做了什么 ===
// 拦截器 preHandle:每个请求进来,set 一个新 UserContext
UserContextHolder.set(parseUser(request));
// 请求处理完:没有任何地方 remove
// === 泄漏是怎么发生的 ===
// 请求 1 来,线程 A 处理,A.map[CTX] = UserContext1
// 请求 1 结束,线程 A 回池子,A.map[CTX] 还是 UserContext1
// 请求 2 来,又是线程 A 处理,A.map[CTX] = UserContext2
// -> UserContext1 这下被覆盖,它能回收了
// 看起来"覆盖"会救场?不完全 ——
// 关键在我们这个案例里 CTX 是 static final 的,
// 它【永远不会被回收】,所以走的不是"key=null 泄漏",
// 而是更直接的:value 一直被线程的 map 强引用着,
// 线程不死,最后一次 set 进去的 value 就一直不死。
// === 两种泄漏,要分清 ===
// 泄漏类型 A(key 被回收):ThreadLocal 不是 static,
// 用完没人引用,key 被 GC,value 成幽灵,回收不掉。
// 泄漏类型 B(线程长命):ThreadLocal 是 static 长期存在,
// 但线程是池化的长命线程,set 后不 remove,
// value 就跟着线程一直活着。
// 我们属于 B。Web 服务里 B 是最常见的。
// === 共同的根:线程的生命周期 >> 业务的生命周期 ===
// 一个请求(业务)就几十毫秒,
// 一个线程池线程能活几个月。
// 把"请求级"的数据塞进"线程级"的容器,
// 又不在请求结束时清掉,泄漏就是必然的。
修复 4:正确姿势——用完必须 remove
// === 黄金法则:set 和 remove 成对出现,且 remove 放 finally ===
// remove 会把当前线程 map 里这个 ThreadLocal 对应的
// entry 整个删掉(key 和 value 一起),彻底断引用。
// === 在拦截器里:用 try-finally 保证一定清理 ===
public class UserContextInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest req,
HttpServletResponse resp, Object handler) {
UserContextHolder.set(parseUser(req));
return true;
}
@Override
public void afterCompletion(HttpServletRequest req,
HttpServletResponse resp,
Object handler, Exception ex) {
// ★ 关键:请求一结束,无论成功失败,都清掉
UserContextHolder.remove();
}
}
// === 给工具类补上 remove 方法 ===
public class UserContextHolder {
private static final ThreadLocal CTX
= new ThreadLocal<>();
public static void set(UserContext c) { CTX.set(c); }
public static UserContext get() { return CTX.get(); }
public static void remove() { CTX.remove(); } // 补上!
}
// === 如果是业务代码里临时用,也务必 try-finally ===
public void doSomething() {
ContextHolder.set(buildContext());
try {
// ... 业务逻辑,期间各处用 ContextHolder.get()
} finally {
ContextHolder.remove(); // 一定会执行
}
}
// === 一个推荐:让 ThreadLocal 尽量是 static final ===
// static final 的 ThreadLocal 全局唯一、不会被回收,
// 反而避免了"泄漏类型 A"。
// 它配合"严格 remove",是最稳的组合。
修复 5:不止泄漏——线程池还会"串数据"
// === 一个比泄漏更可怕的后果:数据串了 ===
// 还是因为线程池线程复用。如果忘了 remove:
// 请求 1(用户张三)用线程 A,set 了张三的 UserContext
// 请求 1 结束,没 remove,线程 A 的 map 里还留着张三
// 请求 2(用户李四)又恰好被分到线程 A 处理
// 如果请求 2 某条路径上【忘了 set】(比如某个
// 不经过拦截器的内部调用),直接 get() ——
// 它拿到的,是上一个请求残留的【张三】!
// -> 李四的操作,用了张三的身份。这是严重的越权 bug。
// === 所以 remove 不只是为了"内存" ===
// remove 同时保证了:线程被复用前,它身上不留任何
// 上一个任务的痕迹。这对正确性的意义,
// 比"省内存"还重要。
// === 自定义线程池场景:用装饰器统一兜底 ===
// 业务自己的线程池,可以包装 Runnable,
// 强制在任务结束后清理 ThreadLocal:
public class CleaningExecutor {
public void execute(Runnable task) {
executor.execute(() -> {
try {
task.run();
} finally {
UserContextHolder.remove(); // 任务结束兜底清理
TraceHolder.remove();
// 把所有用到的 ThreadLocal 都清一遍
}
});
}
}
// 这样即使某个任务内部漏了 remove,线程归还前也被清干净。
// === 经验 ===
// "线程池 + ThreadLocal"这对组合,
// 泄漏和串数据是【一体两面】,根都是"忘了 remove"。
// 把 remove 当成和"关闭流""释放锁"一样的强制纪律。
修复 6:父子线程传递与 TransmittableThreadLocal
// === 一个延伸问题:子线程拿不到父线程的 ThreadLocal ===
// ThreadLocal 是线程隔离的,主线程 set 的值,
// 你在它里面 new 出来的子线程 get() 不到 —— 各是各的 map。
// === JDK 自带的 InheritableThreadLocal ===
// 它能让【子线程被创建时】,复制一份父线程的值过来。
private static final InheritableThreadLocal TRACE
= new InheritableThreadLocal<>();
// 父线程 set 了 traceId,父线程里 new Thread() 出来的
// 子线程,创建那一刻就继承到了这个 traceId。
// === 但它对"线程池"无效!===
// InheritableThreadLocal 只在【线程被创建时】复制值。
// 线程池里的线程是【复用】的,不是每个任务都新建 ——
// 任务提交时线程早就建好了,根本不会再复制。
// 结果:线程池里的任务,继承到的是
// 这个池线程"第一次被创建时"的那个陈旧的值。
// === 解法:阿里的 TransmittableThreadLocal(TTL)===
// TTL 专门解决"在线程池场景下传递 ThreadLocal"。
// 它的思路:在【任务提交那一刻】捕获父线程的值,
// 在【任务执行时】把值设进执行线程,执行完再恢复。
private static final TransmittableThreadLocal TTL_TRACE
= new TransmittableThreadLocal<>();
// 配合 TtlExecutors 包装线程池:
ExecutorService pool = TtlExecutors.getTtlExecutorService(
Executors.newFixedThreadPool(8));
// 这样提交进去的任务,就能正确拿到提交时刻父线程的值。
// === 但别忘了:TTL 也要 remove ===
// 不管用哪种 ThreadLocal,"用完清理"这条铁律不变。
// 跨线程传递解决的是"传得到",不解决"要清理"。
优化效果
指标 治理前 治理后
=============================================================
运行数天后老年代 97%+,回收不掉 平稳,GC 后正常回落
Full GC 频率 越来越频繁 回到正常水平
OOM 3~5 天一次 不再发生
定时重启 每天重启续命 取消,长期运行
ThreadLocal remove 完全没有 拦截器 finally 强制
线程池任务清理 无 装饰器归还前兜底
请求间数据串号风险 存在(忘 set 即越权) remove 后彻底消除
跨线程上下文传递 子线程丢 traceId TTL 正确传递
ThreadLocal 使用规范 无 纳入编码规范与评审
治理过程:
- dump 分析定位泄漏根因:1 天
- 全局排查 ThreadLocal 使用点,补 remove:1.5 天
- 拦截器 finally + 线程池装饰器改造:1 天
- 跨线程传递场景接入 TTL:1 天
- ThreadLocal 使用规范沉淀 + 评审:0.5 天
避坑清单
- ThreadLocal 的值存在每个线程自己的 ThreadLocalMap 里,生命周期挂在线程身上
- ThreadLocalMap 的 Entry,key(ThreadLocal)是弱引用,value(你存的值)是强引用
- key 被 GC 回收后 value 仍被强引用,形成 key=null 的幽灵 entry,这是经典泄漏模型
- ThreadLocalMap 在 set/get 时会顺手清理部分 null entry,但不彻底,绝不能依赖它
- 线程池线程长期复用,请求级数据塞进线程级容器又不 remove,内存泄漏是必然
- set 和 remove 必须成对出现,remove 放在 finally 里,保证任何情况下都清理
- 忘 remove 不只是泄漏,线程复用时还会让下个请求 get 到上个请求的残留数据
- 残留数据会导致严重越权 bug,所以 remove 对正确性的意义比省内存更重要
- 自定义线程池可用 Runnable 装饰器,在任务结束 finally 里统一兜底清理 ThreadLocal
- InheritableThreadLocal 对线程池无效,线程池场景跨线程传递要用 TTL,且仍需 remove
总结
这次 ThreadLocal 内存泄漏的排查,治的是一种"慢性病"——它不像那种一上来就把服务打挂的急症,会立刻逼着你去查;它是温水煮青蛙,内存一天涨一点,几天后才到 OOM,中间还能靠定时重启把症状暂时压下去。正是这种"重启一下就好了"的假象,最容易让人放过真正的病根。我们最后是靠把堆 dump 下来、用 MAT 一层层看引用链,才看清那条致命的链路:Thread 指向它的 ThreadLocalMap,Map 里的 Entry 强引用着一大堆本该早就消亡的业务对象。要真正理解这个泄漏,得先纠正一个几乎人人都有的误解——ThreadLocal 的值,根本不存在 ThreadLocal 对象自己身上,它存在每一个线程自己身上。每个 Thread 对象内部都揣着一个叫 threadLocals 的 Map,你调用 set 的时候,本质是拿到当前线程、找到它那张专属的 Map,然后以 ThreadLocal 对象本身为 key 把值存进去。ThreadLocal 之所以能做到"线程隔离",靠的就是这个——每个线程查的永远是自己那张表,天然谁也碰不到谁。但也正是这个设计,埋下了泄漏的种子:你存进去的那个值,它的命,从此就拴在了"线程"这条命上,线程活多久,只要你不主动 remove,这个值就陪着线程活多久。泄漏的全部秘密,就藏在两条生命周期的巨大错配里。一个 HTTP 请求,它的业务生命周期可能只有短短几十毫秒;而处理这个请求的,是 Tomcat 线程池里一个被反复复用的线程,这个线程的生命周期,可能长达整个服务从启动到下次重启的几个月。我们犯的错误,就是把一份"请求级"的数据——当前登录用户的上下文——塞进了一个"线程级"的容器里,然后在请求结束时,什么都没做。请求走了,线程还在,线程身上那张 Map 里,就这么留下了一份再也没人需要、却也再也回收不掉的数据。下一个请求来,又塞一份新的,日积月累,老年代就这样被一具具"尸体"填满了。所以这次复盘最核心、也最该刻进肌肉记忆的一条结论是:用 ThreadLocal,set 和 remove 必须成对出现,而且 remove 一定要写在 finally 里。在 Web 服务里,这意味着在拦截器的 afterCompletion 中无条件地调用 remove,让每一个请求无论成功还是抛异常,在它离开的时候,都把自己在线程身上留下的痕迹擦干净。而在我深入下去之后,发现忘记 remove 的危害,其实比"泄漏内存"还要严重得多。内存泄漏顶多是服务慢慢变慢、最后崩溃,是个性能和稳定性问题;可线程池线程的复用,会让"忘了 remove"直接演变成一个数据正确性的灾难——如果请求 A 处理完用户张三的逻辑后没有清理,那个线程被回收进池子,紧接着请求 B 带着用户李四来了,又恰好被分配到同一个线程,而 B 的代码路径上某处如果漏掉了 set、直接调用 get,它拿到的将会是上一个请求残留下来的张三的身份。李四,就这样用着张三的身份在系统里操作——这是一个不折不扣的越权漏洞。想清楚这一点之后,我对 remove 的理解就彻底变了:remove 绝不只是"为了省内存"的一个善后动作,它是在保证一个被复用的线程,在迎接下一个任务之前,身上是干干净净、不带任何前一个任务私货的。从这个角度看,remove 之于 ThreadLocal,就如同 close 之于流、unlock 之于锁——它是一种不容商量的资源纪律。这次治理之后,我把 ThreadLocal 写进了团队的编码规范,而规范的第一条,不是讲它精巧的弱引用原理,而是一句最朴素的话:你只要用了 ThreadLocal 的 set,就必须能在代码里指出对应的那个写在 finally 里的 remove,指不出来,这段代码就过不了评审。原理值得理解,但真正能在深夜里保住你的服务的,从来都是这种简单、机械、不依赖任何小聪明的纪律。
—— 别看了 · 2026