DevFix
Java已验证

ThreadLocal 内存泄漏排查:线程池复用导致 OutOfMemoryError 的坑

@debug_master更新于 3 天前阅读 6 min0

一句话先说结论:ThreadLocal 的 key 是弱引用,value 却是强引用。线程复用后 key 被 GC 回收,value 还挂在 ThreadLocalMap 里,Full GC 收不回来,最终 OutOfMemoryError。解决就一条:用完在 finally 里调 remove()

背景

ThreadLocal 给每个线程一份私有变量副本,线程之间互不干扰。典型用途是保存请求上下文、用户会话、事务信息这类「每个线程一份」的数据。

每个 Thread 内部都有一个 ThreadLocalMap,它是线程私有的微型哈希表,ThreadLocal 的值就存在这里。问题恰恰出在这张表上。

现象

出问题的往往是线程池加容器环境。应用跑一段时间后堆持续上涨,Full GC 也压不下去,最后抛出错误。

堆被吃满的情况:

Exception in thread "http-nio-8080-exec-3" java.lang.OutOfMemoryError: Java heap space

容器热部署、反复重载 classloader 的场景还会抛另一种:

java.lang.OutOfMemoryError: Metaspace

后者更隐蔽:ThreadLocal 强引用挡住了 classloader 的回收,元空间逐渐耗尽。

JDK 官方 bug 库 JDK-6558265 给了一个最小复现,用 2 个线程的线程池跑两次任务就能触发:

public class Main {
    private static final int HUGE_BLOCK_SIZE =
        (int) (Runtime.getRuntime().maxMemory() / 2 + 1);

    private static ThreadLocal<Object> tls = new ThreadLocal<Object>() {
        protected Object initialValue() {
            return new byte[HUGE_BLOCK_SIZE];
        }
    };

    private static ExecutorService executor = Executors.newFixedThreadPool(2);

    static void execute(Runnable task) throws Exception {
        Future<?> future = executor.submit(task);
        future.get();
    }

    public static void main(String[] args) throws Exception {
        Runnable task = () -> tls.get();
        execute(task); // 第一次:大对象塞进 worker-1 的 threadLocals
        execute(task); // 第二次:worker-1 还占着,直接 OOM
        executor.shutdown();
    }
}

只要第二次任务占不满剩余堆,就会在下一次分配时爆掉。

根因分析

关键在 ThreadLocalMapEntry 定义。它继承自 WeakReference

static class Entry extends WeakReference<ThreadLocal<?>> {
    Object value;
    Entry(ThreadLocal<?> k, Object v) {
        super(k);   // key 是弱引用
        value = v;  // value 是强引用
    }
}

super(k) 把 key 包成弱引用,value 却是普通强引用。

泄漏全程大概是这样:

  1. 线程池里的线程处理请求,方法里 set 了值。
  2. 方法结束没调 remove(),指向 ThreadLocal 实例的强引用也消失了。
  3. 下次 GC,key(弱引用)被回收,这个 Entry 变成 key = null
  4. value 还被 Entry 强引用,谁也访问不到,GC 也收不走。
  5. 线程放回池里继续复用,ThreadLocalMap 一直活着,key = null 的 Entry 越攒越多。

弱引用本意是「key 没了就能顺手清掉整个 Entry」。可实际只在 ThreadLocal.get()/set() 时,ThreadLocalMap 才顺带清理碰到的废旧 Entry。线程此后不再碰这个 ThreadLocal,那些 key = null 的 Entry 就一直躺着。

线程池把「线程短命」这个前提打破了。普通线程用完就死,threadLocals 跟着回收;线程池里的线程长命百岁,表一直存在。

解决方案

首选:finally 里 remove

最直接也最有效的是主动清理:

ThreadLocal<UserContext> ctx = new ThreadLocal<>();

void handle() {
    try {
        ctx.set(new UserContext());
        // 业务逻辑
    } finally {
        ctx.remove();
    }
}

remove() 会把当前线程这个 ThreadLocal 对应的 Entry 整个删掉。业务抛异常也不怕,finally 保证执行。

把 ThreadLocal 声明成 static final

频繁 new ThreadLocal 会造出多个实例,而弱引用清理依赖「同一个实例」的引用生命周期。声明成常量,一来只维护一个 key,二来更容易追踪:

private static final ThreadLocal<UserContext> CTX = new ThreadLocal<>();

框架层兜底

Tomcat 这类容器会检测到「线程结束时 ThreadLocal 没清」并打警告日志,帮你揪出漏清理的地方。它只提示、不替你清理,最终还是得回到 remove()

别指望 JDK 自动处理。JDK-6558265 提出过 ThreadLocalScope(任务结束后自动擦除线程局部变量),至今仍是 Unresolved 状态,说明官方把它当增强特性挂着,没当 bug 修。

思考过程

社区流传一个说法:「ThreadLocal 用了弱引用,所以不会泄漏」。这是错的。

弱引用只让 key 能被回收,value 才是泄漏的本体。弱引用的设计,是防止 ThreadLocal 实例本身长期占内存,不是防 value 泄漏。分清这点,面试和排查都不会被绕进去。

另一个容易漏的点:InheritableThreadLocal 一样会漏,只要值被线程池里的线程持有且不清理,机理完全相同。

小结

  • ThreadLocal 泄漏的根子是 value 强引用加线程复用,不是弱引用失效。
  • 表现是堆或元空间缓慢吃满,最终 OutOfMemoryError
  • 修法就一条:finallyremove(),再把 ThreadLocal 声明成 static final
  • 别等 JDK 兜底,ThreadLocalScope 至今没落地。

来源

最后更新于 2026-08-23

这篇帮到你了吗?

刚解决了一个棘手的报错?花两分钟记录下来,帮助下一个遇到同样问题的开发者。

贡献一条解法