深入理解 ThreadLocal ------ 在 Spring Boot 中的应用与原理
在 Spring Boot 项目开发中,很多同学会遇到这样的问题:
- 线程是怎么区分的?
- 如何在一个线程中存储多个上下文值?
- 为什么会有内存泄漏风险?
这篇文章我就结合 Spring Boot 和 ThreadLocal,系统回答这几个关键问题。
1. Spring Boot 中线程是怎么区分的?
Spring Boot 默认使用内置的 Tomcat 作为 Web 容器(当然也可以换 Jetty、Undertow)。
Tomcat 内部维护了一个 线程池 (ThreadPoolExecutor
),每个 HTTP 请求到达时,都会从线程池里取一个线程来处理。
常见的线程名格式如下:
http-nio-8080-exec-1
http-nio-8080-exec-2
...
这里的 exec-1
、exec-2
就是线程池中的线程编号。
👉 需要注意的是:
- 线程和请求不是一一对应的。一个请求结束后,线程会被放回线程池,下次可能被复用来处理别的请求。
- 请求的识别不靠线程,而是依赖 HTTP 协议(Header、Cookie、Session、Token 等)来区分不同用户。
2. 在 Spring Boot 中如何使用 ThreadLocal?
2.1 ThreadLocal 的作用
ThreadLocal
提供了一种 线程局部变量 机制,让每个线程都能保存一份独立的数据副本,互不干扰。
在 Spring Boot 的 Web 开发中,我们常用它来保存 请求级别的上下文信息(例如当前用户、TraceId)。
2.2 存一个值
java
public class UserContext {
private static final ThreadLocal<String> userHolder = new ThreadLocal<>();
public static void setUser(String username) {
userHolder.set(username);
}
public static String getUser() {
return userHolder.get();
}
public static void clear() {
userHolder.remove(); // 防止内存泄漏
}
}
在拦截器里解析请求头,设置用户信息:
java
@Component
public class UserInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request,
HttpServletResponse response,
Object handler) {
String username = request.getHeader("X-USER");
if (username != null) {
UserContext.setUser(username);
}
return true;
}
@Override
public void afterCompletion(HttpServletRequest request,
HttpServletResponse response,
Object handler,
Exception ex) {
UserContext.clear();
}
}
Controller 中可以直接获取:
java
@GetMapping("/hello")
public String hello() {
return "Hello " + UserContext.getUser();
}
2.3 存多个值(推荐方法)
方式一:定义多个 ThreadLocal
,每个存一个值。
方式二(推荐):封装一个上下文对象,用一个 ThreadLocal
管理:
java
public class RequestContext {
private String username;
private Integer age;
private String traceId;
// getter / setter ...
}
public class RequestContextHolder {
private static final ThreadLocal<RequestContext> holder = new ThreadLocal<>();
public static void set(RequestContext context) {
holder.set(context);
}
public static RequestContext get() {
return holder.get();
}
public static void clear() {
holder.remove();
}
}
这样可以在拦截器里统一设置,在业务代码里统一获取,非常优雅。
3. ThreadLocal 为什么会内存泄漏?
3.1 数据结构
每个线程对象 Thread
里都维护着一个 ThreadLocalMap
,结构如下:
ThreadLocalMap {
ThreadLocal -> value
}
- key =
ThreadLocal
(弱引用) - value = 存储的对象(强引用)
3.2 内存泄漏的原因
如果我们使用的是全局变量的ThreaLocal,就不会发生内存泄露,只有当我们在一个方法内部new threadlocal时,才会发生key被清理,val游离在内存中,发生内存泄露
-
ThreadLocalMap 的存在
- 每个线程都有自己的
ThreadLocalMap
,存储(key=ThreadLocal, value=你的数据)
。 - 线程长期存活(比如线程池) → ThreadLocalMap 也长期存活。
- 每个线程都有自己的
-
局部 ThreadLocal 的风险
- 如果在方法里
new ThreadLocal()
,方法结束后局部变量消失。 - 如果 ThreadLocalMap 用强引用 key,Entry 里的 key/value 永远存在 → 内存泄漏。
- 弱引用 key → ThreadLocal 对象被 GC 回收,Entry.key 变 null,value 可以在之后惰性清理 → 避免泄漏。
- 如果在方法里
-
全局 ThreadLocal 的情况
- 你把 ThreadLocal 定义成静态全局变量,线程里 ThreadLocalMap 的 key 永远有强引用。
- 线程存活,key/value 也一直存活,但这是正常需求,不会算泄漏。
- 强弱引用对全局 ThreadLocal 没影响。
💡 总结一句话:
弱引用 key 是 防护措施,主要针对方法内部临时创建的 ThreadLocal,避免线程长期持有导致内存泄漏;全局静态 ThreadLocal 不存在泄漏风险,强弱引用无关紧要。
示例:
java
public void test() {
ThreadLocal<User> local = new ThreadLocal<>();
local.set(new User("frank"));
// local 没有引用了,GC 会回收 ThreadLocal 对象
// 但 User("frank") 还强引用在 ThreadLocalMap 里,没法释放
}
3.3 强引用和弱引用的区别
-
强引用(Strong Reference):最常见的引用方式,有强引用的对象不会被 GC。
javaObject obj = new Object();
-
弱引用(Weak Reference):一旦没有强引用,GC 扫描到就会直接回收。
javaWeakReference<Object> weak = new WeakReference<>(new Object());
👉 ThreadLocalMap 的设计就是 key 用弱引用,value 用强引用,所以才有泄漏风险。
3.4 如何避免泄漏
最佳实践:
java
try {
threadLocal.set(user);
// 业务逻辑
} finally {
threadLocal.remove(); // 请求结束后清理
}
Spring 和常见框架(事务、日志 MDC)内部也会在请求结束时自动清理 ThreadLocal。
4. 总结
- 线程区分:Spring Boot 基于 Tomcat 线程池处理请求,每个请求由不同线程执行,但线程会复用,请求区分依靠 Cookie/Session/Token,不靠线程号。
- ThreadLocal 使用:常用于保存请求级别的上下文数据,可以存单值,也可以封装对象存多值。
- 内存泄漏原因:ThreadLocalMap key 是弱引用,value 是强引用,ThreadLocal 被回收但 value 残留,线程长时间存活就会泄漏。
- 解决方案 :用完
remove()
,确保不会残留引用。