云计算百科
云计算领域专业知识百科平台

ThreadLocal 内存泄漏

ThreadLocal 不清理,为什么会造成内存泄漏

服务跑了一段时间后,某个用户突然看到了另一个用户的数据。再过几天,堆内存开始缓慢上涨,GC 却一直压不下去。代码里到处用 ThreadLocal 保存登录信息,用完也“觉得没关系”。问题就藏在这些没有被 remove() 的局部变量里。


1. ThreadLocal 本来解决什么问题

同一个 ThreadLocal 对象,在不同的线程里能读到不同的值。比如一个请求进入 Tomcat 后,框架把登录用户放进 ThreadLocal,后续的 Service、Mapper 都能随手取到,而不必把用户对象从 Controller 一路传到底层。

实现上,每个 Thread 内部都维护一张表,叫 ThreadLocalMap。ThreadLocal 对象只是这张表里的一个键,真正的值存在线程自己身上。所以线程之间天然隔离,互相看不到对方的值。

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

public static void set(User user) { CURRENT.set(user); }
public static User get() { return CURRENT.get(); }
}

这段代码本身没有问题。问题出在“用完以后没有清掉”。

2. 数据到底被谁抓着

要理解泄漏,得先看 ThreadLocalMap 内部的结构。它不是一个简单的 Map,而是一个 Entry[] 数组。每个 Entry 继承自 WeakReference,它有两个引用方向:

Thread
└─ threadLocals → ThreadLocalMap
└─ table: Entry[]
└─ Entry
├─ key → WeakReference → ThreadLocal
└─ value → 强引用 → User 对象

关键就在这两条线的区别。key 对 ThreadLocal 是弱引用,意思是:一旦外部没有强引用指向这个 ThreadLocal 对象,下次 GC 就会把 key 回收,Entry 的 key 变成 null。但 value 对 User 是强引用,它不会因为 key 没了而自动消失。

在这里插入图片描述

图 1:key 是弱引用会被回收,value 是强引用仍然被 Entry 抓着,线程不死它就一直占着内存。

于是会出现一个状态:ThreadLocal 对象本身已经被回收了,但那个 User 还躺在某个线程的 ThreadLocalMap 里,没有任何人能通过 key 找到它,也没有代码再去读它。它成了一段“死了但没被回收”的数据。

3. 为什么线程池把它放大成了泄漏

如果每个线程用完就结束,线程对象被回收,它身上的 ThreadLocalMap 和里面的 value 也会跟着被回收。短生命周期的线程几乎不会造成可见的泄漏。

但生产环境几乎都在用线程池。Tomcat 的工作线程、业务自定义的线程池,线程处理完一个任务后不会销毁,而是回到池子里等待下一个任务。线程活着,ThreadLocalMap 就活着,里面那些 key 已经为 null 的 value 就跟着一直活着。

任务越多,这种“孤儿 value”就越多。如果 value 里又间接引用了大对象——比如整个请求上下文、字节数组、某个缓存结构——内存就会一点点涨上去。这解释了为什么内存曲线是缓慢上升,而不是某次请求后突然爆掉。

ThreadLocal 的 get、set、remove 操作在遍历时,会顺便清理一些 key 为 null 的槽位。但这条清理机制只发生在你访问这个线程的那张表时,而且只能清理碰到的位置。如果线程之后不再访问这个 ThreadLocal,这些槽位可能永远等不到清理。

4. 比内存更早暴露的是串数据

泄漏是慢性的,串数据却是立刻能看见的。线程池复用了线程,而上一个任务留下的 ThreadLocal 值没有被清掉,下一个任务如果恰好用了同一个线程,get() 拿到的就是上一个请求的残留。

try {
UserContext.set(currentUser);
handleRequest();
} finally {
UserContext.remove();
}

没有 finally 里的 remove(),当用户 A 的请求和用户 B 的请求先后落到同一个线程,B 就可能在某个分支里读到 A 的信息。这类问题在低并发下很难复现,一旦并发上来、线程被频繁复用,就会出现偶发的“越权”现象。

所以 ThreadLocal 的正确用法不是“设进去就行”,而是“谁设置,谁清理”。清理必须放在 finally 里,保证异常路径也能执行:

User user = null;
try {
user = buildUser();
UserContext.set(user);
doBusiness();
} finally {
UserContext.remove();
}

5. static final 能帮上忙,但不是免死金牌

很多人说 ThreadLocal 应该声明成 static final。这个建议是对的,但原因要分清楚。

如果 ThreadLocal 不是静态的,而是每个对象实例各有一个,那么每个实例都可能在堆里占据一个键。更重要的是,当实例被回收后,这个 ThreadLocal 就成了没人强引用的 key,它的 value 反而更容易变成孤儿。声明成 static final 后,类加载器会一直持有这个 key,key 不会被提前回收,你也始终有入口能调 remove()。

但 static final 只能保证 key 活着,不能保证 value 被清理。只要线程活着、value 还是强引用,内存就仍然占着。static final 解决的是“键不能丢”,remove() 解决的才是“值必须清”。

如果框架已经帮你管理了上下文——比如 Spring MVC 的 RequestContextHolder、日志的 MDC——就要用框架提供的清理机制,而不是自己再包一层 ThreadLocal 然后忘了清。重复管理同一份上下文,只会让清理责任变得模糊。

6. 什么时候可以不清

并不是所有 ThreadLocal 都必须手动清理。

如果 value 的生命周期和线程一样长,而且内容很小、不会再变化,比如每个线程自己的 SimpleDateFormat 或随机数种子,那么让它们跟着线程存续也问题不大。真正需要警惕的是“每次请求都写入、value 可能很大、且任务会不断切换”的场景。

判断标准很简单:这个值是不是和单个请求绑定,而线程会去处理很多个请求? 如果是,就必须清理;如果它和线程本身绑定,和请求无关,就可以留着。


总结

ThreadLocal 的内存泄漏,根子在 ThreadLocalMap 的 Entry:key 是弱引用,value 是强引用。key 没了之后,value 仍然被线程内部的数组抓着,而线程池里的线程又长期存活,垃圾收集器够不到它。表现形式可能是内存缓慢上涨,也可能是串数据。修复办法只有一条核心:谁设置了值,谁就在 finally 里 remove()。static final 只是让 key 稳定,真正清掉 value 的还是 remove()。

参考资料

  • Java ThreadLocal API 文档
  • JDK ThreadLocalMap 源码注释
赞(0)
未经允许不得转载:网硕互联帮助中心 » ThreadLocal 内存泄漏
分享到: 更多 (0)

评论 抢沙发

评论前必须登录!