ThreadLocal详解

一、是什么?作用

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

  1. 每个Thread实例自带:ThreadLocal.ThreadLocalMap threadLocals
  2. ThreadLocalMap 内部是 Entry[] 数组
    • Entry 键:弱引用 ThreadLocal<?> 对象
    • Entry 值:真正存储的业务数据
  3. 调用 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 源码极简流程

  1. set(T value)
    拿到当前线程Thread → 获取map,map不存在则新建;map以当前ThreadLocal为key存入value
  2. get()
    当前线程获取map,根据tl对象key查找Entry,返回value;无数据返回null
  3. remove()
    当前线程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. 为什么会内存泄漏?

  1. ThreadLocalMap.Entry 的key是弱引用(WeakReference),JVM GC时,ThreadLocal对象会被回收,key变成null;
  2. 但Entry的value是强引用,不会自动释放;
  3. 线程池复用线程(核心线程常驻),线程不会销毁,Entry(key=null, value=xxx)永远残留在map里,value无法被GC,内存泄漏堆积。

2. 泄漏触发完整链路

  1. 业务方法结束,ThreadLocal引用置空;
  2. GC回收ThreadLocal,Entry.key = null;
  3. 线程池核心线程长期存活,Thread对象不销毁,map不会销毁;
  4. 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为空时自动初始化
相关推荐
王中阳Go1 小时前
用TRAE Work批量优化学员简历,原来2天的活现在2小时就干完了
后端·面试·agent
Zane19941 小时前
同一个 list 为什么能被两个 for 循环同时遍历?一文讲透可迭代对象与迭代器的真相
后端·python
他们叫我秃子1 小时前
前端开发转 Go 全栈(四):代码写在前面,却要最后执行?我终于搞懂了 defer
前端·后端·go
全麦面包 time展天1 小时前
走向DBA[MSSQL篇] 从SQL语句的角度 提高数据库的访问性能
数据库·sqlserver·dba
掉鱼的猫1 小时前
跳出 Fatjar 的束缚:Solon 插件的体外扩展(E-Spi)与热插拔(H-Spi)
java
贾天佑忆月 迷失的昵1 小时前
客户端与服务器持续同步解析(轮询,comet,WebSocket)
运维·服务器·websocket
刘某的Cloud1 小时前
Galera Cluster mariadb 生产环境常见问题排查与运维指南
linux·运维·数据库·mariadb·集群高可用
雨辰AI1 小时前
K8s 国产数据库慢 SQL 自动监控|Prometheus+Grafana 可视化全落地
数据库·sql·安全·容器·kubernetes·grafana·prometheus
房开民2 小时前
PyQt5 常用模块(对应Qt五大模块)
数据库·pyqt
Bill FANG2 小时前
从原生Java NIO到Netty重构:充电桩Socket服务端高性能改造实战
java·重构·nio