线上内存慢慢涨到 OOM:一次 ThreadLocal 内存泄漏的复盘

Web 服务运行三五天后内存只涨不落,最终 OOM,靠定时重启续命。堆 dump 显示对象被线程池线程通过 ThreadLocal 死死拽住。几天彻底搞清:ThreadLocal 存值原理、ThreadLocalMap 弱引用设计、线程池复用导致的泄漏与数据串号、finally remove 铁律与 TTL 跨线程传递。

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 天

避坑清单

  1. ThreadLocal 的值存在每个线程自己的 ThreadLocalMap 里,生命周期挂在线程身上
  2. ThreadLocalMap 的 Entry,key(ThreadLocal)是弱引用,value(你存的值)是强引用
  3. key 被 GC 回收后 value 仍被强引用,形成 key=null 的幽灵 entry,这是经典泄漏模型
  4. ThreadLocalMap 在 set/get 时会顺手清理部分 null entry,但不彻底,绝不能依赖它
  5. 线程池线程长期复用,请求级数据塞进线程级容器又不 remove,内存泄漏是必然
  6. set 和 remove 必须成对出现,remove 放在 finally 里,保证任何情况下都清理
  7. 忘 remove 不只是泄漏,线程复用时还会让下个请求 get 到上个请求的残留数据
  8. 残留数据会导致严重越权 bug,所以 remove 对正确性的意义比省内存更重要
  9. 自定义线程池可用 Runnable 装饰器,在任务结束 finally 里统一兜底清理 ThreadLocal
  10. 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
声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理 邮箱1846861578@qq.com。
技术教程

消费者追不上生产者:一次 Kafka 消息积压的复盘

2026-5-20 16:50:32

技术教程

搜索接口越来越慢:一次 ElasticSearch 慢查询优化的复盘

2026-5-20 16:57:47

0 条回复 A文章作者 M管理员
    暂无讨论,说说你的看法吧
个人中心
购物车
优惠劵
今日签到
有新私信 私信列表
搜索