ThreadLocal 是并发里非常"容易写对、也非常容易埋坑"的东西。
它表面上像"线程级变量",但工程上你真正要理解的是:
- ThreadLocal 并不是把值存在 ThreadLocal 里,而是存在 Thread 的 ThreadLocalMap 里
- Key 是 ThreadLocal 的弱引用,Value 是强引用
- 在线程池复用场景下,如果不 remove,就会出现"跨请求数据污染"和"类内存泄漏"
这篇按"原理主线 -> 事故场景 -> 排查与修复"的结构讲清楚。
1. 一句话结论:ThreadLocal 解决的是"线程上下文传递"
典型用途:
- TraceId / MDC 日志上下文
- 当前用户信息(仅限内部调用链路)
- 临时缓存(谨慎)
不适合:
- 作为跨线程共享状态(ThreadLocal 天然做不到跨线程)
- 作为全局缓存(线程池下会变成隐性全局变量)
2. 数据到底存在哪里:Thread -> ThreadLocalMap
关键关系:
- 每个
Thread都有一个ThreadLocalMap ThreadLocal.set(value)实际是把(ThreadLocal -> value)放进当前线程的 MapThreadLocal.get()从当前线程的 Map 里取
所以 ThreadLocal 的本质是:
- 以线程为维度的 Map
3. Entry 为啥是弱引用:避免 ThreadLocal 本身泄漏
ThreadLocalMap 的 Entry 结构可以记成:
- Key:ThreadLocal 的弱引用(WeakReference)
- Value:强引用
为什么 Key 要弱引用:
- 如果业务代码丢失了 ThreadLocal 的强引用(例如 ThreadLocal 是临时 new 出来的局部变量),弱引用 key 可以被 GC 回收
- 这避免了"ThreadLocal 对象本身"无法回收
但是注意:
- key 被 GC 回收 != value 立刻回收
因为 value 仍然被 ThreadLocalMap 强引用着。
4. ThreadLocal 内存泄漏到底泄漏的是什么
工程里说 ThreadLocal 内存泄漏,一般指:
- key 变成了
null(ThreadLocal 被 GC) - value 仍然在 ThreadLocalMap 里强引用着
- 线程不结束(线程池线程长期存在)
- value 长期无法回收
这类 Entry 常被称为:
- stale entry(陈旧条目)
ThreadLocalMap 的实现会在 get/set/remove 时做一部分清理,但它不是"全自动兜底",所以工程上你必须养成 remove 习惯。
5. 最危险的坑:线程池复用导致"数据串号/脏读"
线程池的线程会复用:
- 请求 A 在某个线程里 set 了 ThreadLocal(比如 userId)
- 请求 A 结束没 remove
- 同一个线程被请求 B 复用
- 请求 B get 到了 A 的 userId
这类问题最难排查,因为:
- 不是每次发生
- 看起来像"偶发脏数据"
6. 正确姿势:try-finally remove
最简单的模板:
java
public class Context {
private static final ThreadLocal<String> TRACE_ID = new ThreadLocal<>();
public static void setTraceId(String v) {
TRACE_ID.set(v);
}
public static String getTraceId() {
return TRACE_ID.get();
}
public static void clear() {
TRACE_ID.remove();
}
}
// 使用处:
try {
Context.setTraceId(traceId);
// do business
} finally {
Context.clear();
}
建议把 clear 放到:
- Web Filter / Spring Interceptor 的 finally
- 线程池任务包装器(Runnable/Callable wrapper)
7. 在线程池里更稳的做法:包装任务
当你把 ThreadLocal 用在异步线程池时,最危险的是:
- 父线程 set 了上下文
- 子线程拿不到(ThreadLocal 不传递)
- 或者子线程 set 了上下文但不清理
建议做法:
- 统一包装 Runnable/Callable:执行前 set,执行后 finally remove
(如果你需要自动传递上下文,可以用可传递的上下文方案,但要注意线程池复用仍然需要清理。)
8. 线上定位:怎么判断是不是 ThreadLocal 问题
8.1 症状
- 偶发"用户串号/权限串号"
- 日志 traceId 乱跳
- 堆内存增长、Full GC 增多,但对象引用链很隐蔽
8.2 排查路线
- 先判断是否线程池复用:是否存在自建线程池/异步执行
- 检查是否 finally remove:尤其是异常路径是否能走到
- 用 MAT 看引用链:如果怀疑是 stale entry,通常会看到 value 被 ThreadLocalMap 引用
8.3 实用建议
- ThreadLocal 尽量使用
static final,避免临时 new - value 尽量轻量(字符串、id),不要塞大对象/大集合
9. 面试表达(30 秒讲清楚)
- ThreadLocal 的值不在 ThreadLocal 上,而在 Thread 的 ThreadLocalMap 里。
- Entry 的 key 是 ThreadLocal 弱引用,value 是强引用;key 被回收后 value 可能残留。
- 在线程池复用场景下,如果不 remove,可能出现内存泄漏和跨请求数据污染。
- 正确姿势是 try-finally remove,并把清理放在 Filter/Interceptor 或任务包装器里。
10. 总结
- ThreadLocal 是线程上下文工具,不是跨线程共享
- 线程池复用是最大风险点:一定 remove
- 排查思路:看线程池复用 + finally 清理 + MAT 引用链