跳到主内容

踩坑实录:ThreadLocal 在线程池里的泄漏与清理钩子 ​

问题背景:不是 ThreadLocal 没用,而是线程还活着 ​

一次接口服务上线后,堆内存使用量随流量缓慢爬升,Full GC 后 Old 区也只能回收一小部分。 dump 分析发现大量业务上下文对象仍然被 ThreadLocalMap$Entry 引用,而这些 Entry 所在线程全部来自 Tomcat 和业务异步任务的线程池。

很多同学记住了“ThreadLocal 的 key 是弱引用,所以不会泄漏”,但线上事故恰恰说明:弱引用只保护 key,不自动清理 value;只要线程长期存活,value 就可能长期可达。在 Web 容器、异步任务、虚拟线程调度器等场景中,线程会被反复复用,前一个请求遗留的用户信息、租户 ID、链路上下文不仅可能造成内存泄漏,还会造成数据串用。

下面用一个最小可运行示例复现问题,并给出工程上可靠的清理模式。

ThreadLocalMap、弱引用与线程池复用的关系 ​

ThreadLocal 本身并不存储值。每个 Thread 对象内部持有一个 ThreadLocalMap,Map 的 Entry 结构可以简化理解为:

java
static class Entry extends WeakReference<ThreadLocal<?>> {
    Object value; // 强引用:真正的业务对象
    Entry(ThreadLocal<?> k, Object v) {
        super(k);
        value = v;
    }
}

关系链路如下:

线程池线程长期存活ThreadLocalMap每个线程一份Entry.key弱引用 ThreadLocalEntry.value强引用业务对象大对象/上下文无法回收

当局部变量中的 ThreadLocal 实例没有其他强引用时,GC 可以回收 key,Entry 会变成 key=null 的陈旧槽位。但 value 仍被 Entry 强引用,Entry 又被 ThreadLocalMap 引用,Map 被线程引用。固定大小线程池里的线程通常不会销毁,因此这些 value 会继续存活。只有后续在同一个线程上执行 get、set、remove 并触发哈希槽清理,或者线程本身退出,问题才可能缓解。

方案对比:只靠弱引用、try-finally 与统一包装 ​

常见做法有三类:

方案优点局限适用场景
依赖弱引用自动回收无需业务代码不能保证及时清理 value,依赖后续探测清理仅适合短生命周期线程
每个任务手写 try-finally remove直观、成本低容易漏写,多个 ThreadLocal 时清理顺序混乱少量任务、临时脚本
线程池统一包装任务强制清理,可审计可监控需要统一线程池创建入口Web、异步任务、批处理等长期服务

工程上推荐把 ThreadLocal 当作借用人的资源:谁在任务开始时设置,谁必须在任务结束时清理;框架层再提供兜底,避免依赖人的记忆。

可运行复现:固定线程池如何保留大对象 ​

下面代码不需要额外依赖,使用 JDK 17 可直接编译运行。为了避免 JIT 将对象优化掉,示例通过弱引用观察对象是否可回收。

java
import java.lang.ref.WeakReference;
import java.util.concurrent.*;

public class ThreadLocalLeakDemo {
    // 模拟较大的请求上下文,例如用户信息、权限集合、租户配置
    record BigContext(String tenantId, byte[] payload) {}

    static final ThreadLocal<BigContext> CONTEXT = new ThreadLocal<>();

    public static void main(String[] args) throws Exception {
        ExecutorService pool = Executors.newFixedThreadPool(1);

        WeakReference<BigContext> ref = submitWithoutCleanup(pool);
        System.gc();
        Thread.sleep(200);

        // 如果 value 已释放,ref.get() 应为 null;线程池复用时通常仍然非 null
        System.out.println("未 remove,上下文是否还存在:" + (ref.get() != null));

        // 再提交一个空任务,让同一个线程继续工作;此时还可能读到旧值或残留状态
        pool.submit(() -> System.out.println("下一个任务读到旧上下文:" + CONTEXT.get())).get();
        pool.shutdown();
    }

    private static WeakReference<BigContext> submitWithoutCleanup(ExecutorService pool)
            throws Exception {
        BigContext context = new BigContext("tenant-a", new byte[8 * 1024 * 1024]);
        WeakReference<BigContext> ref = new WeakReference<>(context);

        pool.submit(() -> {
            // 典型错误:只 set,不在 finally 中 remove
            CONTEXT.set(context);
            handleRequest();
        }).get();

        return ref;
    }

    private static void handleRequest() {
        System.out.println("处理请求:" + CONTEXT.get().tenantId());
    }
}

运行结果通常会显示上下文仍然存在,下一个任务还能读到上一个任务设置的值。注意,不同 JVM 的 GC 行为可能略有差异,但在生产上不能把回收时机建立在“多执行几次可能触发清理”这种不确定行为上。

正确姿势一:任务内部使用 try-finally 调用 remove ​

最小修复是把 set 和 remove 放在同一个作用域内:

java
private static void handleWithContext(ExecutorService pool, BigContext context) {
    pool.submit(() -> {
        BigContext previous = CONTEXT.get();
        CONTEXT.set(context);
        try {
            handleRequest();
        } finally {
            // 无论成功还是异常,都必须解除当前线程对 value 的强引用
            CONTEXT.remove();
        }
    });
}

如果上下文可能由外层拦截器设置、内层任务只是临时覆盖,还应保存旧值并恢复:

java
static void replaceContext(BigContext next) {
    BigContext old = CONTEXT.get();
    CONTEXT.set(next);
    try {
        handleRequest();
    } finally {
        if (old == null) {
            CONTEXT.remove();
        } else {
            CONTEXT.set(old);
        }
    }
}

remove() 比简单 set(null) 更合适:前者会清理当前 Entry 并执行槽位清理;后者仍保留一个值为 null 的 Entry,在线程池场景下不够彻底。

正确姿势二:线程池统一安装清理钩子 ​

业务代码一多,手写 try-finally 难免遗漏。更稳妥的方式是在线程池边界统一包装 Runnable 和 Callable,并对任务执行前后的上下文状态做监控。

java
import java.util.*;
import java.util.concurrent.*;

public final class ContextAwareExecutors {

    private static final Set<ThreadLocal<?>> MANAGED =
            Collections.newSetFromMap(new ConcurrentHashMap<>());

    // 所有需要被线程池兜底清理的 ThreadLocal 都在这里登记
    public static <T> ThreadLocal<T> register(ThreadLocal<T> local) {
        MANAGED.add(local);
        return local;
    }

    public static ThreadPoolExecutor fixedPool(int size, String namePrefix) {
        return new ThreadPoolExecutor(
                size, size,
                0L, TimeUnit.MILLISECONDS,
                new LinkedBlockingQueue<>(),
                new ThreadFactory() {
                    private final AtomicInteger id = new AtomicInteger();
                    @Override
                    public Thread newThread(Runnable r) {
                        Thread t = new Thread(r, namePrefix + "-" + id.incrementAndGet());
                        t.setDaemon(true);
                        return t;
                    }
                }
        ) {
            @Override
            public void execute(Runnable command) {
                super.execute(wrap(command));
            }

            @Override
            public <T> Future<T> submit(Callable<T> task) {
                return super.submit(wrap(task));
            }

            @Override
            public Future<?> submit(Runnable task) {
                return super.submit(wrap(task));
            }
        };
    }

    private static Runnable wrap(Runnable task) {
        return () -> {
            try {
                task.run();
            } finally {
                cleanupAfterTask();
            }
        };
    }

    private static <T> Callable<T> wrap(Callable<T> task) {
        return () -> {
            try {
                return task.call();
            } finally {
                cleanupAfterTask();
            }
        };
    }

    private static void cleanupAfterTask() {
        for (ThreadLocal<?> local : MANAGED) {
            // 兜底清理,防止业务漏写 finally;发现残留时应打点或记录告警
            if (local.get() != null) {
                System.err.println("检测到 ThreadLocal 残留,线程=" + Thread.currentThread().getName());
                local.remove();
            }
        }
    }

    private static final class AtomicInteger {
        private int value;
        synchronized int incrementAndGet() { return ++value; }
    }
}

使用方式:

java
public class CleanDemo {
    private static final ThreadLocal<String> TENANT =
            ContextAwareExecutors.register(new ThreadLocal<>());

    public static void main(String[] args) throws Exception {
        ThreadPoolExecutor pool = ContextAwareExecutors.fixedPool(2, "biz-worker");

        for (int i = 0; i < 10; i++) {
            String tenant = "tenant-" + i;
            pool.submit(() -> {
                TENANT.set(tenant); // 即使忘记 remove,包装器也会在任务结束时清理
                System.out.println(Thread.currentThread().getName() + " 处理 " + TENANT.get());
            });
        }

        pool.shutdown();
        pool.awaitTermination(5, TimeUnit.SECONDS);
    }
}

上面的 AtomicInteger 只是为了示例自包含,实际项目应使用 java.util.concurrent.atomic.AtomicInteger。如果使用 Spring,可以把类似逻辑放到 TaskDecorator;如果使用 Tomcat 或 Jetty,则应在 Filter 的 finally 中清理请求上下文,因为请求线程同样会被复用。

如何观测与验证清理是否生效 ​

第一,在压测前后分别执行堆直方图,观察业务上下文类实例数量是否随任务结束下降;第二,给关键 ThreadLocal 增加残留计数,任务结束时若值仍非空,输出线程名、任务名和 traceId;第三,编写多任务复用同一个线程的单元测试,第二个任务开始时断言上下文必须为空。

需要特别强调虚拟线程:虚拟线程数量可以很多,不能因为它“轻量”就随意保留 ThreadLocal。大量短暂虚拟线程可能降低单线程长期复用造成的串号概率,但深度递归、调度器载体线程、池化通道中的上下文仍然可能带来额外内存占用。迁移到虚拟线程时,更推荐使用作用域明确、不可变传递的上下文对象;必须使用线程局部语义时,也要保留边界清理和监控。

避坑总结 ​

  1. 错误现象:认为 key 是弱引用就不会泄漏。 修正方法:明确 Entry 对 value 是强引用,线程池线程不退出时 value 仍可达;所有跨请求复用线程都必须显式 remove()。
  2. 错误现象:只在正常返回路径清理,任务抛异常后下个任务读到旧租户或旧用户。 修正方法:把清理写入 finally,框架包装器也必须在 finally 中执行,不能只包一层成功分支。
  3. 错误现象:用 set(null) 代替 remove(),以为值清空就万事大吉。 修正方法:优先调用 remove(),它会删除 Entry 并触发陈旧槽清理;嵌套场景还要保存并恢复 previous 值。
  4. 错误现象:业务团队各自 new ThreadLocal,线程池无法统一兜底。 修正方法:建立受控的上下文注册入口和统一线程池工厂,对残留值打点告警,并在代码评审中禁止请求级 ThreadLocal 跨任务裸奔。
  5. 错误现象:把大对象集合、权限缓存、分页结果放入 ThreadLocal 后异步传递。 修正方法:ThreadLocal 只放小而必要的标识信息,大对象通过方法参数或显式上下文对象传递,避免隐式生命周期和内存放大。

ThreadLocal 泄漏的本质不是引用类型“玄学”,而是资源生命周期没有闭合。在线程池复用模型下,把设置、传递、恢复、移除做成强制边界,并用测试和监控验证,才能同时避免内存泄漏和请求数据串用。

免责声明

本工具与文章内容仅用于学习测试场景,优化建议仅供参考,请勿直接用于生产环境,由此产生的风险自行承担。