Skip to content

ThreadLocal 原理与内存泄漏

提出问题

每个线程持有自己的一份变量副本,这就是 ThreadLocal 的核心承诺。在 Spring 事务管理、请求上下文追踪、MDC 日志埋点等场景中,ThreadLocal 几乎是事实标准——但几乎每个用到它的项目,都或多或少踩过内存泄漏的坑。面试官问 ThreadLocal 一般不会只问"你怎么用",而是追问"为什么线程池里用 ThreadLocal 会内存泄漏"、"弱引用到底解决了什么问题"、"你到底有没有在生产环境遇到过 OOM"。这三个问题背后,是对 ThreadLocal 实现原理、Java 引用类型和线程生命周期三者关系的全面考察。

ThreadLocal 的数据结构:每个线程是自己的 Map

ThreadLocal 不存储数据,它只是一个"钥匙"。真正的数据存在 Thread 对象内部的一个字段 ThreadLocalMap 中。每个线程访问 ThreadLocal.set(value) 时,实际上是把当前 ThreadLocal 实例作为 key,value 作为值,放进了当前线程的 ThreadLocalMap 里。

java
// Thread 类内部
ThreadLocal.ThreadLocalMap threadLocals = null;

ThreadLocalMap 为什么不用 HashMap?

ThreadLocalMap 不是 HashMap,而是一个自定义的哈希表,使用**开放地址法(线性探测)**解决哈希冲突,而不是拉链法。这背后的设计取舍值得拆开看:

对比点ThreadLocalMap(开放地址法)HashMap(拉链法)
数据结构Entry[] 数组,冲突时探测下一个槽位Entry[] + 链表/红黑树
预期数据量≤16 个 Entry(一个线程不会绑太多 ThreadLocal)几千到百万级
缓存友好度数组连续内存,CPU 缓存命中率高链表节点分散,缓存miss 高
Node 对象开销无需额外链表节点对象每个 Entry 需要 next 指针
删除复杂度需要 rehash 后续元素(线性探测族的通病)O(1) 摘链

因为 ThreadLocal 的预期 Entry 数量很小(通常一个线程只持有少数几个 ThreadLocal 变量),开放地址法在少量数据时缓存友好度更高,不需要额外的链表节点对象。

java
static class ThreadLocalMap {
    static class Entry extends WeakReference<ThreadLocal<?>> {
        Object value;
        Entry(ThreadLocal<?> k, Object v) {
            super(k); // key 是弱引用
            value = v;
        }
    }
    private Entry[] table;
    private int size = 0;
    private int threshold; // 默认 2/3 负载因子
}

关键设计:Entry 继承 WeakReference,key 是弱引用指向 ThreadLocal 对象,value 是强引用。这个设计直接决定了 ThreadLocal 的内存泄漏行为。

哈希冲突时的线性探测流程

ThreadLocal 的哈希码通过 ThreadLocalHashCode 生成,每次 new 一个 ThreadLocal 实例,hashCode 就增加一个固定增量 0x61c88647(黄金分割数),这个增量能保证在 2 的幂次数组长度下,散列分布非常均匀。

set() 时发生冲突:

java
private void set(ThreadLocal<?> key, Object value) {
    Entry[] tab = table;
    int len = tab.length;
    int i = key.threadLocalHashCode & (len - 1);

    for (Entry e = tab[i]; e != null; e = tab[++i % len]) {
        ThreadLocal<?> k = e.get();
        if (k == key) {       // 命中同一个 ThreadLocal,覆盖
            e.value = value;
            return;
        }
        if (k == null) {       // 槽位有 Entry 但 key 已被 GC,替换
            replaceStaleEntry(key, value, i);
            return;
        }
        // 否则继续探测下一个槽位
    }
    tab[i] = new Entry(key, value);
    // ...
}

线性探测的代价:当删除一个 Entry 时,不能简单置 null,否则会切断后续探测链。ThreadLocalMap 的 remove() 实现是:找到 Entry → 清空 key 引用 → 调用 expungeStaleEntry() rehash 被影响的后续元素,保证探测链不中断。

弱引用设计的意图:防止 ThreadLocal 对象本身泄漏

假设 ThreadLocal 的 key 是强引用:当外部代码不再持有 ThreadLocal 引用(如 tl = null),GC 也无法回收 ThreadLocal 对象,因为 Thread 的 ThreadLocalMap 中还有强引用链。线程存活多久,ThreadLocal 对象就存活多久,这是彻底的泄漏

用弱引用后:ThreadLocal 对象被回收(外部没有强引用指向它时),GC 下次扫描即可回收它的内存。但 Entry 中的 value 仍然是强引用——Thread → ThreadLocalMap → Entry → value,这条链没有断。value 的泄漏是弱引用设计无法解决的,必须在每次 get/set/remove 时顺带清理。

引用链可视化

外部强引用 → ThreadLocal 实例 ← 弱引用(ThreadLocalMap.Entry)

                                value(强引用 → 大对象)

当外部强引用置 null(如 threadLocal = null),GC 回收 ThreadLocal 实例,Entry 的 get() 返回 null,但 value 的强引用链还在:

Thread → ThreadLocalMap → Entry → value(强引用,无法回收)

线程池场景下的内存泄漏链路

最典型的泄漏场景:

java
ExecutorService pool = Executors.newFixedThreadPool(10);
for (int i = 0; i < 1000; i++) {
    pool.submit(() -> {
        ThreadLocal<BigObject> tl = new ThreadLocal<>();
        tl.set(new BigObject()); // 10MB
        // 业务逻辑...
        // 忘记 tl.remove()
    });
}

泄漏量化分析

假设线程池 10 个线程,堆内存 4GB,BigObject 10MB:

轮次每个线程累积 stale Entry 数每个线程泄漏量总泄漏量堆剩余
100 次1001000MB10GBOOM 已触发
50 次50500MB5GBOOM 触发
10 次10100MB1GB剩余 3GB,隐性风险
1 次110MB100MB安全

关键点:每次迭代都 new 了一个新的 ThreadLocal 实例(tl 是局部变量),每个线程的 ThreadLocalMap 中插入了一个新的 Entry。迭代结束后 tl 局部变量出作用域,弱引用被 GC 清除,但 value 仍然强引用。10MB × 1000 ÷ 10 = 每个线程 1GB,10 个线程 × 1GB = 10GB,堆撑爆。

线程池的线程是复用的,不会销毁。每个线程执行完任务后,tl 局部变量被回收(弱引用被 GC 清除),但 ThreadLocalMap 中对应的 Entry 的 value 仍然被强引用,而 Entry 本身没有被清理。

expungeStaleEntry 的清理机制

java
private int expungeStaleEntry(int staleSlot) {
    Entry[] tab = table;
    int len = tab.length;

    // 清理 staleSlot 位置的 Entry
    tab[staleSlot].value = null;
    tab[staleSlot] = null;
    size--;

    // 顺带清理后续槽位
    Entry e;
    int i;
    for (i = nextIndex(staleSlot, len); (e = tab[i]) != null; i = nextIndex(i, len)) {
        ThreadLocal<?> k = e.get();
        if (k == null) {
            e.value = null;  // 释放 value
            tab[i] = null;
            size--;
        } else {
            // rehash:如果当前槽位不是理想位置,挪到正确位置
            int h = k.threadLocalHashCode & (len - 1);
            if (h != i) {
                tab[i] = null;
                while (tab[h] != null)
                    h = nextIndex(h, len);
                tab[h] = e;
            }
        }
    }
    return i;
}

getEntryset 方法在发生哈希冲突进行线性探测时,会调用 expungeStaleEntry() 清理 key 为 null 的 Entry,将 value 赋值为 null。但关键问题是:如果 get()set() 没有被调用,这些 stale entry 就永远不会被清理。这就是为什么线程池场景下,必须显式调用 remove()

生产事故:物流调度系统的 OOM

我参与过的某个物流调度系统,使用固定线程池处理订单,每个请求在 ThreadLocal 中缓存了用户画像(约 2MB JSON 对象)。业务逻辑中有个分支(约 5% 的请求)会跳过 finally 块中的 remove() 调用。系统每秒钟处理 200 个请求,10 个线程的核心线程池。

事故时间线:

  • 上线第 1 天:正常,堆 4GB,GC 正常
  • 上线第 3 天:Full GC 频率从 1 次/小时升到 1 次/10 分钟,老年代从 1.2GB 涨到 3GB
  • 上线第 5 天凌晨 3 点:OOM 触发,Dump 文件 4.2GB,线程 0 的 ThreadLocalMap 中有 2.3 万个 stale Entry
  • 排查:每个线程的 ThreadLocalMap$Entry 数量 = 2.3 万,每个 value 是 2MB → 单线程 46GB,但因为 OOM 已经触发,实际堆只存了部分

修复方案: finally 块改为 try-finally-remove 模式,并在 ThreadLocalset() 时加了一层计数器,当同一个线程的 ThreadLocalMap 中 Entry 数量超过阈值(100)时打印告警日志。

实战:InheritableThreadLocal 与线程池的脏数据问题

InheritableThreadLocal 允许子线程继承父线程的 ThreadLocal 值,但仅限创建时传递一次

java
ThreadLocal<String> tl = new InheritableThreadLocal<>();
tl.set("parent-value");

Runnable task = () -> System.out.println(tl.get()); // 输出 "parent-value"
new Thread(task).start(); // 可以继承

但在线程池中,线程是复用的:

java
ExecutorService pool = Executors.newFixedThreadPool(1);
tl.set("request-A");
pool.submit(() -> System.out.println(tl.get())); // 输出 "request-A"

tl.set("request-B");
pool.submit(() -> System.out.println(tl.get())); // 输出 "request-B" 或 "request-A"?不确定!

因为线程池中线程只创建一次,第二次提交时用的还是同一个线程,InheritableThreadLocal 的继承发生在线程创建时,第二次提交时线程已经存在,不会重新继承。所以第二个请求可能读到第一个请求的脏值。

解决方案:阿里 TTL(TransmittableThreadLocal)

xml
<dependency>
    <groupId>com.alibaba</groupId>
    <artifactId>transmittable-thread-local</artifactId>
    <version>2.14.5</version>
</dependency>

TTL 的原理是在任务提交时,TtlRunnable.get() 快照当前线程的 TTL 值,在 run() 之前恢复子线程的值。核心代码:

java
// 简化版源码
public class TransmittableThreadLocal<T> extends InheritableThreadLocal<T> {
    // 任务提交时,注册当前线程的 TTL 到 holder
    private static ThreadLocal<Map<TransmittableThreadLocal<?>, ?>> holder =
        new ThreadLocal<>();

    // TtlRunnable.run() 执行前,从 holder 中恢复值
    static <T> T transceive(TransmittableThreadLocal<T> threadLocal, T value) {
        return threadLocal.get();
    }
}

最佳实践总结

必须做的

  1. 每次用完显式调用 remove(),尤其是拦截器/过滤器 + 线程池的组合
  2. try-finally 模式:finally 块中必须 remove,不要依赖框架自动清理
  3. 线程池环境用阿里 TTL 替代 InheritableThreadLocal

建议不要做的

  1. 不要用 ThreadLocal 存储大对象——value 的强引用链在 GC 时是"半回收"状态,CMS 和 G1 处理这种场景效率很低
  2. 不要在 static 初始化的 ThreadLocal 中放请求级别的数据——static 变量生命周期长,一旦忘记 remove,数据会存活到系统重启

典型错误代码 vs 正确写法

java
// ❌ 错误
ThreadLocal<User> userTL = new ThreadLocal<>();
try {
    userTL.set(fetchUser());
    // 业务逻辑...
} catch (Exception e) {
    log.error("处理异常", e);
}
// 忘记 remove,如果异常抛出了,finally 块都没走

// ✅ 正确
ThreadLocal<User> userTL = new ThreadLocal<>();
try {
    userTL.set(fetchUser());
    // 业务逻辑...
} finally {
    userTL.remove(); // 无论是否异常,都要清理
}

框架清理的可靠性

框架清理时机可靠性
Spring MVC(DispatcherServlet)请求处理完,doDispatch 末尾高,但自定义拦截器+异步线程管不到
Spring Security(SecurityContextHolder)MODE_INHERITABLETHREADLOCAL 模式中等,MODE_THREADLOCAL 模式线程池下会泄漏
Logback MDCMDC.clear() 需要手动调用低,Filter 会自动清理,但自定义线程池不保证
Dubbo(RpcContext)每次 RPC 调用结束高,但服务端异步处理时需手动清理

ThreadLocal 的替代方案:ScopedValue(JDK 21+)

JDK 21 的 ScopedValue(JEP 429)是 ThreadLocal 的官方替代方案:

java
// 定义
private static final ScopedValue<User> CURRENT_USER = ScopedValue.newInstance();

// 绑定(不可变,只在作用域内有效)
ScopedValue.where(CURRENT_USER, adminUser).run(() -> {
    // 在此作用域内,CURRENT_USER.get() 返回 adminUser
    // 子线程自动继承,且不可修改
    // 作用域结束后自动释放,无泄漏风险
});

对比 ThreadLocal:

对比点ThreadLocalScopedValue
可变性可修改不可变(绑定后只读)
线程池兼容需手动 remove自动管理,无泄漏风险
子线程传递InheritableThreadLocal(有脏值)自动传递
性能中等(每次 get/set 有哈希计算)高(直接绑定到虚拟线程栈帧)
最低 JDK 版本JDK 1.2+JDK 21(预览),JDK 22(正式)

生产排查方法

当怀疑 ThreadLocal 导致内存泄漏时:

  1. 堆转储分析jmap -dump:live,format=b,file=dump.hprof <pid>,然后用 MAT 或 JProfiler 分析 java.lang.ThreadLocal$ThreadLocalMap$Entry 实例数
  2. JFR 事件:JDK 11+ 的 JFR 支持 jdk.ThreadLocalMap 事件,可以监控每个线程的 ThreadLocalMap Entry 数量
  3. 代码审计:搜索所有 ThreadLocal.set() 调用,检查是否每个调用都有对应的 remove() 在 finally 块中

参考:ThreadLocal 源码 java.lang.ThreadLocal、阿里 TTL 框架 https://github.com/alibaba/transmittable-thread-local、Scoped Values JEP 429 https://openjdk.org/jeps/429

手撕 → 框架 → 生产化,一步步把 AI Agent 工程化搞透。