一句话先说结论:ThreadLocal 的 key 是弱引用,value 却是强引用。线程复用后 key 被 GC 回收,value 还挂在 ThreadLocalMap 里,Full GC 收不回来,最终
OutOfMemoryError。解决就一条:用完在finally里调remove()。
背景
ThreadLocal 给每个线程一份私有变量副本,线程之间互不干扰。典型用途是保存请求上下文、用户会话、事务信息这类「每个线程一份」的数据。
每个 Thread 内部都有一个 ThreadLocalMap,它是线程私有的微型哈希表,ThreadLocal 的值就存在这里。问题恰恰出在这张表上。
现象
出问题的往往是线程池加容器环境。应用跑一段时间后堆持续上涨,Full GC 也压不下去,最后抛出错误。
堆被吃满的情况:
Exception in thread "http-nio-8080-exec-3" java.lang.OutOfMemoryError: Java heap space
容器热部署、反复重载 classloader 的场景还会抛另一种:
java.lang.OutOfMemoryError: Metaspace
后者更隐蔽:ThreadLocal 强引用挡住了 classloader 的回收,元空间逐渐耗尽。
JDK 官方 bug 库 JDK-6558265 给了一个最小复现,用 2 个线程的线程池跑两次任务就能触发:
public class Main {
private static final int HUGE_BLOCK_SIZE =
(int) (Runtime.getRuntime().maxMemory() / 2 + 1);
private static ThreadLocal<Object> tls = new ThreadLocal<Object>() {
protected Object initialValue() {
return new byte[HUGE_BLOCK_SIZE];
}
};
private static ExecutorService executor = Executors.newFixedThreadPool(2);
static void execute(Runnable task) throws Exception {
Future<?> future = executor.submit(task);
future.get();
}
public static void main(String[] args) throws Exception {
Runnable task = () -> tls.get();
execute(task); // 第一次:大对象塞进 worker-1 的 threadLocals
execute(task); // 第二次:worker-1 还占着,直接 OOM
executor.shutdown();
}
}
只要第二次任务占不满剩余堆,就会在下一次分配时爆掉。
根因分析
关键在 ThreadLocalMap 的 Entry 定义。它继承自 WeakReference:
static class Entry extends WeakReference<ThreadLocal<?>> {
Object value;
Entry(ThreadLocal<?> k, Object v) {
super(k); // key 是弱引用
value = v; // value 是强引用
}
}
super(k) 把 key 包成弱引用,value 却是普通强引用。
泄漏全程大概是这样:
- 线程池里的线程处理请求,方法里
set了值。 - 方法结束没调
remove(),指向ThreadLocal实例的强引用也消失了。 - 下次 GC,key(弱引用)被回收,这个 Entry 变成
key = null。 - value 还被 Entry 强引用,谁也访问不到,GC 也收不走。
- 线程放回池里继续复用,
ThreadLocalMap一直活着,key = null的 Entry 越攒越多。
弱引用本意是「key 没了就能顺手清掉整个 Entry」。可实际只在 ThreadLocal.get()/set() 时,ThreadLocalMap 才顺带清理碰到的废旧 Entry。线程此后不再碰这个 ThreadLocal,那些 key = null 的 Entry 就一直躺着。
线程池把「线程短命」这个前提打破了。普通线程用完就死,threadLocals 跟着回收;线程池里的线程长命百岁,表一直存在。
解决方案
首选:finally 里 remove
最直接也最有效的是主动清理:
ThreadLocal<UserContext> ctx = new ThreadLocal<>();
void handle() {
try {
ctx.set(new UserContext());
// 业务逻辑
} finally {
ctx.remove();
}
}
remove() 会把当前线程这个 ThreadLocal 对应的 Entry 整个删掉。业务抛异常也不怕,finally 保证执行。
把 ThreadLocal 声明成 static final
频繁 new ThreadLocal 会造出多个实例,而弱引用清理依赖「同一个实例」的引用生命周期。声明成常量,一来只维护一个 key,二来更容易追踪:
private static final ThreadLocal<UserContext> CTX = new ThreadLocal<>();
框架层兜底
Tomcat 这类容器会检测到「线程结束时 ThreadLocal 没清」并打警告日志,帮你揪出漏清理的地方。它只提示、不替你清理,最终还是得回到 remove()。
别指望 JDK 自动处理。JDK-6558265 提出过 ThreadLocalScope(任务结束后自动擦除线程局部变量),至今仍是 Unresolved 状态,说明官方把它当增强特性挂着,没当 bug 修。
思考过程
社区流传一个说法:「ThreadLocal 用了弱引用,所以不会泄漏」。这是错的。
弱引用只让 key 能被回收,value 才是泄漏的本体。弱引用的设计,是防止 ThreadLocal 实例本身长期占内存,不是防 value 泄漏。分清这点,面试和排查都不会被绕进去。
另一个容易漏的点:InheritableThreadLocal 一样会漏,只要值被线程池里的线程持有且不清理,机理完全相同。
小结
- ThreadLocal 泄漏的根子是 value 强引用加线程复用,不是弱引用失效。
- 表现是堆或元空间缓慢吃满,最终
OutOfMemoryError。 - 修法就一条:
finally里remove(),再把ThreadLocal声明成static final。 - 别等 JDK 兜底,
ThreadLocalScope至今没落地。
来源
- JDK-6558265: Using thread locals with thread pools may lead to unintentional object retention
- Azure/azure-sdk-for-java #48018: Static ThreadLocal<Collator> is never cleaned up
- Azure/azure-sdk-for-java #48037: Replace ThreadLocal Collator with instance Collator
- Troubleshooting Memory Leaks — Oracle Java Platform