ThreadLocal 用完为什么要 remove?除了避免下一次任务读到旧值,还涉及线程长期存活时的引用保留。

弱引用只用在 key 上

JDK 8 的 ThreadLocalMap.Entry 以弱引用保存 ThreadLocal 这个 key,但 value 是普通的强引用。

当外部不再引用某个 ThreadLocal,它可以被垃圾回收,Entry 的 key 随后变成 null。然而只要线程、Map 和 Entry 仍然可达,value 就可能继续被引用。

存活的 Thread -> ThreadLocalMap -> Entry -> value
                                  └─ 弱引用 key

所以“key 是弱引用”不等于“value 自动同时被回收”。反过来,如果 ThreadLocal 是一个仍然可达的静态字段,key 根本没有消失,但 value 依然可能因为忘记 remove 而长期保留。

在线程池里看旧值

先看线程复用时会发生什么:前一个任务设置的值,后一个任务还能读到。

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class ThreadLocalLeakDemo {
    private static final ThreadLocal<String> LOCAL = new ThreadLocal<>();

    public static void main(String[] args) throws Exception {
        ExecutorService pool = Executors.newSingleThreadExecutor();
        try {
            pool.submit(() -> LOCAL.set("task-a")).get();
            pool.submit(() -> {
                try {
                    System.out.println(LOCAL.get());
                } finally {
                    LOCAL.remove();
                }
            }).get();
            pool.submit(() -> System.out.println(LOCAL.get())).get();
        } finally {
            pool.shutdown();
        }
    }
}

输出 task-a 和 null。单线程池复用了工作线程,第一次任务结束并没有清掉 ThreadLocal,第二个任务仍然读到了它。

这个例子为了展示问题,故意在第一个任务里漏掉 remove。实际使用时,应当在第一个任务自己的 finally 中调用 remove。

Map 会不会自己清理

源码里有 expungeStaleEntry、cleanSomeSlots 等清理逻辑,get/set/remove 的某些路径会扫描并清理 key 已失效的条目。

但它不是一个持续运行的后台回收器。线程如果长期空闲,或者后续访问没有触发相关清理,失效条目的 value 仍可能保留一段很长时间。不能把“有清理逻辑”理解成“业务不用 remove”。

正确清理的位置

清理必须发生在保存值的那个线程上。主线程调用 remove,清的是主线程自己的映射,不是线程池工作线程的映射。

任务的入口负责 set,出口的 finally 负责 remove,边界最清楚。如果存在嵌套上下文,还要考虑内层结束后恢复外层的旧值,不能直接把外层的上下文一起清掉。

内存泄漏的关键是本来不再需要的对象仍被持有。排查时沿着 GC Root 到 value 的引用链看,比仅凭“用了 ThreadLocal”就判断有泄漏更准确。

最后修改:2018 年 03 月 29 日
如果觉得我的文章对你有用,请随意赞赏