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 源码注释
网硕互联帮助中心






评论前必须登录!
注册