ThreadLocal 内存泄漏:网关服务每三天重启一次的真相

网关服务跑几天内存就涨满,频繁 Full GC、接口卡顿,只能定时重启。dump 堆发现一堆对象被 ThreadLocalMap 拽住——经典 ThreadLocal 泄漏;还查出线程池复用导致用户上下文串号。几天治理:set 配对 remove、收口到 Filter、封装 AutoCloseable 强制清理、InheritableThreadLocal 改 TransmittableThreadLocal。

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 天

避坑清单

  1. ThreadLocal 的值存在每个 Thread 的 ThreadLocalMap 里,生命周期跟着线程走
  2. Entry 的 key 是弱引用、value 是强引用,泄漏的是 value
  3. 线程池的线程常驻不销毁,set 不 remove 的 value 会无限堆积
  4. 别指望 JDK 的探测式清理兜底,它只清探测路径上的脏 Entry
  5. set 必须配对 remove,用 try-finally 保证异常路径也能清
  6. 线程复用下不 remove 还会导致脏读:下个请求读到上个请求的残留
  7. 请求入口无条件 set(没值给默认值)、出口无条件 remove
  8. 把 set/remove 收口到 Filter/拦截器,业务代码只 get,最不易错
  9. 线程池场景 InheritableThreadLocal 会失效,跨线程传递用 TTL
  10. 泄漏靠老年代、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
声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理 邮箱1846861578@qq.com。
技术教程

雪花算法时钟回拨导致 ID 重复:一次凌晨主键冲突的复盘

2026-5-20 12:37:04

技术教程

接口幂等设计实战:从一次重复扣款事故说起

2026-5-20 12:43:31

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