Java 线程本地变量 ThreadLocal 深度解析——内存泄漏的根本原因与预防
Java 线程本地变量 ThreadLocal 深度解析——内存泄漏的根本原因与预防
适读人群:Java后端开发,用过或打算用ThreadLocal的工程师 | 阅读时长:约16分钟 | 核心价值:彻底搞清楚ThreadLocal的底层实现,以及内存泄漏的真正原因和预防方案
一次诡异的用户数据串了的事故
2021年,我们做了一个功能:在Controller层把当前登录用户存入ThreadLocal,在后面的Service层直接取,省得每个方法都传userId参数。
代码很常见:
public class UserContext {
private static final ThreadLocal<UserInfo> current = new ThreadLocal<>();
public static void set(UserInfo user) { current.set(user); }
public static UserInfo get() { return current.get(); }
public static void remove() { current.remove(); }
}上线后运行了一周,突然有用户反映:他查到了别人的订单数据。
排查之后发现原因:使用Tomcat线程池,线程是复用的。某个请求处理完之后,没有调用UserContext.remove(),线程归还到线程池。下一个不同用户的请求复用这个线程,ThreadLocal里还是上一个用户的数据,结果查了别人的订单。
不是内存泄漏,是数据污染——但同样因为没有remove()。
今天把ThreadLocal的底层实现、内存泄漏原因和正确使用方式都讲清楚。
ThreadLocal 的底层实现
数据存储在哪里
很多人以为ThreadLocal是一个Map,key是线程,value是值。实际上反过来:
数据存储在Thread对象里,每个Thread有一个ThreadLocalMap字段:
// Thread 类里(简化)
class Thread {
ThreadLocal.ThreadLocalMap threadLocals = null; // 就是这个
ThreadLocal.ThreadLocalMap inheritableThreadLocals = null;
}ThreadLocalMap是一个自定义的哈希表,key是ThreadLocal对象的弱引用,value是存储的值。
Thread对象
└── threadLocals (ThreadLocalMap)
├── Entry[0]: key=WeakRef(ThreadLocal_A), value=UserInfo{id=1001}
├── Entry[1]: null
├── Entry[2]: key=WeakRef(ThreadLocal_B), value="session-token-xyz"
└── ...get/set 的完整流程
// ThreadLocal.get() 的逻辑
public T get() {
Thread t = Thread.currentThread(); // 拿到当前线程
ThreadLocalMap map = getMap(t); // 拿到线程的 ThreadLocalMap
if (map != null) {
ThreadLocalMap.Entry e = map.getEntry(this); // 以当前ThreadLocal为key查找
if (e != null) {
return (T) e.value; // 找到了直接返回
}
}
return setInitialValue(); // 没有就初始化
}
// ThreadLocal.set() 的逻辑
public void set(T value) {
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);
if (map != null)
map.set(this, value); // 以当前ThreadLocal为key存入
else
createMap(t, value); // 第一次set,创建ThreadLocalMap
}关键点:key是ThreadLocal对象本身(弱引用),不是线程ID或线程名字。一个线程可以有多个ThreadLocal变量,它们都存在同一个ThreadLocalMap里。
内存泄漏的根本原因
为什么 key 是弱引用
强引用链:
栈帧变量 → ThreadLocal 对象
弱引用链(ThreadLocalMap里):
Thread → ThreadLocalMap → Entry.key → ThreadLocal 对象(弱引用)
→ Entry.value → 实际数据(强引用!)如果key是强引用,当外部的ThreadLocal变量(如static字段)被设置为null时,ThreadLocal对象会因为ThreadLocalMap里的强引用而无法被GC回收——每个线程都持有对它的强引用,所有线程不结束,它就不能回收。
所以JDK把key设计为弱引用:当外部不再持有ThreadLocal对象的强引用时,GC可以回收ThreadLocal对象,key变成null。
内存泄漏怎么发生的
key变成null之后,对应的value却还是强引用,无法被GC回收:
泄漏状态:
Thread(存活,因为是线程池线程)
└── ThreadLocalMap
└── Entry: key=null(ThreadLocal已被GC), value=UserInfo{...}(无法被回收!)只要线程不死(线程池线程通常不死),这个value就会一直占着内存。如果value是一个大对象(比如List、复杂的Session对象),泄漏就很明显了。
复现内存泄漏
/**
* 演示 ThreadLocal 内存泄漏
* 注意:在实际应用中绝对不要这样写
*/
public class ThreadLocalLeakDemo {
// 这个 ThreadLocal 会被GC回收(没有static持有)
public static void demonstrateLeak() throws Exception {
ExecutorService pool = Executors.newFixedThreadPool(2);
for (int i = 0; i < 10000; i++) {
final int iteration = i;
pool.submit(() -> {
// 每次循环创建新的 ThreadLocal(不是static的!)
// 不推荐,这里仅为演示泄漏
ThreadLocal<byte[]> leakingLocal = new ThreadLocal<>();
leakingLocal.set(new byte[1024 * 100]); // 100KB数据
// 注意:没有 remove(),ThreadLocal对象也没有static引用
// 下次GC:leakingLocal对象被回收,key变null
// 但Thread还在池子里,Thread.threadLocals里的Entry(null, 100KB数据)
// 永远不会被回收!直到线程死亡或该Entry被清理
});
}
// 触发GC,观察内存变化
System.gc();
Thread.sleep(1000);
pool.shutdown();
}
}ThreadLocalMap 的自清理机制
ThreadLocalMap并非完全不清理:每次get()、set()、remove()操作时,会清理遇到的key为null的Entry("stale entries")。但这种清理是惰性的、不完全的——只有在执行操作时才触发,没有操作就一直泄漏。
所以不能依赖ThreadLocalMap的自清理,必须手动remove()。
完整可运行代码:正确使用 ThreadLocal
/**
* ThreadLocal 的完整正确使用示例
* 包含:Web请求上下文传递、数据库连接管理、remove的保障方式
*/
// ============ 1. Web 请求上下文传递 ============
public class RequestContext {
private static final ThreadLocal<RequestInfo> REQUEST_INFO = new ThreadLocal<>();
public record RequestInfo(
String userId,
String traceId,
String tenantId,
long requestTime
) {}
public static void set(RequestInfo info) {
REQUEST_INFO.set(info);
}
public static RequestInfo get() {
RequestInfo info = REQUEST_INFO.get();
if (info == null) {
throw new IllegalStateException("RequestContext未初始化,请确保在请求处理前调用set()");
}
return info;
}
public static String getUserId() {
return get().userId();
}
public static void remove() {
REQUEST_INFO.remove();
}
}
// ============ 2. 拦截器:设置和清理上下文 ============
@Component
public class RequestContextInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request,
HttpServletResponse response,
Object handler) {
// 从JWT或Session中解析用户信息
String userId = extractUserId(request);
String traceId = request.getHeader("X-Trace-Id");
if (traceId == null) traceId = UUID.randomUUID().toString();
String tenantId = extractTenantId(request);
RequestContext.set(new RequestContext.RequestInfo(
userId, traceId, tenantId, System.currentTimeMillis()
));
return true;
}
@Override
public void afterCompletion(HttpServletRequest request,
HttpServletResponse response,
Object handler, Exception ex) {
// 请求完成后必须清理!
// afterCompletion 在所有情况下都会执行(包括异常)
RequestContext.remove();
}
private String extractUserId(HttpServletRequest req) {
// JWT解析逻辑
return "user-123"; // 简化
}
private String extractTenantId(HttpServletRequest req) {
return req.getHeader("X-Tenant-Id");
}
}
// ============ 3. Service 层使用 ============
@Service
public class OrderService {
public List<Order> getMyOrders() {
String userId = RequestContext.getUserId(); // 无需参数传递
String tenantId = RequestContext.get().tenantId();
// 自动带上当前用户和租户过滤
return orderDao.findByUserIdAndTenantId(userId, tenantId);
}
}
// ============ 4. 异步场景:ThreadLocal 不能跨线程传递 ============
@Service
public class AsyncService {
@Async
public void asyncTask() {
// 危险!@Async 用的是另一个线程,ThreadLocal里没有数据
String userId = RequestContext.getUserId(); // 可能抛异常或拿到null
}
/**
* 正确:手动传递上下文到异步线程
*/
public void asyncTaskCorrect() {
// 在主线程中提前捕获上下文
RequestContext.RequestInfo contextSnapshot = RequestContext.get();
CompletableFuture.runAsync(() -> {
// 在新线程中设置上下文
RequestContext.set(contextSnapshot);
try {
doAsyncWork();
} finally {
RequestContext.remove(); // 异步线程也要清理!
}
}, asyncExecutor);
}
private void doAsyncWork() {
String userId = RequestContext.getUserId(); // 现在可以正确获取
System.out.println("异步任务,用户: " + userId);
}
}
// ============ 5. InheritableThreadLocal:子线程继承父线程的值 ============
public class InheritableDemo {
// InheritableThreadLocal:创建子线程时,子线程会继承父线程的值
private static final InheritableThreadLocal<String> context =
new InheritableThreadLocal<>();
public static void main(String[] args) throws InterruptedException {
context.set("parent-value");
Thread child = new Thread(() -> {
System.out.println("子线程获取到: " + context.get()); // parent-value
});
child.start();
child.join();
// 注意:InheritableThreadLocal 在线程池场景下依然不可靠
// 因为线程池的线程是提前创建的,不是任务提交时创建的
// 如果需要在线程池中传递上下文,使用 TransmittableThreadLocal(阿里开源)
}
}三个踩坑实录
坑一:线程池复用线程,ThreadLocal 数据污染
现象: 用户A的请求能查到用户B的数据,但日志显示JWT解析没问题。
原因: 如上文开头案例,拦截器的afterCompletion里忘了RequestContext.remove(),线程归还到Tomcat线程池后,ThreadLocal里还保留着上个请求的用户数据,下个请求复用该线程时读到了脏数据。
解法: 始终在afterCompletion或finally块里调用remove(),且不要依赖业务代码来做清理,应该在框架层(拦截器/过滤器)统一处理。
坑二:@Async 方法里读不到 ThreadLocal 数据
现象: 主业务流程中设置了TraceId到ThreadLocal,@Async方法打的日志里TraceId全是null。
原因: @Async使用的是Spring的异步线程池里的线程,不是处理当前请求的线程,自然读不到当前线程的ThreadLocal数据。
解法: 方案1:用上文的"提前捕获上下文"方式手动传递 方案2:用阿里的TransmittableThreadLocal(TTL),它能解决线程池场景下的上下文传递 方案3:改为显式参数传递(最简单但侵入性强)
坑三:static ThreadLocal 里存了 Spring Bean,导致 ClassLoader 泄漏
现象: 应用部署到Tomcat,热重启后内存持续增长,最终OOM。
原因: static ThreadLocal存的值里引用了Spring Bean(包含ApplicationContext的引用),而Tomcat的线程不会随应用重启而销毁。热重启时,新ClassLoader加载了新的类,但老ClassLoader因为ThreadLocal里的引用链无法被GC,导致ClassLoader泄漏。
Tomcat线程(存活)
└── ThreadLocalMap
└── value = MyBean(由老ClassLoader加载)
└── ApplicationContext
└── 大量Bean和Class对象
└── 引用老ClassLoader(无法GC!)解法:
- 在应用关闭时(@PreDestroy)调用ThreadLocal.remove()
- 不要在ThreadLocal里存含有ClassLoader引用的对象
- 使用JVM参数
-XX:+UseGCOverheadLimit让OOM快速暴露问题
小结
ThreadLocal的内存泄漏本质是:
- key是弱引用,ThreadLocal对象可能被GC
- 但value是强引用,无法被GC
- 线程池的线程长期存活,Entry一直占内存
预防三原则:
- static + final修饰:保证ThreadLocal对象本身不会被GC(key不会变null)
- 用完即remove():在请求拦截器/filter的finally里统一清理
- 警惕线程池场景:线程复用会带来数据污染,异步线程无法自动继承父线程上下文
