在 Spring Boot 项目开发中,很多同学第一次接触 ThreadLocal 时,都会有几个疑惑:
ThreadLocal到底把数据存在哪里?- 为什么一个全局
ThreadLocal对象,可以让每个线程都有自己的值? ThreadLocalMap里面的 key 和 value 到底是谁引用谁?- 为什么
ThreadLocalMap的 key 要设计成弱引用? - 为什么线程池场景下必须调用
remove()?
这篇文章就从最基础的 Java 变量引用关系 开始,逐步讲清楚 ThreadLocal 的底层原理和 Spring Boot 中的实际用法。
1. 先理解 Java 变量和对象的引用关系
在理解 ThreadLocal 之前,必须先搞懂一件事:
java
void test() {
User a = new User();
}
这段代码里面,到底是谁引用了谁?
1.1 new User() 在哪里?
new User() 会在 堆内存 中创建一个 User 对象。
java
User a = new User();
可以粗略理解成两步:
java
User对象 = new User(); // 在堆中创建对象
User a = User对象的引用; // 在栈中创建局部变量 a,保存对象引用
执行过程中,内存关系大概是这样:
text
栈内存 堆内存
┌──────────────┐ ┌──────────────┐
│ test 栈帧 │ │ User对象 │
│ │ │ │
│ a ───────────┼─────────────>│ new User() │
└──────────────┘ └──────────────┘
也就是说:
是局部变量
a引用了堆中的User对象。不是
User对象引用了变量a。
1.2 a 是不是指针?
在 Java 里面,一般不说"指针",而是说 引用。
但是为了初学时理解,你可以把它近似理解成:
a是一个"安全版指针",里面保存着找到堆对象的信息。
它和 C/C++ 指针不同,Java 不允许你直接操作内存地址,比如不能做:
java
a + 1;
所以准确说法是:
text
a 是引用变量,不是传统意义上的指针。
1.3 方法结束后,对象会立刻销毁吗?
不会。
比如:
java
void test() {
User a = new User();
return;
}
方法执行时:
text
栈内存 堆内存
┌──────────────┐ ┌──────────────┐
│ test 栈帧 │ │ User对象 │
│ a ───────────┼─────────────>│ │
└──────────────┘ └──────────────┘
方法结束后,test 方法对应的栈帧销毁,局部变量 a 也销毁:
text
栈内存 堆内存
┌──────────────┐ ┌──────────────┐
│ test 栈帧消失 │ │ User对象 │
│ a 消失 │ │ 暂时还在 │
└──────────────┘ └──────────────┘
此时堆中的 User 对象如果没有其他引用指向它,就会变成 垃圾对象。
注意:
方法结束后,局部变量
a会立刻消失;但是堆中的
User对象不是立刻销毁,而是等待 GC 回收。
可以总结为:
text
方法执行中:
a ─────────> User对象
方法结束后:
a 消失
User对象没有引用,等待 GC 回收
1.4 什么叫强引用?
平时最常见的引用就是强引用:
java
User a = new User();
关系是:
text
a ─── 强引用 ───> User对象
只要 a 还存在,并且程序还能通过 a 找到这个对象,那么 GC 就不会回收这个 User 对象。
也就是说:
text
强引用的意思是:只要我还指着你,GC 就不能回收你。
1.5 什么叫弱引用?
弱引用需要用 WeakReference 包一层:
java
WeakReference<User> weakRef = new WeakReference<>(new User());
关系是:
text
weakRef ─── 弱引用 ───> User对象
弱引用的特点是:
虽然弱引用也能找到对象,但是它不能阻止 GC 回收对象。
如果一个对象只剩弱引用,没有任何强引用,那么 GC 看到它时,就可以直接回收。
例如:
java
User a = new User();
WeakReference<User> weakRef = new WeakReference<>(a);
此时关系是:
text
a ─── 强引用 ───> User对象
weakRef ─── 弱引用 ───> User对象
这时 User对象 不会被回收,因为它还有强引用 a。
如果执行:
java
a = null;
关系就变成:
text
a 不再指向 User对象
weakRef ─── 弱引用 ───> User对象
此时 User对象 只剩弱引用,下一次 GC 时就可以被回收。
回收之后:
java
weakRef.get(); // null
2. ThreadLocal 是什么?
ThreadLocal 是一种 线程局部变量机制。
它的作用是:
让每个线程都拥有一份独立的数据,线程之间互不影响。
但是一定要注意:
ThreadLocal对象本身通常不直接保存业务值。真正的 value 是存在当前线程
Thread对象里的ThreadLocalMap中。
3. ThreadLocal 的真实存储结构
很多人会误以为 ThreadLocal 是这样存的:
text
ThreadLocal对象
├── Thread-1 -> frank
├── Thread-2 -> tom
└── Thread-3 -> jack
但实际上不是。
真实结构更像这样:
text
Thread-1
└── ThreadLocalMap
└── userHolder -> frank
Thread-2
└── ThreadLocalMap
└── userHolder -> tom
Thread-3
└── ThreadLocalMap
└── userHolder -> jack
也就是说:
每个线程对象
Thread里面都有自己的ThreadLocalMap。
ThreadLocal对象只是这个 Map 里的 key。你存进去的值,是 Map 里的 value。
4. 全局 ThreadLocal 和每个线程自己的值,为什么不矛盾?
比如我们定义一个全局 ThreadLocal:
java
public class UserContext {
private static final ThreadLocal<String> userHolder = new ThreadLocal<>();
}
这里的 userHolder 的确是一个全局静态对象,整个 JVM 里面通常只有这一份。
但是每个线程都有自己的 ThreadLocalMap。
所以结构是:
text
全局静态变量:
UserContext.userHolder
│
│ 强引用
v
ThreadLocal对象
不同线程里面:
text
Thread-A
└── ThreadLocalMap
└── key: userHolder -> value: "frank"
Thread-B
└── ThreadLocalMap
└── key: userHolder -> value: "tom"
所以不矛盾。
可以这样理解:
ThreadLocal是同一把钥匙;每个线程都有自己的柜子;
同一把钥匙去不同柜子里开出来的格子,里面可以放不同的值。
5. ThreadLocal 的 set 和 get 底层大概做了什么?
假设你写:
java
userHolder.set("frank");
底层近似逻辑是:
java
Thread currentThread = Thread.currentThread();
ThreadLocalMap map = currentThread.threadLocals;
if (map == null) {
map = new ThreadLocalMap();
currentThread.threadLocals = map;
}
map.set(userHolder, "frank");
重点是:
text
不是 userHolder 自己保存了 frank;
而是当前线程的 ThreadLocalMap 保存了:
userHolder -> frank
再看 get():
java
userHolder.get();
底层近似逻辑:
java
Thread currentThread = Thread.currentThread();
ThreadLocalMap map = currentThread.threadLocals;
return map.get(userHolder);
所以核心模型是:
text
当前线程.threadLocalMap.get(当前 ThreadLocal 对象)
6. Spring Boot 中为什么常用 ThreadLocal?
在 Spring Boot Web 项目中,一个 HTTP 请求通常由 Tomcat 线程池中的某个线程处理。
常见线程名类似:
text
http-nio-8080-exec-1
http-nio-8080-exec-2
http-nio-8080-exec-3
Tomcat 线程池的特点是:
请求结束后,线程不会销毁,而是回到线程池,等待处理下一次请求。
因此,ThreadLocal 常用于保存请求级别上下文,比如:
- 当前登录用户
- 当前请求 TraceId
- 租户 ID
- 请求链路上下文
- 日志 MDC 信息
7. Spring Boot 中使用 ThreadLocal 示例
7.1 定义上下文对象
如果只保存一个值,可以直接用一个 ThreadLocal。
但实际项目中,通常推荐封装一个上下文对象:
java
public class RequestContext {
private String username;
private String traceId;
private Long userId;
public String getUsername() {
return username;
}
public void setUsername(String username) {
this.username = username;
}
public String getTraceId() {
return traceId;
}
public void setTraceId(String traceId) {
this.traceId = traceId;
}
public Long getUserId() {
return userId;
}
public void setUserId(Long userId) {
this.userId = userId;
}
}
然后定义 RequestContextHolder:
java
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 String getUsername() {
RequestContext context = HOLDER.get();
return context == null ? null : context.getUsername();
}
public static String getTraceId() {
RequestContext context = HOLDER.get();
return context == null ? null : context.getTraceId();
}
public static void clear() {
HOLDER.remove();
}
}
7.2 在拦截器中设置上下文
java
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.util.UUID;
@Component
public class UserInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request,
HttpServletResponse response,
Object handler) {
String username = request.getHeader("X-USER");
String traceId = request.getHeader("X-TRACE-ID");
if (traceId == null || traceId.isBlank()) {
traceId = UUID.randomUUID().toString().replace("-", "");
}
RequestContext context = new RequestContext();
context.setUsername(username);
context.setTraceId(traceId);
RequestContextHolder.set(context);
return true;
}
@Override
public void afterCompletion(HttpServletRequest request,
HttpServletResponse response,
Object handler,
Exception ex) {
RequestContextHolder.clear();
}
}
Controller 里就可以直接获取:
java
@GetMapping("/hello")
public String hello() {
return "Hello " + RequestContextHolder.getUsername()
+ ", traceId = " + RequestContextHolder.getTraceId();
}
8. ThreadLocal 里面的引用关系到底是什么样的?
这是理解内存泄漏的重点。
ThreadLocalMap 里面的 Entry 大概类似这样:
java
static class Entry extends WeakReference<ThreadLocal<?>> {
Object value;
}
也就是说:
text
Entry.key 是弱引用,指向 ThreadLocal 对象
Entry.value 是强引用,指向你存进去的业务对象
比如:
java
private static final ThreadLocal<User> userHolder = new ThreadLocal<>();
userHolder.set(new User("frank"));
内存引用关系大概是:
text
UserContext 类
└── static userHolder
│
│ 强引用
v
ThreadLocal对象
当前线程 Thread
└── ThreadLocalMap
└── Entry
├── key ──弱引用──> ThreadLocal对象
└── value ─强引用──> User("frank")
画成完整图:
text
┌────────────────────┐
│ UserContext 类 │
│ static userHolder │
└─────────┬──────────┘
│ 强引用
v
┌────────────────────┐
│ ThreadLocal 对象 │
└─────────▲──────────┘
│ 弱引用
┌─────────┴──────────┐
│ Entry.key │
│ Entry.value ────────┼──── 强引用 ────> User("frank")
└────────────────────┘
需要注意:
ThreadLocalMap.Entry.key是弱引用。
ThreadLocalMap.Entry.value是强引用。
9. 为什么 ThreadLocalMap 的 key 要设计成弱引用?
假设 key 是强引用。
代码:
java
public void test() {
ThreadLocal<User> local = new ThreadLocal<>();
local.set(new User("frank"));
}
方法执行时:
text
栈内存
┌──────────────┐
│ local │
└──────┬───────┘
│ 强引用
v
ThreadLocal对象
当前线程 Thread
└── ThreadLocalMap
└── Entry
├── key ─强引用─> ThreadLocal对象
└── value ─强引用─> User("frank")
方法结束后,局部变量 local 消失。
如果 Entry 的 key 是强引用,那么引用关系仍然是:
text
当前线程 Thread
└── ThreadLocalMap
└── Entry
├── key ─强引用─> ThreadLocal对象
└── value ─强引用─> User("frank")
只要线程不死,ThreadLocalMap 就不死,Entry 就不死。
结果就是:
text
ThreadLocal对象 回收不了
User("frank") 也回收不了
这就很危险。
所以 JDK 把 key 设计成弱引用。
真实情况是:
text
当前线程 Thread
└── ThreadLocalMap
└── Entry
├── key ─弱引用─> ThreadLocal对象
└── value ─强引用─> User("frank")
方法结束后,局部变量 local 消失:
text
local 消失
Entry.key ─弱引用─> ThreadLocal对象
Entry.value ─强引用─> User("frank")
此时 ThreadLocal对象 只剩弱引用。
GC 看到这个 ThreadLocal对象 没有强引用了,就可以回收它。
GC 之后:
text
当前线程 Thread
└── ThreadLocalMap
└── Entry
├── key = null
└── value ─强引用─> User("frank")
所以,key 设计成弱引用的目的主要是:
避免 ThreadLocalMap 强行持有已经不用的 ThreadLocal 对象,导致 ThreadLocal 对象无法被回收。
10. key 是弱引用,为什么还会有内存泄漏?
因为 value 仍然是强引用。
GC 之后可能变成这样:
text
当前线程 Thread
└── ThreadLocalMap
└── Entry
├── key = null
└── value ─强引用─> User("frank")
虽然 ThreadLocal对象 被回收了,但是 User("frank") 还被 Entry 的 value 强引用着。
只要当前线程一直活着,这个 value 就可能一直活着。
在线程池场景下,线程通常不会频繁销毁。
所以就可能出现:
text
Tomcat 线程池线程长期存活
-> Thread 持有 ThreadLocalMap
-> ThreadLocalMap 持有 Entry
-> Entry.value 强引用业务对象
-> 业务对象无法释放
这就是 ThreadLocal 的内存泄漏风险。
11. static ThreadLocal 就不会泄漏吗?
这个说法不严谨。
比如:
java
private static final ThreadLocal<User> userHolder = new ThreadLocal<>();
它确实有一个静态变量强引用 ThreadLocal对象:
text
UserContext.static userHolder ─强引用─> ThreadLocal对象
所以它不容易出现:
text
Entry.key = null
这种情况。
但是如果你不调用 remove(),value 仍然会残留在线程里面:
text
Tomcat工作线程 Thread
└── ThreadLocalMap
└── Entry
├── key ─弱引用─> static ThreadLocal对象
└── value ─强引用─> 上一次请求的 User对象
请求结束后,如果没有清理:
text
ThreadLocalMap 里面还保存着上一次请求的用户信息
下一次这个线程被复用处理别的请求时,就可能读到上一次请求的数据。
这就是脏数据问题。
所以准确说法是:
static ThreadLocal 不太容易出现 key 被 GC 后变成 null 的问题,
但它仍然可能因为线程池线程复用,导致 value 残留和脏数据。
所以用完仍然必须 remove。
12. remove() 到底做了什么?
当你调用:
java
userHolder.remove();
本质上就是从当前线程的 ThreadLocalMap 里面删除这个 Entry。
删除前:
text
当前线程 Thread
└── ThreadLocalMap
└── Entry
├── key ─弱引用─> userHolder
└── value ─强引用─> User("frank")
删除后:
text
当前线程 Thread
└── ThreadLocalMap
└── Entry 被清理
这样 User("frank") 就不再被当前线程持有。
如果没有其他引用指向它,它就可以等待 GC 回收。
所以在 Spring Boot 请求中,推荐写法是:
java
try {
RequestContextHolder.set(context);
// 执行业务逻辑
} finally {
RequestContextHolder.clear();
}
在拦截器中,则通常放到 afterCompletion() 里面清理:
java
@Override
public void afterCompletion(HttpServletRequest request,
HttpServletResponse response,
Object handler,
Exception ex) {
RequestContextHolder.clear();
}
13. ThreadLocalMap 为什么会有哈希冲突?
很多人会疑惑:
ThreadLocalMap 不是一个 Map 吗?
每个 key、value 不应该都有固定位置吗?
为什么还会哈希冲突?
其实 Map 底层通常是数组。
ThreadLocalMap 底层也是一个数组:
java
Entry[] table;
假设数组长度是 16:
text
下标: 0 1 2 3 4 5 6 7 ... 15
数组:[ ] [ ] [ ] [ ] [ ] [ ] [ ] [ ] ... [ ]
一个 ThreadLocal 对象要放到哪个位置,需要先算 hash:
java
index = threadLocalHashCode & (table.length - 1);
比如:
text
userHolder 算出来 index = 4
traceHolder 算出来 index = 4
两个不同的 ThreadLocal 对象,都想放到下标 4,这就是哈希冲突。
14. ThreadLocalMap 如何解决哈希冲突?
普通 HashMap 会用链表或者红黑树处理冲突。
但是 ThreadLocalMap 使用的是 开放寻址法 ,更具体一点是 线性探测法。
什么意思?
如果目标位置被占了,就往后找下一个位置。
比如:
text
原本想放 index = 4
下标: 0 1 2 3 4 5 6
数组:[ ] [ ] [ ] [ ] [key1] [key2] [ ]
现在 key3 也算出来要放下标 4。
但是 4 被占了,就往后找:
text
4 被占了
5 被占了
6 空着
放到 6
结果:
text
下标: 0 1 2 3 4 5 6
数组:[ ] [ ] [ ] [ ] [key1] [key2] [key3]
查找的时候也一样。
如果要找 key3,它本来算出来的位置是 4。
底层会这样找:
text
table[4] 不是 key3
table[5] 不是 key3
table[6] 是 key3,找到了
所以,线性探测在冲突比较多时,确实会变慢。
不过在正常业务中,一个线程里通常不会放特别多 ThreadLocal,所以性能一般不是主要问题。
真正需要注意的是:
text
线程池场景下,用完必须 remove。
15. ThreadLocal 在 Spring Boot 中的完整生命周期图
一次请求开始:
text
HTTP请求进入
│
v
Tomcat线程池分配线程
│
v
http-nio-8080-exec-1
│
v
拦截器 preHandle()
│
v
RequestContextHolder.set(context)
此时:
text
http-nio-8080-exec-1
└── ThreadLocalMap
└── Entry
├── key ─弱引用─> RequestContextHolder.HOLDER
└── value ─强引用─> RequestContext(username, traceId)
业务代码中:
java
RequestContextHolder.getUsername();
底层是:
text
当前线程.threadLocalMap.get(HOLDER)
请求结束:
text
Controller 执行完成
│
v
拦截器 afterCompletion()
│
v
RequestContextHolder.clear()
│
v
ThreadLocalMap 中对应 Entry 被清理
清理后:
text
http-nio-8080-exec-1
└── ThreadLocalMap
└── 不再保存本次请求的 RequestContext
线程回到线程池,等待处理下一次请求。
16. 总结
16.1 Java 引用关系
java
User a = new User();
含义是:
text
局部变量 a 强引用堆中的 User对象
方法结束后:
text
a 消失
如果 User对象没有其他引用,就等待 GC 回收
16.2 ThreadLocal 的真实模型
不要理解成:
text
ThreadLocal 保存了所有线程的值
而要理解成:
text
每个 Thread 里面有自己的 ThreadLocalMap
ThreadLocal 对象只是这个 Map 的 key
value 存在当前线程自己的 Map 里
16.3 ThreadLocalMap 的引用关系
text
Thread
└── ThreadLocalMap
└── Entry
├── key ─弱引用─> ThreadLocal对象
└── value ─强引用─> 业务对象
16.4 key 为什么是弱引用?
为了避免:
text
Thread -> ThreadLocalMap -> Entry.key -> ThreadLocal对象
这条强引用链导致已经不用的 ThreadLocal对象 无法被回收。
16.5 为什么还要 remove?
因为 value 是强引用。
如果不清理:
text
Thread -> ThreadLocalMap -> Entry.value -> 业务对象
这条引用链仍然可能让业务对象长期存活。
尤其在 Spring Boot / Tomcat 线程池场景下,线程会被复用,不清理还可能造成脏数据。
17. 最后记住一句话
ThreadLocal是 key,不是仓库;
Thread才是仓库;
ThreadLocalMap是仓库里的货架;
Entry.key弱引用ThreadLocal;
Entry.value强引用业务数据;Spring Boot 线程池会复用线程,所以请求结束一定要
remove()。