2024 年我们的网关服务跑了几天后内存就慢慢涨满,最后频繁 Full GC、接口卡顿,只能定时重启续命。dump 堆内存分析,发现一堆本该被回收的对象被 ThreadLocalMap 死死拽住 —— 经典的 ThreadLocal 内存泄漏。同时还查出一个更隐蔽的问题:线程池复用线程时 ThreadLocal 没清理,导致用户 A 的请求读到了用户 B 的上下文。投了几天做 ThreadLocal 专项治理,本文复盘这次实战。
问题背景
业务:API 网关,Spring Boot,Tomcat 线程池处理请求
事故现象:
- 服务启动后内存缓慢上涨,3-4 天后逼近 heap 上限
- 接近上限时 Full GC 频繁,接口 P99 从 30ms 飙到 2s+
- 重启后恢复,过几天又复现 —— 典型内存泄漏曲线
- 偶发更诡异的 bug:日志里 A 用户的请求,traceId/userId 却是 B 的
现场排查:
# 1. 监控看 heap,老年代锯齿一路抬升,GC 后回不到底
# 2. dump 堆内存,用 MAT 分析
$ jmap -dump:format=b,file=heap.hprof
# MAT 的 Leak Suspects 直接点名:
"Thread" 实例持有大量内存,
通过 threadLocals -> ThreadLocalMap -> Entry[] 链路
retained 了一堆 UserContext / 大 byte[] 对象
# 3. 看 Entry 数组,发现大量 key 为 null 的 Entry
Entry.referent (key) = null ← ThreadLocal 已被 GC
Entry.value = UserContext@... ← value 还在!
根因:
1. ThreadLocal 的 value 在线程存活期间不会被回收
2. Tomcat/线程池的线程是常驻、复用的,几乎不销毁
3. 业务代码 set 了 ThreadLocal 却从不 remove
→ value 在常驻线程上越积越多 → 泄漏
4. 线程复用 + 没 remove → 上一个请求的 ThreadLocal 残留
→ 下一个请求读到脏数据(串号 bug)
修复 1:看懂 ThreadLocal 的内存结构
// === ThreadLocal 到底把数据存哪了 ===
// 不是存在 ThreadLocal 对象里,而是存在"每个线程自己"身上:
//
// Thread 对象
// └── threadLocals (字段)
// └── ThreadLocalMap
// └── Entry[] 每个 Entry:
// key = ThreadLocal 实例(弱引用 WeakReference)
// value = 你 set 进去的值(强引用)
//
// 关键点 1:Entry 的 key 是弱引用,value 是强引用
// 关键点 2:ThreadLocalMap 的生命周期 == Thread 的生命周期
public class ThreadLocal {
// set 实际是往"当前线程的 ThreadLocalMap"里塞
public void set(T value) {
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t); // t.threadLocals
if (map != null) map.set(this, value);
else createMap(t, value);
}
// Entry 继承 WeakReference,key(ThreadLocal)是弱引用
static class Entry extends WeakReference> {
Object value; // value 是强引用!
Entry(ThreadLocal> k, Object v) {
super(k); // k 作为弱引用 referent
value = v;
}
}
}
// === 泄漏链条 ===
// 1. ThreadLocal 实例没有别的强引用时,会被 GC → Entry.key 变 null
// 2. 但 Entry.value 是强引用,仍被 Entry 持有
// 3. Entry 被 Entry[] 持有,Entry[] 被 ThreadLocalMap 持有,
// ThreadLocalMap 被 Thread 持有
// 4. 线程是常驻的(线程池) → 这条强引用链永不断裂
// 5. value 永远活着 → 泄漏
修复 2:为什么 key 用弱引用还会泄漏
// === 常见误解:"key 是弱引用,所以不会泄漏" ===
// 弱引用只解决了 key(ThreadLocal 本身)的回收,
// 没解决 value 的回收 —— value 是实打实的强引用。
// JDK 确实做了一点"补救":get/set/remove 时会顺手清理
// 一部分 key==null 的"脏 Entry"(expungeStaleEntry)。
// 但这只是"探测式"清理:
// - 只在你恰好操作 ThreadLocalMap 时触发
// - 只清扫探测路径上遇到的脏 Entry,不是全量
// 如果一个 ThreadLocal set 完就再不碰了,它的脏 Entry
// 可能永远没机会被清掉。
// === 结论:别指望 JDK 自动兜底,必须自己 remove ===
private static final ThreadLocal CTX = new ThreadLocal<>();
// 错误用法:set 了不 remove
public void handleBad(Request req) {
CTX.set(buildContext(req));
doBusiness();
// 方法结束,但线程不结束,CTX 的 value 永远留在这个线程上
}
// 正确用法:try-finally 保证 remove
public void handleGood(Request req) {
CTX.set(buildContext(req));
try {
doBusiness();
} finally {
CTX.remove(); // 铁律:set 必须配对 remove
}
}
// remove() 会把当前线程 ThreadLocalMap 里这个 ThreadLocal
// 对应的 Entry 整个删掉(key 和 value 一起),彻底断链。
修复 3:线程池场景的脏数据问题
// === 比泄漏更可怕的:线程复用导致的"脏读" ===
// 线程池里的线程处理完请求 A,不销毁,接着处理请求 B。
// 如果 A 没 remove,B 一进来 CTX.get() 拿到的是 A 的上下文!
// 我们真实踩的坑:
private static final ThreadLocal CTX = new ThreadLocal<>();
public Response process(Request req) {
if (req.hasToken()) {
CTX.set(parseUser(req.getToken())); // 有 token 才 set
}
// ... 业务逻辑里某处 CTX.get().getUserId() ...
// 问题:如果当前请求没 token、没 set,
// CTX.get() 拿到的是这个线程上次请求残留的 UserContext!
return handle(req);
}
// === 修复:无论如何,请求入口设、出口清 ===
public Response process(Request req) {
try {
UserContext ctx = req.hasToken()
? parseUser(req.getToken())
: UserContext.anonymous(); // 没 token 也给个默认值
CTX.set(ctx);
return handle(req);
} finally {
CTX.remove(); // 出口无条件清理
}
}
// === 更系统的做法:用拦截器/Filter 统一管理生命周期 ===
public class ContextFilter implements Filter {
@Override
public void doFilter(ServletRequest req, ServletResponse resp,
FilterChain chain) throws IOException, ServletException {
try {
CTX.set(buildContext((HttpServletRequest) req));
chain.doFilter(req, resp);
} finally {
CTX.remove(); // 请求结束统一清,业务代码不用操心
}
}
}
// 把 set/remove 收口到 Filter,业务代码只 get,不碰生命周期 ——
// 这是最不容易出错的方式。
修复 4:跨线程传递 — InheritableThreadLocal 的坑
// === 需求:主线程的 traceId 要能传给子线程 ===
// 普通 ThreadLocal 是线程隔离的,子线程读不到父线程的值。
// === InheritableThreadLocal:能传给子线程,但只在"创建时" ===
private static final InheritableThreadLocal TRACE =
new InheritableThreadLocal<>();
// new Thread() 时,子线程会拷贝父线程当时的 TRACE 值。
// === 坑:线程池场景下 InheritableThreadLocal 几乎失效 ===
// 线程池的线程是"很久以前"创建的,创建时拷贝的是那时的值。
// 之后线程被复用处理新任务,InheritableThreadLocal 的值
// 还停留在线程创建那一刻 —— 完全是错的、过期的。
ExecutorService pool = Executors.newFixedThreadPool(8);
TRACE.set("trace-A");
pool.submit(() -> {
// 这里 TRACE.get() 不是 "trace-A"!
// 而是这个池线程当年被创建时的值(可能是 null 或更早的脏值)
log.info("traceId={}", TRACE.get());
});
// === 正确方案:用阿里 TransmittableThreadLocal(TTL)===
// TTL 在"任务提交时"快照父线程的值,"任务执行时"还原,
// 专门解决线程池复用场景的上下文传递。
private static final TransmittableThreadLocal TRACE_TTL =
new TransmittableThreadLocal<>();
// 用 TtlExecutors 包装线程池,或用 TtlRunnable 包装任务
ExecutorService ttlPool = TtlExecutors.getTtlExecutorService(pool);
TRACE_TTL.set("trace-A");
ttlPool.submit(() -> {
log.info("traceId={}", TRACE_TTL.get()); // 正确拿到 trace-A
});
// 注意:TTL 同样要在任务结束后清理,别因为换了实现就忘了 remove。
修复 5:排查与定位手段
# === 1. 确认是不是 ThreadLocal 泄漏 ===
# dump 堆,用 MAT 打开,看 Leak Suspects 报告
$ jmap -dump:live,format=b,file=heap.hprof
# MAT 里搜 ThreadLocalMap$Entry,按 retained size 排序
# 如果排前面的 Entry 大量 key==null 而 value 很大 → 实锤泄漏
# === 2. OQL 查询 key 为 null 的脏 Entry ===
# 在 MAT 的 OQL 窗口执行:
# SELECT * FROM java.lang.ThreadLocal$ThreadLocalMap$Entry e
# WHERE e.referent = null
# 列出所有"key 已被回收但 value 还在"的泄漏 Entry
# === 3. 看是哪个线程持有 ===
# Entry 往上找 GC Root,能定位到具体的 Thread
# 多半是 http-nio-xxx-exec-* (Tomcat) 或自定义线程池的线程
# === 4. 运行时快速观测 ===
$ jcmd GC.class_histogram | grep -i context
# 业务上下文类的实例数如果远超并发线程数 → 泄漏迹象
// === 5. 加一层自检:封装 ThreadLocal,强制配对 ===
public final class ContextHolder {
private static final ThreadLocal TL = new ThreadLocal<>();
// 用 AutoCloseable 强制 try-with-resources,出作用域自动 remove
public static Scope open(UserContext ctx) {
TL.set(ctx);
return TL::remove; // Scope 是个 AutoCloseable
}
public static UserContext get() {
UserContext c = TL.get();
if (c == null) throw new IllegalStateException("上下文未初始化");
return c;
}
public interface Scope extends AutoCloseable {
@Override void close(); // 去掉受检异常
}
}
// 业务代码:try-with-resources 保证一定 remove
// try (var scope = ContextHolder.open(ctx)) {
// doBusiness();
// } // 自动 remove,想忘都忘不了
修复 6:监控告警
# ThreadLocal 泄漏没有专门指标,靠"内存 + GC + 线程"侧面监控
groups:
- name: jvm-memory-leak
rules:
# 1. 老年代使用率持续高位(泄漏的典型表现)
- alert: JvmOldGenHigh
expr: |
jvm_memory_used_bytes{area="heap",id=~".*Old.*|.*Tenured.*"}
/ jvm_memory_max_bytes{area="heap",id=~".*Old.*|.*Tenured.*"} > 0.85
for: 10m
annotations:
summary: "{{ $labels.instance }} 老年代 > 85%,疑似内存泄漏"
# 2. Full GC 频繁(泄漏到后期的症状)
- alert: JvmFullGcFrequent
expr: increase(jvm_gc_pause_seconds_count{action=~".*Major.*|.*Full.*"}[10m]) > 5
for: 10m
annotations:
summary: "{{ $labels.instance }} 10 分钟 Full GC 超 5 次"
# 3. GC 后老年代回不去(最能说明泄漏 —— 内存只增不减)
- alert: JvmHeapNotReclaimed
expr: min_over_time(jvm_memory_used_bytes{area="heap"}[1h]) > 2e9
for: 30m
annotations:
summary: "{{ $labels.instance }} GC 后堆内存基线持续抬升"
# 4. 线程数异常(线程暴涨会放大 ThreadLocal 泄漏)
- alert: JvmThreadsHigh
expr: jvm_threads_live_threads > 800
for: 5m
annotations:
summary: "{{ $labels.instance }} 存活线程 > 800,排查线程池配置"
优化效果
指标 治理前 治理后
=============================================================
heap 内存曲线 3-4 天涨满 长期平稳锯齿
定时重启 每 3 天一次 取消,不再需要
Full GC 频率 后期每分钟数次 基本为 0
接口 P99(运行 3 天后) 2s+ 稳定 30ms
ThreadLocal 脏读 bug 偶发用户串号 0
ThreadLocalMap 脏 Entry 数万(key==null) 0
跨线程 traceId 传递 线程池下丢失/错乱 TTL 准确传递
ThreadLocal 生命周期管理 业务各自 set 收口到 Filter 统一
压测(持续压测 72 小时):
- 治理前:heap 基线持续抬升,48h 后触发 Full GC 风暴
- 治理后:heap 基线 72h 无抬升,GC 后稳定回到低位,无脏读
排查与改造:
- dump 堆 + MAT 定位泄漏点:0.5 天
- 全量排查 set 无 remove 的代码(17 处):1 天
- set/remove 收口到 Filter + 封装 AutoCloseable:1 天
- InheritableThreadLocal 改 TTL:0.5 天
- 72h 长稳压测验证:3 天
避坑清单
- ThreadLocal 的值存在每个 Thread 的 ThreadLocalMap 里,生命周期跟着线程走
- Entry 的 key 是弱引用、value 是强引用,泄漏的是 value
- 线程池的线程常驻不销毁,set 不 remove 的 value 会无限堆积
- 别指望 JDK 的探测式清理兜底,它只清探测路径上的脏 Entry
- set 必须配对 remove,用 try-finally 保证异常路径也能清
- 线程复用下不 remove 还会导致脏读:下个请求读到上个请求的残留
- 请求入口无条件 set(没值给默认值)、出口无条件 remove
- 把 set/remove 收口到 Filter/拦截器,业务代码只 get,最不易错
- 线程池场景 InheritableThreadLocal 会失效,跨线程传递用 TTL
- 泄漏靠老年代、Full GC、GC 后基线、线程数侧面监控,dump 用 MAT 定位
总结
这次 ThreadLocal 内存泄漏的排查,让我对这个看似简单的工具有了重新认识。很多人对 ThreadLocal 的理解停留在"线程隔离的变量"这一层,但真正的关键在于它把数据存在哪 —— 不是存在 ThreadLocal 对象里,而是存在每一个 Thread 自己的 ThreadLocalMap 里,这意味着这份数据的生命周期是跟着线程走的,而不是跟着 ThreadLocal 走的。在用普通 new Thread() 的年代,线程用完就销毁,数据也跟着没了,问题不明显;但今天到处都是线程池,池里的线程是常驻的、几乎永不销毁,你 set 进去的 value 只要不主动 remove,就会一直挂在那个线程身上。有人会说"Entry 的 key 是弱引用,会自动回收啊",这是最大的误解 —— 弱引用回收的只是 key(ThreadLocal 实例本身),而真正占内存的 value 是实打实的强引用,key 被回收后反而留下一个 key 为 null 的"脏 Entry",value 还牢牢地被这条 Thread → ThreadLocalMap → Entry 的强引用链拽着,线程不死它就不死。JDK 确实有一点探测式的自动清理,但那只是在你恰好调用 get/set/remove 时顺手扫一下探测路径,根本不是全量兜底,完全不能依赖。所以唯一可靠的做法就是一条铁律:set 必须配对 remove,而且要用 try-finally 保证哪怕抛了异常也能清掉。更值得警惕的是,这个问题不只是"内存泄漏"这么简单 —— 线程复用还会带来脏读,这次我们就遇到了一个请求没 set、却 get 到了上一个请求残留上下文的串号 bug,所以请求入口要无条件 set、出口要无条件 remove。工程上最稳的办法,是把 set 和 remove 收口到 Filter 或拦截器里统一管理,业务代码只负责 get,根本不碰生命周期,从源头上消灭"忘记 remove"的可能。另外如果有跨线程传递上下文的需求,要知道 InheritableThreadLocal 在线程池场景下是失效的(它只在线程创建那一刻拷贝一次),正确的选择是阿里开源的 TransmittableThreadLocal。ThreadLocal 用对了是优雅的上下文传递工具,用错了就是悄无声息的内存杀手,理解它的存储模型和引用链,是用好它的前提。
—— 别看了 · 2026