TheadLocal专题一
那我把 ThreadLocal 的底层结构、弱引用原因、线程池为什么必须 remove() 一次讲透,你面试时基本就够用了。
1. ThreadLocal 的整体结构图
先看最核心的关系:
Thread
└── ThreadLocalMap threadLocals
├── Entry(ThreadLocalA, valueA)
├── Entry(ThreadLocalB, valueB)
└── Entry(ThreadLocalC, valueC)
注意:
- 每个线程 Thread 自己内部都有一个
ThreadLocalMap ThreadLocal对象本身并不存值- 真正的值是存在 当前线程的
ThreadLocalMap里
所以 ThreadLocal 更像一个“操作当前线程局部变量的句柄 / key”。
2. set/get 到底发生了什么
2.1 set()
例如:
private static final ThreadLocal<String> TL = new ThreadLocal<>();
TL.set("hello");
大致流程:
第一步:拿到当前线程
Thread t = Thread.currentThread();
第二步:拿到当前线程里的 ThreadLocalMap
ThreadLocalMap map = t.threadLocals;
第三步:以当前 ThreadLocal 作为 key,存 value
map.set(TL, "hello");
所以本质是:
当前线程.threadLocals.put(TL, "hello")
2.2 get()
String v = TL.get();
大致流程:
- 拿到当前线程
Thread.currentThread() - 拿到线程内部的
threadLocals - 用当前
TL作为 key 去查 value
也就是:
value = 当前线程.threadLocals.get(TL)
3. 一个线程里可以有多个 ThreadLocal
比如:
ThreadLocal<Long> userIdTL = new ThreadLocal<>();
ThreadLocal<String> traceIdTL = new ThreadLocal<>();
ThreadLocal<String> tenantTL = new ThreadLocal<>();
userIdTL.set(1001L);
traceIdTL.set("trace-001");
tenantTL.set("tenant-A");
那么当前线程的 ThreadLocalMap 可能就是:
ThreadLocalMap
userIdTL -> 1001L
traceIdTL -> "trace-001"
tenantTL -> "tenant-A"
所以你可以把它理解为:
每个线程有一个自己的 Map,Map 的 key 是 ThreadLocal 对象,value 是线程独享的数据。
4. ThreadLocalMap 的 Entry 为什么是弱引用
这是 ThreadLocal 最容易被问的点。
ThreadLocalMap 里的每个元素大致长这样:
static class Entry extends WeakReference<ThreadLocal<?>> {
Object value;
}
也就是说:
- Entry 的 key = 弱引用的 ThreadLocal
- Entry 的 value = 你存进去的对象
可以理解成:
Entry {
weakReference(ThreadLocal<?> key)
Object value
}
5. 为什么 key 要设计成弱引用
先说结论:
为了避免 ThreadLocal 对象本身不用了以后,因为被 ThreadLocalMap 强引用着,导致它无法被 GC。
5.1 如果 key 是强引用,会怎样?
假设:
public void test() {
ThreadLocal<byte[]> tl = new ThreadLocal<>();
tl.set(new byte[10 * 1024 * 1024]); // 10MB
}
执行完 test() 后,局部变量 tl 已经没了,按理说这个 ThreadLocal 对象应该可以回收。
但是如果 ThreadLocalMap.Entry 里对 key 是强引用,那会变成:
当前线程 Thread
-> threadLocals
-> Entry
-> key(ThreadLocal对象)
-> value(10MB数组)
由于 Thread -> threadLocals -> Entry -> key 这条引用链还在,ThreadLocal 对象就回收不了。
所以 JDK 把 key 设计成弱引用。
5.2 弱引用之后会怎样?
如果外部没有强引用再指向这个 ThreadLocal 对象,比如方法执行完后 tl 消失了,那么下次 GC 时:
ThreadLocal key会被回收Entry.get()会变成null
于是这个 Entry 会变成这样:
Entry {
key = null
value = 10MB数组
}
这种 Entry 就叫 stale entry(过期条目)。
6. 这里为什么还会内存泄漏?
这也是最关键的点:
key 被回收了,不代表 value 自动就没了。
此时结构是:
Thread
└── ThreadLocalMap
└── Entry
├── key = null
└── value = 10MB数组
注意:
key没了- 但
value还被Entry强引用 Entry又被ThreadLocalMap引用ThreadLocalMap又被Thread引用
所以只要线程还活着,这个 value 就还活着,GC 回收不掉。
这就是很多人说的:
ThreadLocal 会内存泄漏
更准确地说应该是:
ThreadLocal 使用不当时,ThreadLocalMap 中 key 已失效但 value 仍存活,在线程长期不销毁的情况下会造成内存泄漏。
7. 为什么在线程池里尤其危险
因为普通线程执行完任务可能就结束了,线程一死,整个 Thread -> threadLocals 都会被回收,问题没那么大。
但线程池线程不会轻易销毁,会被反复复用。
比如:
static ThreadLocal<String> TL = new ThreadLocal<>();
executor.submit(() -> {
TL.set("用户A的数据");
});
如果你没 remove(),线程执行完任务后,这个线程回到线程池,并不会死。
那么线程内部还可能留着:
线程池线程1:
TL -> "用户A的数据"
后面另一个任务又复用了这个线程:
executor.submit(() -> {
System.out.println(TL.get());
});
这时就可能读到上一个任务残留的数据。
所以线程池里 ThreadLocal 有两个风险:
7.1 风险一:内存泄漏
旧 value 一直挂在线程上,线程不销毁,就一直占内存。
7.2 风险二:脏数据 / 数据串线程任务
线程池线程被复用,后续任务可能读到上个任务遗留的数据。
这在下面这些场景特别危险:
- 用户登录信息
userId - 租户信息
tenantId - traceId
- 数据源切换标记
- 权限上下文
如果不清理,可能直接串用户、串租户。
8. 所以为什么必须 remove()
标准写法就是:
private static final ThreadLocal<String> TL = new ThreadLocal<>();
public void doBiz() {
try {
TL.set("some value");
// 业务逻辑
} finally {
TL.remove();
}
}
为什么要放在 finally:
- 正常执行完,要清理
- 中途抛异常,也要清理
这样才能保证线程被线程池复用时是干净的。
9. remove() 到底做了什么
remove() 本质上是把当前线程 ThreadLocalMap 中以当前 ThreadLocal 为 key 的那条记录删掉。
也就是:
当前线程.threadLocals.remove(当前ThreadLocal)
删掉后:
- 这个 Entry 没了
- value 没有强引用链了
- 后续就能被 GC
10. 一个完整的生产例子:登录用户上下文
10.1 定义上下文
public class UserContext {
private static final ThreadLocal<Long> USER_ID_HOLDER = new ThreadLocal<>();
public static void setUserId(Long userId) {
USER_ID_HOLDER.set(userId);
}
public static Long getUserId() {
return USER_ID_HOLDER.get();
}
public static void clear() {
USER_ID_HOLDER.remove();
}
}
10.2 在拦截器里设置
public class LoginInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request,
HttpServletResponse response,
Object handler) {
Long userId = 1001L; // 假设从 token 解析出来
UserContext.setUserId(userId);
return true;
}
@Override
public void afterCompletion(HttpServletRequest request,
HttpServletResponse response,
Object handler,
Exception ex) {
UserContext.clear();
}
}
业务代码中:
Long userId = UserContext.getUserId();
这样就能在同一个请求线程里随处拿到用户信息。
11. 面试时最标准的一套回答
你可以按这个逻辑说:
11.1 ThreadLocal 是什么
ThreadLocal 用来给每个线程提供独立副本,做到线程隔离。
不同线程访问同一个 ThreadLocal,拿到的是各自线程里的值,互不影响。
11.2 底层实现
底层并不是 ThreadLocal 自己存值,而是:
-
每个
Thread内部有一个ThreadLocalMap -
ThreadLocalMap的 key 是ThreadLocal对象 -
value 是通过
set()存进去的值
所以:
threadLocal.set(x)
本质是:
Thread.currentThread().threadLocals.put(threadLocal, x)
11.3 为什么 key 是弱引用
ThreadLocalMap.Entry 继承了 WeakReference<ThreadLocal<?>>,也就是 key 是弱引用。
这样当外部没有强引用指向某个 ThreadLocal 时,GC 可以回收这个 key,避免 ThreadLocal 对象本身无法回收。
11.4 为什么还会内存泄漏
因为 key 被 GC 回收后,Entry 里可能变成:
- key = null
- value 还在
而 value 仍然被 Thread -> ThreadLocalMap -> Entry 引用着。
如果线程长期存活,尤其在线程池中,这些 value 就可能一直无法释放,从而导致内存泄漏。
11.5 为什么线程池必须 remove
线程池线程会复用,如果不 remove():
- value 可能长期挂在线程上,导致内存泄漏
- 下一个任务复用这个线程时,可能读到上一个任务遗留的数据,造成脏数据问题
所以正确写法是:
try {
threadLocal.set(value);
// 业务逻辑
} finally {
threadLocal.remove();
}
12. 你可以这样理解 ThreadLocal 的本质
我给你一个最容易记住的类比:
ThreadLocal 不是“把值存在线程变量里”
而是:
给每个线程发一个私有的 Map 柜子,ThreadLocal 对象就是柜子的钥匙编号。
- 线程 = 一个员工
Thread.threadLocals= 这个员工自己的储物柜ThreadLocal= 某个抽屉的标签- value = 抽屉里的东西
所以不同员工(线程)即使用同一个标签(同一个 ThreadLocal),打开的也是自己柜子里的那份东西。
13. 最后给你一个面试加分点:ThreadLocal 适用场景
典型场景:
1)保存用户上下文
- userId
- 登录用户信息
- 权限信息
2)保存链路信息
- traceId
- requestId
3)保存租户信息
- tenantId
4)数据库动态数据源切换
- 在 AOP 中把数据源标识放入 ThreadLocal
5)SimpleDateFormat 老版本线程安全问题
以前有人会给每个线程一个自己的 SimpleDateFormat
如果你愿意,我下一步可以继续给你讲 “ThreadLocalMap 为什么要用开放寻址法而不是链表法”,这个是很多 Java 面试官特别爱追问的点,我可以直接按面试问答模式给你讲。
浙公网安备 33010602011771号