前言
ThreadLocal 是个好东西:给每个线程一份独立的变量副本,天然线程隔离,常用来存用户上下文、事务、SimpleDateFormat 这类"每线程一份"的东西。
但它有个著名的坑:用不好会内存泄漏。 更让人困惑的是,很多人知道"ThreadLocalMap 的 Entry 用了弱引用来防泄漏",于是产生一个疑问——既然都用弱引用了,为什么还会泄漏?
这正是这篇文章要讲清楚的。弱引用确实解决了一半问题,但另一半(value)它管不到,而线程池又把这个隐患放大成了实实在在的线上事故。
环境说明:本文基于 JDK 8。涉及的引用类型、GC 概念,属于 Java 内存管理基础。
一、先复现:一个会让内存慢慢涨的接口
设想一个 Web 接口,用 ThreadLocal 缓存一个比较大的上下文对象:
public class UserContextHolder {
private static final ThreadLocal<UserContext> CONTEXT = new ThreadLocal<>();
public static void set(UserContext ctx) { CONTEXT.set(ctx); }
public static UserContext get() { return CONTEXT.get(); }
// 注意:这里没有提供 remove(),也没人调用
}
// 拦截器里,每个请求进来就 set 一份
public class ContextInterceptor implements HandlerInterceptor {
public boolean preHandle(HttpServletRequest req, ...) {
UserContext ctx = buildContext(req); // 假设这个对象不小
UserContextHolder.set(ctx);
return true;
}
// 请求结束后,没有 remove
}
这段代码功能上完全正常,测试也没问题。但把它放到生产环境,用 Tomcat 默认的线程池跑一段时间后,你会观察到:堆内存缓慢但持续地增长,老年代越堆越满,最终频繁 Full GC 甚至 OOM。
问题在于:ThreadLocal 用完了没有 remove(),而线程池里的线程一直活着不销毁,那些 UserContext 对象就一直被"挂"在线程上,GC 回收不掉。
要理解为什么,得先看 ThreadLocal 的存储结构。
二、根因/底层:弱引用只保护了 key,value 没人管
2.1 数据到底存在哪:不是存在 ThreadLocal 里
第一个反直觉的点:ThreadLocal.set(value) 的值,并不存在 ThreadLocal 对象里,而是存在"当前线程"身上。
每个 Thread 对象内部有一个字段 threadLocals,类型是 ThreadLocal.ThreadLocalMap。你调用 threadLocal.set(value) 时,实际是以这个 ThreadLocal 实例为 key、你的 value 为 value,存进了当前线程的那个 ThreadLocalMap。
Thread(线程对象)
└─ threadLocals: ThreadLocalMap
└─ Entry[] (每个 Entry 是一个 key-value 对)
key = ThreadLocal 实例(弱引用)
value = 你 set 进去的值(强引用)
这个设计的好处是天然隔离:不同线程有各自的 ThreadLocalMap,互不干扰。

2.2 关键:Entry 的 key 是弱引用,value 是强引用
ThreadLocalMap 里的 Entry 定义是这样的(简化):
static class Entry extends WeakReference<ThreadLocal<?>> {
Object value;
Entry(ThreadLocal<?> k, Object v) {
super(k); // key(ThreadLocal)作为弱引用
value = v; // value 是强引用,普通字段
}
}
注意这个不对称的设计:
- key(
ThreadLocal实例)是弱引用:Entry extends WeakReference<ThreadLocal>,key 被弱引用持有。 - value(你 set 的值)是强引用:
value就是个普通字段,被Entry强引用着。
为什么 key 要用弱引用?就是为了防泄漏:当外部不再引用这个 ThreadLocal 时(比如 ThreadLocal 变量被置空或超出作用域),弱引用不阻止 GC,key 就能被回收掉,Entry 的 key 变成 null。
设计者的本意是好的,但问题恰恰出在这个"一半弱、一半强"上。
2.3 泄漏是怎么发生的:key 没了,value 还在
设想这样一条引用链,当外部对 ThreadLocal 的强引用消失后:
- key 是弱引用 → GC 时被回收,
Entry的 key 变成null; - 但 value 是强引用,它的引用链是:
Thread→ThreadLocalMap→Entry→value。
只要线程还活着,这条强引用链就一直在,value 就永远回收不掉。结果就是 ThreadLocalMap 里出现一堆 key 为 null、value 还占着内存的"僵尸 Entry"——这就是内存泄漏。

2.4 为什么线程池让问题致命
如果是普通线程,用完就结束,Thread 对象被回收,它的 ThreadLocalMap 连同里面所有 Entry、value 一起被回收,泄漏也就"自愈"了——所以短生命周期的线程,问题不明显。
但线程池里的线程是复用的、长期存活的。一个线程处理完请求 A,不会销毁,而是回到池里等着处理请求 B、C、D……它的 ThreadLocalMap 一直存在。于是:
- 每个请求
set一个UserContext,用完不remove; - 线程不死,
ThreadLocalMap不释放,僵尸 Entry(或旧 value)越积越多; - 内存持续增长,最终 OOM。
“ThreadLocal + 线程池 + 忘记 remove” 是内存泄漏的黄金三角。 这也是为什么这个坑在 Web 应用(Tomcat 线程池)里特别常见。
JDK 其实做了点"补救":
ThreadLocalMap在set/get/remove时,会顺带清理一些 key 为null的僵尸 Entry(探测式清理)。但这个清理是"碰运气"的、不彻底的,绝不能依赖它。根治办法只有一个:手动remove。
三、正解:用完一定 remove(),最好放在 finally 里
根治方案非常简单:每次用完 ThreadLocal,显式调用 remove()。 remove() 会把当前线程 ThreadLocalMap 里对应的整个 Entry(key 和 value)都删掉,斩断强引用链。
关键是要保证 remove() 一定被执行,所以放在 finally 里:
public boolean preHandle(HttpServletRequest req, ...) {
UserContextHolder.set(buildContext(req));
return true;
}
// 在请求结束的回调里 remove(Spring 的 afterCompletion)
public void afterCompletion(HttpServletRequest req, ...) {
UserContextHolder.remove(); // ✓ 请求结束,清理
}
或者在业务代码里用标准的 try-finally 包裹:
try {
UserContextHolder.set(ctx);
doBusiness();
} finally {
UserContextHolder.remove(); // ✓ 无论是否异常,都清理
}
(finally 里做清理,正是它的正确用法——可参考上一篇《try-finally 里的 return》。)
几个补充实践:
- 拦截器/过滤器场景:在
afterCompletion或finally里统一remove,别依赖 JDK 的探测式清理。 - 把
ThreadLocal声明为static final:让它跟随类存在,避免它被意外回收(其实反而是"key 不该被过早回收"),也便于统一管理。注意这和"防泄漏"不矛盾——防泄漏靠的是remove,不是让 key 被回收。 - 父子线程传递用
InheritableThreadLocal,但线程池下要谨慎:线程复用会导致继承的值错乱,阿里的TransmittableThreadLocal(TTL)是更完善的方案。

四、常见误区与面试高频问答
Q:Entry 的 key 用了弱引用,不就是为了防泄漏吗,为什么还漏?
弱引用只解决了 key(ThreadLocal 实例) 的回收,让没人引用的 ThreadLocal 能被 GC。但 value 是强引用,它通过 Thread → ThreadLocalMap → Entry → value 这条链被线程强引用着,只要线程活着就回收不掉。弱引用防了 key,防不了 value——这才是泄漏的根源。
Q:那 key 为什么不干脆也用强引用,或者 value 也用弱引用?
key 用强引用会更糟:ThreadLocalMap 会强引用 ThreadLocal,导致 ThreadLocal 实例本身也回收不掉,泄漏更严重。value 用弱引用又不行:value 通常没有其他强引用,一 GC 就没了,ThreadLocal 就存不住值了。所以现在这个"key 弱、value 强"是权衡后的设计,代价就是需要你手动 remove。
Q:JDK 不是会自动清理 null key 的 Entry 吗?
会,但不可靠。set/get/remove 时会触发探测式/启发式清理,顺路清掉一些 key 为 null 的 Entry。但它只清理"碰到的"部分槽位,不保证全清,更不会主动触发。如果后续不再调用这个 ThreadLocal 的方法,僵尸 Entry 就一直留着。不能依赖它,必须手动 remove。
Q:为什么普通线程没事,线程池才严重?
普通线程执行完就销毁,Thread 及其 ThreadLocalMap 整个被回收,泄漏自动消失。线程池的线程长期复用、不销毁,ThreadLocalMap 一直存在,不 remove 的话 value 越积越多,泄漏就暴露了。
Q:remove() 和 set(null) 一样吗?
不一样。set(null) 只是把 value 设为 null,Entry 本身(key 和这个 null value)还留在 map 里,是"半清理"。remove() 会把整个 Entry 从 map 中删除,才是彻底清理。要 remove()。
总结
“ThreadLocal 的 key 是弱引用,为什么还泄漏”,答案在那个不对称的设计里:
ThreadLocal的值存在线程的ThreadLocalMap里,Entry的 key(ThreadLocal)是弱引用、value(你的值)是强引用。- 弱引用让没人用的 key 能被 GC 回收(key 变
null),但 value 仍被Thread → ThreadLocalMap → Entry → value强引用链拴着,只要线程活着就回收不掉,形成"key 为 null、value 常驻"的僵尸 Entry。 - 线程池里线程长期复用、不销毁,把这个隐患放大成持续的内存泄漏,直至 OOM。
- 根治办法只有一个:用完
remove(),并放在finally/afterCompletion里确保执行。别指望 JDK 的探测式清理。
一句话记忆: 弱引用只保护 key,value 是强引用、被活着的线程拴着回收不掉;ThreadLocal + 线程池 + 忘记 remove = 内存泄漏,用完必须 remove()。
237

被折叠的 条评论
为什么被折叠?



