在 Java 后端开发中,ThreadLocal是一个既熟悉又神秘的存在。我们用它来存用户登录信息、链路追踪 ID,甚至它是 Spring 声明式事务能够生效的基石。
但同时,关于它的流言蜚语从未停止:“用不好会内存泄漏”、“阿里的规范里严禁使用”、“请求量小就没事”。
今天,我们就结合 Web 请求的生命周期和 Spring 事务的源码设计,彻底讲清楚 ThreadLocal的工作原理与避坑指南。
一、Web 请求中的线程真相:为什么你的数据能一路透传?
很多初学者认为,一个 HTTP 请求从 Controller-> Service-> Dao,是多线程在协作。其实不然。
1. 单线程模型
在默认的 Spring Boot(Tomcat)应用中:
一个请求 = 一个线程。
Tomcat 使用线程池处理请求。
从请求进入,到响应返回,除非你手动开启异步线程,否则全程都是同一个线程在执行。
2. ThreadLocal 的作用域
正因为如此,ThreadLocal才能发挥作用。ThreadLocal 作为当前线程的局部变量,它相当于给当前线程提供了一个私有的储物柜。
只要在同一个线程里,无论调用多少个 Service,大家看到的都是同一个 User对象。
// 伪代码:请求上下文
public class RequestContext {
private static final ThreadLocal<User> TL = new ThreadLocal<>();
}二、内存泄漏之谜:既然请求结束了,对象不该被回收吗?
这是最经典的误解。
1. 普通对象 vs ThreadLocal Value
普通对象(如 new User()):存储在堆中,方法结束,栈帧弹出,引用断开,GC 回收。
ThreadLocal 存储的 Value:存储在线程的私有地图(ThreadLocalMap)中。
关键点:Web 服务器使用的是线程池。请求结束了,线程并没有死,而是回到了池中,准备接下一个请求。
只要线程活着,它口袋里的东西(Value)就一直活着。
2. 为什么 Key 是弱引用,Value 是强引用?
查看 ThreadLocalMap的源码,你会发现 Entry 是这样定义的:
static class Entry extends WeakReference<ThreadLocal<?>> {
Object value; // 强引用
}为什么要这么设计?
3. 泄漏是如何发生的?
请求到来,线程执行,ThreadLocalMap中塞入 Entry(key, value)。
请求结束,代码中未调用 remove()。
线程回到池中。
下一次请求来了,复用该线程。
旧 Value 依然占据内存,且无法被访问到。
结论:set()覆盖并不能解决泄漏问题,因为 Map 的底层数组可能会积累大量无用的 Entry。
三、实战案例:Filter 中的正确姿势
以用户认证为例,我们经常在 Filter 中解析 Token。
错误示范(高风险)
public class SSOAuthFilter extends OncePerRequestFilter {
public static final ThreadLocal<String> tokenThreadLocal = new ThreadLocal<>();
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws IOException, ServletException {
tokenThreadLocal.set(request.getHeader("Token"));
filterChain.doFilter(request, response);
// 没有清理!
}
}正确示范(生产级)
必须在 finally中清理。
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws IOException, ServletException {
try {
String token = request.getHeader("Authorization");
if (token != null) {
AuthContext.set(token); // ThreadLocal.set
}
filterChain.doFilter(request, response);
} finally {
// 无论成功还是异常,必须清理
AuthContext.clear(); // ThreadLocal.remove
}
}四、Spring 声明式事务的秘密
既然 ThreadLocal这么危险,为什么 Spring 还要大量使用它?
而且 Spring 提供了“教科书级”的用法。
当你使用 @Transactional注解时:
@Transactional
public void createOrder() {
orderDao.insert();
paymentDao.pay();
}Spring 在背后做了什么?
开启事务:从连接池拿一个 Connection。
绑定连接:将 Connection 放入 ThreadLocal(TransactionSynchronizationManager)。
执行方法:orderDao和 paymentDao在执行 SQL 时,会先去 ThreadLocal里找 Connection。
找到了:用同一个连接(保证事务)。
找不到:新建连接(无事务)。
提交/回滚:操作同一个 Connection。
清理资源:在 finally中解除绑定(调用 remove())。
这就是为什么 @Transactional方法自调用会失效的原因:绕过了代理,没有把 Connection 放进 ThreadLocal。
五、到底还推不推荐使用?
推荐使用,但要遵守纪律。
适合使用的场景
用户会话信息:用户 ID、Token。
链路追踪:TraceId。
请求级上下文:需要在多层方法间传递,但不想污染方法签名的数据。
禁忌场景
异步线程:@Async、CompletableFuture。
线程池任务:ExecutorService。
最佳实践清单
封装:不要到处 new ThreadLocal(),封装成一个 Context工具类。
静态:ThreadLocal变量必须是 static final,确保只有一个实例。
清理:99% 的情况下,清理动作应该放在 Filter 或 Interceptor 的 finally块中。
重启不是解决方案:低流量服务靠重启掩盖问题,一旦流量上涨,OOM 只是时间问题。
总结
ThreadLocal不是洪水猛兽,它是一个作用域为“线程”的全局变量。
希望这篇文章能帮你搞定 ThreadLocal。如果你在异步场景中也有上下文传递的需求,欢迎在评论区留言,我们下期聊聊 TransmittableThreadLocal。