一、是什么?作用
ThreadLocal 是线程本地存储工具 ,可以给当前线程单独绑定私有变量副本,多个线程之间完全隔离、互不干扰。
- 每个线程独有一份数据,其他线程读不到;
- 不用加锁就能实现线程安全,规避并发争抢;
- 典型用途:在线程生命周期内,传递上下文信息(登录用户、traceId、数据源标记)。
直观例子
java
ThreadLocal<String> tl = new ThreadLocal<>();
// 线程1设置
new Thread(() -> {
tl.set("用户A");
System.out.println(tl.get()); // 用户A
}).start();
// 线程2设置,互不影响
new Thread(() -> {
tl.set("用户B");
System.out.println(tl.get()); // 用户B
}).start();
同一个ThreadLocal对象,不同线程set/get互不干扰,无并发竞争。
二、底层存储结构
1. 存储位置
数据不存放在 ThreadLocal 对象里 ,而是存在 Thread 对象内部的 ThreadLocalMap:
- 每个
Thread实例自带:ThreadLocal.ThreadLocalMap threadLocals ThreadLocalMap内部是Entry[]数组Entry键:弱引用ThreadLocal<?>对象Entry值:真正存储的业务数据
- 调用
tl.set(value):
获取当前线程Thread→ 拿到它的ThreadLocalMap→ 以当前tl为key存入value
结构图简化:
Thread-1
└─ threadLocals(ThreadLocalMap)
├─ Entry(key=tl1, value=用户信息)
└─ Entry(key=tl2, value=traceId)
Thread-2
└─ threadLocals(ThreadLocalMap)
├─ Entry(key=tl1, value=另一个用户)
2. get/set/remove 源码极简流程
set(T value)
拿到当前线程Thread → 获取map,map不存在则新建;map以当前ThreadLocal为key存入valueget()
当前线程获取map,根据tl对象key查找Entry,返回value;无数据返回nullremove()
当前线程map中删除该tl对应的Entry,主动释放内存(必做)
三、典型实战场景
场景1:全局传递登录用户上下文(Spring拦截器)
不用每层接口、service、mapper层层传参,线程内任意位置获取当前登录人。
java
// 1. 全局上下文工具类
public class UserContextHolder {
private static final ThreadLocal<LoginUser> USER_TL = new ThreadLocal<>();
// 设置
public static void setUser(LoginUser user) {
USER_TL.set(user);
}
// 获取
public static LoginUser getUser() {
return USER_TL.get();
}
// 移除!关键
public static void clear() {
USER_TL.remove();
}
}
拦截器解析token,存入上下文:
java
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
String token = request.getHeader("token");
LoginUser user = parseToken(token);
UserContextHolder.setUser(user);
return true;
}
// 接口、Service任意处直接获取,不用传参
@GetMapping("/info")
public Result info() {
LoginUser user = UserContextHolder.getUser();
return Result.success(user);
}
// 后置处理器必须清理
@Override
public void afterCompletion(...) {
UserContextHolder.clear();
}
场景2:线程内链路追踪 TraceId 透传
MDC底层就是封装了ThreadLocal,打印日志自动带上链路ID,不用每层传递。
场景3:多数据源动态切换标记
同一线程内标记要使用的数据源,DAO层直接读取,不用参数传递。
场景4:SimpleDateFormat 线程不安全解决方案
SimpleDateFormat 非线程安全,不能static共用;
给每个线程单独实例一份,用ThreadLocal隔离:
java
private static final ThreadLocal<SimpleDateFormat> DATE_TL = ThreadLocal.withInitial(
() -> new SimpleDateFormat("yyyy-MM-dd HH:mm:ss")
);
四、关键问题:内存泄漏(高频考点)
1. 为什么会内存泄漏?
ThreadLocalMap.Entry的key是弱引用(WeakReference),JVM GC时,ThreadLocal对象会被回收,key变成null;- 但Entry的value是强引用,不会自动释放;
- 线程池复用线程(核心线程常驻),线程不会销毁,Entry(key=null, value=xxx)永远残留在map里,value无法被GC,内存泄漏堆积。
2. 泄漏触发完整链路
- 业务方法结束,ThreadLocal引用置空;
- GC回收ThreadLocal,Entry.key = null;
- 线程池核心线程长期存活,Thread对象不销毁,map不会销毁;
- value强引用常驻内存,无法释放 → 内存泄漏。
3. 怎么彻底避免?
使用完毕必须手动调用 tl.remove()
- 普通单次线程:线程结束自动销毁Thread,map跟着销毁,泄漏概率低;
- 线程池环境(Tomcat线程池、自定义业务线程池):100%必须remove,必写finally清理。
标准安全模板:
java
try {
UserContextHolder.setUser(user);
// 执行业务
} finally {
UserContextHolder.clear(); // remove 强制清理
}
key设计成弱引用,是为了尽可能减少泄漏,只是不能彻底根除,必须配合手动remove。
五、ThreadLocal 父子线程传递问题
1. 原生ThreadLocal:子线程拿不到父线程数据
主线程set值,新开子线程new Thread(),子线程get()返回null;
因为父子线程是两个独立Thread对象,各自的ThreadLocalMap完全隔离。
2. 解决方案:InheritableThreadLocal
继承类,会在创建子线程时,自动把父线程map里的键值拷贝一份到子线程。
java
private static final InheritableThreadLocal<String> TL = new InheritableThreadLocal<>();
局限:只在新建线程时拷贝一次 ,线程池复用线程时失效,无法多次透传;
线程池异步场景要用阿里
TransmittableThreadLocal(TTL)解决。
六、常用API汇总
| 方法 | 作用 |
|---|---|
set(T value) |
当前线程存入数据 |
get() |
当前线程读取数据 |
remove() |
删除当前线程本条键值对,释放内存 |
withInitial(Supplier) |
设置初始值,get为空时自动初始化 |