ThreadLocal原理与内存泄漏:WeakReference和expungeStaleEntry
ThreadLocal原理与内存泄漏:WeakReference和expungeStaleEntry
适读人群:Java中高级开发者、使用过ThreadLocal的后端工程师 | 阅读时长:约17分钟
开篇故事
2020年,我们有个Web服务,运行几天后内存稳定增长,最终OOM重启。GC日志里Young GC越来越频繁,Full GC开始出现。
用MAT(Memory Analyzer Tool)分析堆dump,发现一大片ThreadLocalMap$Entry对象,占了约1.2GB内存。定位到是一个中间件SDK的日志MDC组件,用ThreadLocal存储了每个请求的trace信息,但没有在请求结束时清理。
为什么这会导致内存泄漏,而不是线程结束后自动回收?
答案是:我们用的是线程池,线程不死。线程活着,ThreadLocalMap就活着,里面的Entry就活着,trace信息就永远不会被GC回收。
这次事故让我深入研究了ThreadLocal的内存模型,包括那个经常被提到但很少人真正理解的WeakReference和expungeStaleEntry机制。
一、ThreadLocal的内存模型
1.1 ThreadLocal、Thread和ThreadLocalMap的关系
很多人以为ThreadLocal自己存了数据,其实数据存在Thread对象里:
Thread {
ThreadLocal.ThreadLocalMap threadLocals; // 每个Thread自己的Map
}
ThreadLocalMap {
Entry[] table; // 散列表
}
Entry extends WeakReference<ThreadLocal<?>> {
Object value; // 真正的数据
// key是对ThreadLocal的弱引用!
// value是强引用
}当你调用threadLocal.set(value)时:
- 获取当前线程的
threadLocals(如果没有则创建) - 以
threadLocal为key,value为value,存入ThreadLocalMap
关键:Entry的key是对ThreadLocal对象的WeakReference(弱引用),而value是强引用。
1.2 WeakReference的含义
WeakReference意味着:如果ThreadLocal对象只有这一个弱引用指向它(没有任何强引用),下次GC时ThreadLocal对象就会被回收。
EntryKey变成null(因为ThreadLocal被GC了),但value还在(强引用)。这个key为null的Entry就是stale entry(过期Entry),它的value造成内存泄漏。
二、核心机制:WeakReference与expungeStaleEntry
2.1 ThreadLocalMap的散列冲突解决
ThreadLocalMap不使用链表解决散列冲突(不像HashMap),而是用开放地址法(线性探测):
- 找到冲突时,往后找下一个空槽
- get时同样线性探测,找到匹配的key
这意味着遍历table时会顺序访问Entry数组,expungeStaleEntry在遍历时就能顺便清理遇到的stale entries。
2.2 expungeStaleEntry源码分析
// ThreadLocalMap.expungeStaleEntry(简化注释)
private int expungeStaleEntry(int staleSlot) {
Entry[] tab = table;
int len = tab.length;
// 清理staleSlot位置的Entry(key已经是null)
tab[staleSlot].value = null; // 断开value引用,帮助GC
tab[staleSlot] = null; // 删除Entry本身
size--;
// 继续向后扫描,直到遇到null槽
Entry e;
int i;
for (i = nextIndex(staleSlot, len); (e = tab[i]) != null; i = nextIndex(i, len)) {
ThreadLocal<?> k = e.get(); // 获取WeakRef指向的ThreadLocal
if (k == null) {
// 又发现一个key为null的stale entry,清理之
e.value = null;
tab[i] = null;
size--;
} else {
// key还活着,检查是否需要重新散列(因为staleSlot被删了,可能影响后续探测)
int h = k.threadLocalHashCode & (len - 1);
if (h != i) {
// 重新放置到正确位置
tab[i] = null;
while (tab[h] != null) h = nextIndex(h, len);
tab[h] = e;
}
}
}
return i;
}expungeStaleEntry在set()、get()、remove()时可能被触发,顺便清理沿途遇到的stale entries。但它是被动触发的,如果一直没有ThreadLocal操作,stale entries会永久存活。
2.3 为什么value不用WeakReference?
理论上,如果value也用WeakReference,只要外部没有对value的强引用,value就可以被GC回收,不就解决内存泄漏了吗?
但这样ThreadLocal就失去了意义:你存进去的value,在任何时候都可能被GC悄悄回收,下次threadLocal.get()可能返回null。这完全违背了"线程局部变量"的语义。
所以ThreadLocal的设计选择:
- key用WeakReference(ThreadLocal对象本身通常是static final,活得比Thread还久;即使不是static,GC ThreadLocal不影响已存储的数据)
- value用强引用(数据必须可靠存在,不能被GC)
- 依靠开发者主动调用remove()来清理
三、完整代码实现
3.1 ThreadLocal内存泄漏的复现与修复
package com.laozhang.concurrent.threadlocal;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
/**
* ThreadLocal内存泄漏复现与修复
*
* 内存泄漏场景:
* 1. 使用线程池(线程不死)
* 2. ThreadLocal存入较大对象
* 3. 请求结束后没有调用remove()
*
* 测试环境:JDK 11
* 检测方式:用 -Xmx256m 限制堆大小,观察OOM
*/
public class ThreadLocalLeakDemo {
// 模拟请求上下文(每个请求有一些数据)
static class RequestContext {
final String requestId;
final byte[] data; // 模拟请求携带的数据,1MB
RequestContext(String requestId) {
this.requestId = requestId;
this.data = new byte[1024 * 1024]; // 1MB
}
}
// ===== 有内存泄漏的版本 =====
static ThreadLocal<RequestContext> leakyContext = new ThreadLocal<>();
static void processRequestLeaky(String requestId) {
leakyContext.set(new RequestContext(requestId));
try {
// 处理请求...
String id = leakyContext.get().requestId;
} finally {
// 忘记了 leakyContext.remove()
}
}
// ===== 正确的版本 =====
static ThreadLocal<RequestContext> safeContext = new ThreadLocal<>();
static void processRequestSafe(String requestId) {
safeContext.set(new RequestContext(requestId));
try {
// 处理请求...
String id = safeContext.get().requestId;
} finally {
safeContext.remove(); // 必须在finally里清理!
}
}
// ===== 更好的封装:AutoCloseable模式 =====
static class RequestContextHolder implements AutoCloseable {
private static final ThreadLocal<RequestContext> holder = new ThreadLocal<>();
public RequestContextHolder(String requestId) {
holder.set(new RequestContext(requestId));
}
public static RequestContext get() {
return holder.get();
}
@Override
public void close() {
holder.remove(); // try-with-resources自动清理
}
}
static void processRequestAutoCloseable(String requestId) {
try (RequestContextHolder ctx = new RequestContextHolder(requestId)) {
// 处理请求...
String id = RequestContextHolder.get().requestId;
}
// 自动调用close() → remove()
}
public static void main(String[] args) throws InterruptedException {
ExecutorService pool = Executors.newFixedThreadPool(4);
System.out.println("开始模拟请求处理...");
// 提交100个请求
for (int i = 0; i < 100; i++) {
final String requestId = "REQ-" + i;
pool.submit(() -> {
// 切换这两个方法,对比内存使用
processRequestSafe(requestId);
// processRequestLeaky(requestId); // 使用这个会导致内存泄漏
});
}
pool.awaitTermination(5, TimeUnit.SECONDS);
pool.shutdown();
// 建议添加以下JVM参数观察GC:
// -Xmx256m -XX:+PrintGCDetails -XX:+PrintGCDateStamps
System.out.println("处理完成。如果使用leaky版本,内存不会释放。");
}
}3.2 InheritableThreadLocal:父子线程的值传递
package com.laozhang.concurrent.threadlocal;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
/**
* InheritableThreadLocal:子线程继承父线程的ThreadLocal值
*
* 使用场景:
* - 分布式链路追踪(traceId从主线程传到异步线程)
* - 用户认证上下文(异步任务需要知道当前是哪个用户)
*
* 注意:
* 1. 只在线程创建时复制一次(new Thread时),不是动态同步
* 2. 线程池里的线程在创建时复制,之后主线程的修改不会影响池中线程
* 3. 阿里TransmittableThreadLocal(TTL)解决了线程池场景下的传递问题
*
* 测试环境:JDK 11
*/
public class InheritableThreadLocalDemo {
// 普通ThreadLocal:子线程看不到父线程的值
static ThreadLocal<String> regular = new ThreadLocal<>();
// InheritableThreadLocal:子线程在创建时复制父线程的值
static InheritableThreadLocal<String> inheritable = new InheritableThreadLocal<>();
public static void main(String[] args) throws InterruptedException {
// 在主线程设置值
regular.set("main-thread-data");
inheritable.set("main-thread-inheritable-data");
System.out.println("[主线程] regular: " + regular.get());
System.out.println("[主线程] inheritable: " + inheritable.get());
// 创建子线程
Thread child = new Thread(() -> {
System.out.println("[子线程] regular: " + regular.get()); // null
System.out.println("[子线程] inheritable: " + inheritable.get()); // 复制自父线程
// 子线程修改inheritable不影响父线程(各自独立的副本)
inheritable.set("child-modified");
System.out.println("[子线程] 修改后: " + inheritable.get());
});
child.start();
child.join();
System.out.println("[主线程] 子线程修改后主线程的inheritable: " + inheritable.get());
// ===== 线程池场景的问题 =====
System.out.println("\n=== 线程池场景(ITL的局限性) ===");
ExecutorService pool = Executors.newFixedThreadPool(1);
// 第一次提交:线程创建,复制当前值
inheritable.set("request-1-data");
pool.submit(() -> {
System.out.println("[池线程-请求1] inheritable: " + inheritable.get());
// 输出:request-1-data(线程创建时复制的)
});
TimeUnit.MILLISECONDS.sleep(100);
// 第二次提交:线程复用,不重新复制
inheritable.set("request-2-data");
pool.submit(() -> {
System.out.println("[池线程-请求2] inheritable: " + inheritable.get());
// 输出:request-1-data(线程是复用的,不重新复制!这是bug!)
});
TimeUnit.MILLISECONDS.sleep(100);
System.out.println("结论:线程池场景下ITL无法正确传递,需要用阿里TTL(TransmittableThreadLocal)");
pool.shutdown();
regular.remove();
inheritable.remove();
}
}四、踩坑实录
坑1:static ThreadLocal字段使用后不remove,线程池OOM
报错现象: 服务运行几小时到几天后OOM,堆dump里有大量业务对象(RequestInfo、UserContext等),都被ThreadLocalMap的Entry引用着。
原因分析: 这是最常见的ThreadLocal内存泄漏场景。典型代码:
// 通常声明为static(这本身没问题,static的ThreadLocal是全局入口)
static final ThreadLocal<UserContext> USER_CONTEXT = new ThreadLocal<>();
// 在请求处理时set
USER_CONTEXT.set(new UserContext(userId, ...));
// 请求处理完,没有remove!
// Tomcat/线程池的线程继续存活
// ThreadLocalMap里的Entry永远不会被GC每个请求都在同一个线程上set一个新的UserContext,但旧的UserContext在Map里只是被新的覆盖了(value替换),不会GC。实际上set覆盖不会泄漏,但如果线程和Entry越来越多(线程池动态扩展),就会累积。
正确做法: 用AOP或Filter的finally块统一调用remove():
// Spring AOP切面
@Around("@annotation(NeedRequestContext)")
public Object aroundMethod(ProceedingJoinPoint pjp) throws Throwable {
try {
USER_CONTEXT.set(buildContext());
return pjp.proceed();
} finally {
USER_CONTEXT.remove(); // 必须清理
}
}坑2:在子线程里使用父线程的ThreadLocal值,改了父线程不知道
报错现象: 主线程设置了认证信息,提交给线程池的任务里修改了认证信息,但主线程的认证信息没有更新(期望是同步更新的)。
原因分析: InheritableThreadLocal在子线程创建时复制父线程的值,之后父子是独立的副本,互不影响。这不是"引用传递",是"值复制"(如果value是对象,复制的是引用,但如果value是不可变对象或基本类型包装,就完全独立了)。
解法: 如果需要真正共享(父线程能看到子线程的修改),应该共享一个可变对象(如AtomicReference),而不是用InheritableThreadLocal。
坑3:ThreadLocal.withInitial()的initialValue在同一线程多次调用
报错现象: 使用ThreadLocal.withInitial(() -> new ArrayList<>()),发现同一个线程的不同地方,get()返回的ArrayList是同一个对象(符合预期),但某些情况下是一个新的空ArrayList,之前加进去的元素消失了。
原因分析: withInitial的supplier只在"第一次get且没有set过"或"remove()后再次get"时调用。如果某处代码调用了threadLocal.remove()(比如某个AOP切面统一清理),下次get()会重新调用supplier,返回新的ArrayList。
解法: 检查代码路径,确认没有意外的remove()调用。对于需要在整个请求生命周期内保持的数据,确保只在请求开始时set,请求结束时remove。
坑4:Tomcat/JBoss重新部署应用时的类加载器内存泄漏
报错现象: 热部署应用后,JVM内存持续增长,每次重部署都增加一大块,最终OOM或PermGen溢出(JDK 7)/ Metaspace溢出(JDK 8+)。
原因分析: 这是ThreadLocal和类加载器结合产生的特殊泄漏:
- Web应用的类由WebApp ClassLoader加载
- 应用代码在Tomcat线程中设置了ThreadLocal(Tomcat的线程是全局的,不属于WebApp ClassLoader)
- 应用重新部署,WebApp ClassLoader被替换,但Tomcat线程里的ThreadLocalMap还活着
- ThreadLocalMap里的Entry的value是WebApp ClassLoader加载的类的实例
- 旧的WebApp ClassLoader无法被GC(因为有强引用指向它加载的类)
- 每次重部署都累积一个旧的ClassLoader
解法:
- Servlet里的
contextDestroyed()里清理ThreadLocal - 使用
Filter在每个请求结束时清理 - 日志框架(Log4j、Logback)和MDC要特别注意,它们内部用了ThreadLocal
五、总结与延伸
ThreadLocal的内存模型总结:
Thread
└── ThreadLocalMap
└── Entry[] table
├── key = WeakRef<ThreadLocal> (GC可回收)
└── value = Object (强引用,不会自动GC)内存泄漏的根本原因:
- 线程池的线程长期存活
- ThreadLocalMap里的stale entry(key=null,value有值)
- 被动清理(expungeStaleEntry)只在有后续操作时触发
防泄漏铁律:
- 永远在
finally块里调用threadLocal.remove() - 框架层(Filter/AOP)统一清理,不依赖业务代码
- 线程池场景避免用
InheritableThreadLocal传递动态数据,考虑阿里TransmittableThreadLocal - 代码审查时,每个
ThreadLocal.set()都要对应一个remove()
JDK版本注意:
- JDK 17+:
ThreadLocal的内存模型没有变化,但虚拟线程(Loom)引入了ScopedValue作为ThreadLocal的更安全替代(不会泄漏,因为作用域明确)
