Skip to content

ThreadLocal 内存泄漏

ThreadLocal 用着顺手,但有一个隐患——内存泄漏。

很多人知道要用 remove(),但未必清楚为什么。不理解原理,就会在关键时刻踩坑。

Entry 的弱引用设计

ThreadLocalMap 的 Entry 是用弱引用(WeakReference)存储 key 的:

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

为什么要用弱引用?因为如果用强引用,ThreadLocal 对象没被使用时也无法被 GC 回收——只要 Entry 还在,ThreadLocal 就一直存在。

弱引用的设计意味着:当 ThreadLocal 对象没有强引用时,可以被 GC 回收

泄漏的完整链路

问题出在 value 上。来看这个链条:

Thread 对象(强引用)

    ├─► ThreadLocalMap(强引用)
    │       │
    │       └─► Entry[] table(强引用)
    │               │
    │               └─► Entry
    │                       │
    │                       ├─► key: ThreadLocal(弱引用)→ 可以被 GC
    │                       │
    │                       └─► value: Object(强引用)→ 不能被 GC

    └─► ThreadLocal(强引用)→ 手动设为 null

假设你写了这样的代码:

java
ThreadLocal<byte[]> bigData = new ThreadLocal<>();
bigData.set(new byte[10 * 1024 * 1024]);  // 10MB

// 用完了
bigData = null;  // ThreadLocal 对象可以被 GC

ThreadLocal 对象被 GC 了,但 value 呢?

由于 Thread 本身持有 ThreadLocalMap,而 Thread 通常是长期存在的(线程池中的线程永不死亡),所以 Entry[] 也长期存在。value 指向的大对象,就一直无法回收。

Thread 对象 ──► ThreadLocalMap ──► Entry[] ──► Entry ──► value (10MB)
                                                          ↑ 泄漏!
                       key (ThreadLocal) ← GC 掉了(弱引用)

这就是内存泄漏的根源。

三种泄漏场景

场景原因后果
线程池复用线程不死亡,ThreadLocalMap 持续存在value 持续累积
set 后不 remove每次请求留下一个 value内存不断增长
ThreadLocal 置 nullkey 被 GC,value 仍存在value 成为孤岛

泄漏演示

java
public class LeakDemo {

    private static final ThreadLocal<byte[]> bigData =
        ThreadLocal.withInitial(() -> new byte[1024 * 1024 * 10]); // 10MB

    public static void main(String[] args) throws InterruptedException {
        System.out.println("初始内存");
        printMemory();

        for (int i = 0; i < 100; i++) {
            Thread thread = new Thread(() -> {
                bigData.get();  // 创建 ThreadLocal
                // ⚠️ 如果不 remove(),10MB 泄漏
            });
            thread.start();
            thread.join();
        }

        System.gc();
        Thread.sleep(200);
        System.out.println("处理完成(未清理)");
        printMemory();
    }

    private static void printMemory() {
        Runtime rt = Runtime.getRuntime();
        long usedMB = (rt.totalMemory() - rt.freeMemory()) / 1024 / 1024;
        System.out.println("内存使用: " + usedMB + " MB");
    }
}

每次线程结束时(join),Thread 死亡,value 才能被回收。但如果用的是线程池,Thread 不死亡,value 就持续累积。

ThreadLocalMap 的自我保护

ThreadLocalMap 不是什么都没做。它有 expungeStaleEntry() 方法,会在某些操作中清理「key 为 null」的过期 Entry:

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

    // 清理过期 Entry
    expungeStaleEntry 时:
    // 1. 将 value 设为 null,让 GC 回收
    // 2. 重新哈希,填补空位
}

触发时机:

  • get() 时,如果遇到 stale slot,会触发清理
  • set() 时,如果遇到 stale slot,会触发清理
  • remove() 时,会清理当前 Entry 并重新哈希

但这不意味着你可以不调用 remove()——清理是延迟的,不及时的清理仍然会造成泄漏。

正确清理方式

方式一:try-finally(必须掌握)

java
public class TryFinallyDemo {

    private static final ThreadLocal<Connection> conn =
        new ThreadLocal<>();

    public static void main(String[] args) {
        // ✅ 正确
        try {
            conn.set(getConnection());
            // 业务逻辑
        } finally {
            conn.remove();
        }

        // ⚠️ 即使抛异常,finally 也会执行
    }

    private static Connection getConnection() {
        return null; // 模拟
    }
}

方式二:AutoCloseable 封装(优雅)

java
public class ThreadLocalContext<T> implements AutoCloseable {
    private final ThreadLocal<T> local = new ThreadLocal<>();
    private final T value;

    public ThreadLocalContext(T value) {
        this.value = value;
        local.set(value);
    }

    public T get() {
        return local.get();
    }

    @Override
    public void close() {
        local.remove();
    }
}

// 使用
try (ThreadLocalContext<String> ctx = new ThreadLocalContext<>("value")) {
    String value = ctx.get();
    // ...
}  // 自动清理

方式三:拦截器自动清理(Web 应用)

java
public class RequestContextFilter implements Filter {

    private static final ThreadLocal<User> currentUser = new ThreadLocal<>();

    @Override
    public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
            throws IOException, ServletException {
        try {
            // 请求开始:设置上下文
            HttpServletRequest request = (HttpServletRequest) req;
            currentUser.set(resolveUser(request));
            chain.doFilter(req, res);
        } finally {
            // 请求结束:清理
            currentUser.remove();
        }
    }
}

Spring 的 RequestContextFilter(或 Interceptor)就是这样的机制。

线程池 + ThreadLocal

这是最容易泄漏的场景:

java
public class ThreadPoolLeakDemo {

    private static final ThreadLocal<String> taskId = new ThreadLocal<>();

    public static void main(String[] args) throws InterruptedException {
        ExecutorService executor = Executors.newFixedThreadPool(2);

        // ⚠️ 错误示例:线程复用导致值残留
        executor.submit(() -> {
            taskId.set("task-A");
            System.out.println("设置 task-A");
            // 没有 remove,下一个任务会看到 task-A
        });

        Thread.sleep(100);

        // 同一个线程,可能获取到上次的值
        executor.submit(() -> {
            String id = taskId.get();
            System.out.println("获取 taskId: " + id);  // 可能是 task-A,不是 null
        });

        Thread.sleep(100);

        // ✅ 正确示例:在 finally 中清理
        executor.submit(() -> {
            try {
                taskId.set("task-B");
                System.out.println("设置 task-B");
            } finally {
                taskId.remove();
            }
        });

        Thread.sleep(100);

        executor.submit(() -> {
            String id = taskId.get();
            System.out.println("获取 taskId: " + id);  // null,干净的值
        });

        executor.shutdown();
    }
}

内存泄漏检查清单

检查项说明
ThreadLocal 在哪里 set找到所有 set 点
是否在 finally 中 remove每一个 set 都必须有 remove
线程池是否复用线程是 → 必须每次任务前后清理
是否有 Filter/Interceptor 清理Web 应用应有统一清理机制
value 占用内存多大越大越容易 OOM

要点回顾

  • Entry 的 key 用弱引用,value 用强引用
  • 当 ThreadLocal 置 null,key 被 GC,value 可能泄漏
  • ThreadLocalMap 有 expungeStaleEntry() 清理机制,但延迟且不完整
  • 每次 set 后,必须在 finally 中 remove()
  • 线程池场景尤其危险,线程复用且永不死亡

用一句话记住:ThreadLocal 是租房子,set() 是入住,remove() 是退房。赖着不走,后面的人就麻烦了。

相关链接

基于 VitePress 构建