ThreadLocal 原理与内存泄漏

举报
yd_287908631 发表于 2026/10/28 16:35:44 2026/10/28
【摘要】 从 ThreadLocalMap 的弱引用键讲清泄漏成因与线程池下的正确用法。

ThreadLocal 的 API 只有 get、set、remove 三个方法,看起来简单,但它是线上内存泄漏和"脏数据串号"的高发区。问题的根源在于数据实际存在 Thread 对象里,而不是 ThreadLocal 里,并且 key 是弱引用。本文从 ThreadLocalMap 的结构讲起,把泄漏的每一个环节还原出来,并给出线程池场景下的正确写法。

一、数据到底存在哪里

ThreadLocal 本身不存数据,它只是一个"钥匙"。真正的存储结构挂在 Thread 上:

public class Thread implements Runnable {
    ThreadLocal.ThreadLocalMap threadLocals = null;
    ThreadLocal.ThreadLocalMap inheritableThreadLocals = null;
}

ThreadLocalMap 是 ThreadLocal 的静态内部类,用数组实现,结构与 HashMap 类似但没有链表:

static class ThreadLocalMap {
    static class Entry extends WeakReference<ThreadLocal<?>> {
        Object value;                       // 强引用 value
        Entry(ThreadLocal<?> k, Object v) {
            super(k);                       // key 是弱引用
            value = v;
        }
    }
    private Entry[] table;
    private int size;
    private int threshold;
}

关系图如下:

Thread ──► threadLocals (ThreadLocalMap)
                     │
                     ▼
             Entry[] table
             ┌──────────────────────────────┐
             │ Entry                        │
             │  key   ──弱引用──► ThreadLocal │  ◄── 栈上的 tl 变量强引用它
             │  value ──强引用──► 实际对象    │
             └──────────────────────────────┘

ThreadLocal 的 set 就是拿到当前线程的 map 再写入:

public void set(T value) {
    Thread t = Thread.currentThread();
    ThreadLocalMap map = getMap(t);
    if (map != null)
        map.set(this, value);
    else
        createMap(t, value);
}

public T get() {
    Thread t = Thread.currentThread();
    ThreadLocalMap map = getMap(t);
    if (map != null) {
        ThreadLocalMap.Entry e = map.getEntry(this);
        if (e != null) {
            @SuppressWarnings("unchecked")
            T result = (T) e.value;
            return result;
        }
    }
    return setInitialValue();
}

这也解释了为什么 ThreadLocal 天然线程隔离:每个线程有自己的 map,key 是同一个 ThreadLocal 对象,但 value 各存各的。

二、哈希与冲突处理

ThreadLocalMap 用 threadLocalHashCode 作为哈希值,每次创建 ThreadLocal 时递增一个魔数 0x61c88647:

private static final int HASH_INCREMENT = 0x61c88647;

private static int nextHashCode() {
    return nextHashCode.getAndAdd(HASH_INCREMENT);
}

这个魔数是黄金分割数 (√5 - 1) / 2 乘以 2 的 32 次方,能让哈希值在 2 的幂长度的数组中均匀分布,减少冲突。斐波那契散列在 ThreadLocalMap 这种开放寻址结构里效果很好。

冲突处理采用开放寻址法中的线性探测:位置被占用就往后找下一个空位。查找时如果遇到 key 不匹配,也继续往后探测,直到找到或遇到空槽。

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 = nextIndex(i, len)]) {
        ThreadLocal<?> k = e.get();
        if (k == key) {
            e.value = value;
            return;
        }
        if (k == null) {
            replaceStaleEntry(key, value, i);   // 遇到 key 被回收的槽,顺手清理
            return;
        }
    }
    tab[i] = new Entry(key, value);
    int sz = ++size;
    if (!cleanSomeSlots(i, sz) && sz >= threshold)
        rehash();
}

注意 replaceStaleEntry 和 cleanSomeSlots:它们在写入路径上顺带清理 key 为 null 的"陈旧条目",这是 ThreadLocalMap 自我修复的一部分。

开放寻址带来一个副作用:数组不能装得太满,否则探测链变长、性能下降。ThreadLocalMap 的阈值是 len * 2 / 3,比 HashMap 的 0.75 更保守,且扩容时容量同样翻倍。rehash() 会先全量清理陈旧条目,如果清理后仍超过阈值的 3/4 才真正扩容:

private void rehash() {
    expungeStaleEntries();          // 先全量清理
    if (size >= threshold - threshold / 4)
        resize();
}

这个"先清理再扩容"的顺序很关键:很多所谓的泄漏,其实是 map 里堆了大量 key 已死的条目,清理一次就能回收,没必要扩容。

三、为什么 key 是弱引用

如果 key 用强引用,ThreadLocal 对象即使外部不再使用,也会被 ThreadLocalMap 的 Entry 一直引用,永远无法回收。改成弱引用后,当外部强引用消失,GC 就能回收 ThreadLocal,Entry 的 key 变成 null。

但弱引用只解决了 key 的回收,value 仍然是强引用。这就产生了经典泄漏链:

Thread(线程池中长期存活)
  └─► ThreadLocalMap
        └─► Entry
              ├─ key: null(ThreadLocal 已被 GC 回收)
              └─ value: 强引用一个 10 MB 的缓存对象  ← 永远无法回收

ThreadLocalMap 只有在后续 get/set/remove 时才会顺带清理 key 为 null 的槽位。如果线程长时间不再访问这个 map,这些 value 就一直挂着。

在线程池里,线程是复用的,几乎不会结束,于是泄漏会持续累积。这就是"ThreadLocal 内存泄漏"的完整成因。

四、脏数据:比泄漏更隐蔽的问题

线程池复用线程时,上一个任务留下的 ThreadLocal 值会被下一个任务读到:

ExecutorService pool = Executors.newFixedThreadPool(2);

pool.execute(() -> {
    UserContext.set(new User("alice"));
    // 忘记清理
});

pool.execute(() -> {
    // 可能跑在同一个线程上,读到 alice
    System.out.println(UserContext.get());   // 脏数据
});

这类问题在请求上下文中尤其危险:用户 A 的请求处理完没有 remove,用户 B 的请求复用了同一线程,读到了 A 的身份信息,造成越权。

五、正确用法

public final class UserContext {
    private static final ThreadLocal<User> HOLDER = new ThreadLocal<>();

    private UserContext() {}

    public static void set(User user) {
        HOLDER.set(user);
    }

    public static User get() {
        return HOLDER.get();
    }

    public static void clear() {
        HOLDER.remove();   // 必须用 remove,不是 set(null)
    }
}

使用模板:

try {
    UserContext.set(user);
    handleRequest();
} finally {
    UserContext.clear();   // 无论如何都清理
}

remove() 的实现会真正删除槽位并做一次清理:

public void remove() {
    ThreadLocalMap m = getMap(Thread.currentThread());
    if (m != null)
        m.remove(this);
}

private void remove(ThreadLocal<?> key) {
    Entry[] tab = table;
    int len = tab.length;
    int i = key.threadLocalHashCode & (len - 1);
    for (Entry e = tab[i]; e != null; e = tab[i = nextIndex(i, len)]) {
        if (e.get() == key) {
            e.clear();          // 清空弱引用
            expungeStaleEntry(i); // 清理 value 并整理数组
            return;
        }
    }
}

set(null) 不能替代 remove():它只是把 value 置为 null,Entry 仍然存在,key 仍然被弱引用,槽位没有释放。

remove 与 set(null) 在 ThreadLocalMap 层面的差异可以这样验证:

public class ThreadLocalProbe {
    private static final ThreadLocal<byte[]> TL = new ThreadLocal<>();

    public static void main(String[] args) throws Exception {
        TL.set(new byte[8 * 1024 * 1024]);   // 8 MB
        TL.set(null);                         // Entry 仍在,value 变 null
        System.gc();
        Thread.sleep(500);
        System.out.println("after set(null), entry still in map");

        TL.set(new byte[8 * 1024 * 1024]);
        TL.remove();                          // 槽位被清理
        System.gc();
        Thread.sleep(500);
        System.out.println("after remove, entry gone");
    }
}

两次调用后 value 都能被回收,因为 set(null) 已经把强引用断开;区别在于 Entry 对象本身是否残留。在长期存活的线程里,大量残留的 Entry 会撑大 table,并让探测链变长,最终表现为 map 不断扩容、查询变慢。所以"释放引用"和"清理结构"是两件事,remove 两件都做。

六、InheritableThreadLocal 与线程池的冲突

InheritableThreadLocal 会在创建子线程时把父线程的值复制过去:

private static final InheritableThreadLocal<String> TRACE =
        new InheritableThreadLocal<>();

TRACE.set("trace-001");
new Thread(() -> System.out.println(TRACE.get())).start();  // trace-001

但复制只发生在 Thread 构造时(Thread.init 中的 inheritThreadLocals 判断)。线程池的线程是提前创建并复用的,提交任务时不会重新复制,因此 InheritableThreadLocal 在线程池里基本失效。

需要跨线程池传递上下文时,用阿里开源的 TransmittableThreadLocal(TTL),它在任务提交时捕获快照、执行前恢复、执行后还原:

TransmittableThreadLocal<String> ctx = new TransmittableThreadLocal<>();
ExecutorService pool = TtlExecutors.getTtlExecutorService(
        Executors.newFixedThreadPool(4));

Spring 环境下也可以自定义 TaskDecorator 手动复制 MDC 等上下文。

七、并发场景分析

| 场景 | 风险 |

| --- | --- |

| Web 请求上下文 | 忘记 remove,线程复用时串号、越权 |

| 线程池中的大对象缓存 | value 无法回收,堆内存持续增长 |

| 异步任务(CompletableFuture) | 上下文不会自动传递,读到 null |

| 子线程创建 | InheritableThreadLocal 只复制一次 |

| 静态 ThreadLocal 持有连接 | 连接未关闭,泄漏连接和内存 |

ThreadLocal 适合存放"与线程绑定、生命周期短、不需要跨线程"的数据,例如日期格式化器(SimpleDateFormat 非线程安全)、请求上下文、事务连接。不适合缓存大对象,也不适合需要在线程池间传递的数据。

JDK 8 之后 SimpleDateFormat 的替代方案是 DateTimeFormatter,它本身不可变且线程安全,可以直接作为静态常量共享,不必再借助 ThreadLocal。这也是判断"是否真的需要 ThreadLocal"的一个标准:如果目标对象是不可变的,就没必要每个线程存一份。

八、常见坑 / 最佳实践

  1. finally 中必须 remove()。这是唯一能彻底避免泄漏和脏数据的方式,set(null) 无效。
  2. 用 static final 修饰 ThreadLocal。避免每次创建新实例导致 map 中堆积大量 Entry。
  3. 不要用 ThreadLocal 缓存大对象。线程池线程长期存活,value 会一直占着堆内存。
  4. 异步任务显式传递上下文。CompletableFuture、@Async 都不会自动继承 ThreadLocal,用 TTL 或 TaskDecorator。
  5. InheritableThreadLocal 不能解决线程池传递问题,它只在 Thread 构造时复制一次。
  6. 框架层统一清理。拦截器或过滤器在 finally 中调用 remove,比依赖每个业务方法自己清理可靠。
  7. 排查泄漏时看堆转储。用 jmap -dump 后分析 ThreadLocalMap 的 Entry,key 为 null 但 value 很大的条目就是泄漏点。

九、小结

  • 数据存在 Thread 的 threadLocals 字段中,ThreadLocal 只是访问用的 key。
  • ThreadLocalMap 用开放寻址法解决冲突,哈希增量 0x61c88647 是斐波那契散列魔数。
  • key 是弱引用,value 是强引用,key 被回收后 value 无法释放,形成泄漏。
  • 线程池复用线程会让泄漏持续累积,也会让上一个任务的值被下一个任务读到。
  • remove() 是唯一正确的清理方式,set(null) 不能释放槽位。
  • 跨线程池传递上下文需要 TransmittableThreadLocal 或自定义 TaskDecorator。
【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。