臭名昭著aTrust隔离计划1.臭名昭著aTrust隔离计划2.VPN原理与aTrust隔离网络实践3.docker-easyconnect到底做了什么4.TUN(tunnel-隧道-虚拟网卡)模式
个人项目
personal(个人展示)GitHub Actions 自动部署个人展示项目
基于Quartz搭建的个人博客1.Quartz个人博客使用教程2.使用 rsync 增量部署 Quartz 博客3.使用 GitHub Actions 自动部署 Quartz 博客5.域名绑定6.CDN加速7.Google 搜索收录排查:为什么关键词搜得到别人,却搜不到我的网站?Obsidian、双向链接与知识图谱
架构设计
分布式分布式事务RPC原理
旅行
行程攻略青岛三日游行程攻略
前端
NginxNginx配置与反向代理入门
Nodenpm与npx的区别
数据库
mysql01.MySQL基础语法与约束02.MySQL数据库类型03.MySQL多表查询04.MySQL 索引05.MySQL 事务06.MySQL 隔离级别07.MySQL MVCC08.MySQL 锁09.EXPLAIN10.慢查询与 SQL 优化
mysql面试题01.索引问题02.事务与隔离级别03.锁机制04.存储引擎与性能优化05.用户权限、字符集与备份恢复
mysql实战01.EXPLAIN实战02.测试环境菜单误删排查与恢复03.MySQL IN 详解
网络
网络安全1.无线监听网卡苹果M系列虚拟机安装Kali Linux教程3.kali怎么使用RTL8812AU4.kali基础命令
网络基础进程之间怎样通过网络传递数据
网址常用网址
中间键
esElasticsearch 入门
mq
RabbitMQRabbitMQ高级RabbitMQ基础
1. MQ 基础2. MQ 核心角色与代码对应3. MQ Consumer Group 与消费模式4. SOFAMQ 账户变更代码实践5. MQ 可靠性问题6. 消息幂等
redis缓存击穿、穿透与雪崩Redis 分布式锁Redis 缓存问题Redis 数据结构
AI
使用说明
第三方插件&技能简介Archify使用与安装指南geekgeekRun使用OfferFlow使用指南Ponytail使用指南Skills Manager使用指南
CodexCodex CLI与IDE区别及使用指南Codex Hook单独配置与提交通知Codex MCP安装与使用指南Codex第三方插件安装与使用指南
Agent开发0-学习路线1.LLM、Token、上下文窗口与模型参数2.大模型 API、请求参数与响应结构3.Spring AI ChatClient4.Prompt、System Prompt、Prompt 模板6.结构化输出、JSON Schema7.SSE 流式响应8.会话 ID、聊天记录、Redis9.超时、重试、限流、降级10.*完成可运行聊天接口11.Function Calling、Tool Calling 原理12.用 Java 方法定义 Tool13.工具参数设计和校验14.实现订单查询工具15.实现物流查询工具16.实现库存查询工具17.多个工具自动选择18.工具异常、超时、重试
diy主机
主机零件学习1.CPU2.主板3.内存4.显卡5.硬盘6.散热7.风扇8.机箱热门型号价格表
捡垃圾diy-13600kf+5060diy-14600kf+5070
English高频词汇英文基础语法简单句子音标
GitGit常用命令Sublime Merge 使用指南
Java
工具库Caffeine本地缓存Reactor教程
工作实践如何使用@Async线程池使用与排查线上服务 CPU 使用率过高排查压测下RPC接口大量失败排查记录
基础1.HashMap2.ConcurrentHashMap3.HashSet4.final5.static6.volatile7.AtomicInteger8.String动态代理反射锁与线程同步线程注解
面试题1.HashMap 存储在方法内是线程安全的吗2.ArrayList参数传递与引用重新赋值3.== 和 equals 的区别、hashcode4.重载和重写的区别5.String、StringBuffer、StringBuilder区别及使用场景6.接口和抽象类7.ArrayList 与 LinkedList 的区别和扩容机制8.HashMap 和 Hashtable 的区别及底层实现9.Object 和 Objects 的区别10.ThreadLocal的原理和使用场景11.字节码是什么及作用12.Java类加载器有哪些13.双亲委派模型14.Java异常体系15.GC是什么16.GC如何判断对象可以被回收
面试总结0.HR面试模板1.面试一2.面试二3.面试三笔试
网络与 IO(黑马程序员-纯理论)01-网络编程基础02-IO模型与零拷贝03-BIO04-NIO基础与缓冲区05-NIO通道与选择器06-AIO
juc00-目录01-进程与线程02-线程同步与通信03-并发设计模式04-Java内存模型05-CAS与原子类06-ThreadLocal07-阻塞队列08-线程池使用与原理09-任务调度10-ForkJoin与享元模式11-AQS12-ReentrantLock13-读写锁14-并发协作工具15-ConcurrentHashMap16-CopyOnWrite与同步容器17-SkipListMap18-非阻塞队列
juc(工作常见)Java并发编程三个重点拆解
jvm1. JVM运行时数据区
Spring1.Spring IoC、DI与构造方法注入CGLIB、Byte Buddy 与 Spring AOPSpring 常用注解详解Spring Bean 生命周期
Spring MVC异常处理机制
linux常用命令
mac快捷键终端常用操作
python
基础01.Python是什么,为什么要安装Python02.Python项目中.venv是什么03.Python基础语法
基础实战sql替换
HTTPXHRMS员工详情接口调用(Python HTTPX)
windowswindows激活

一、💡 一句话理解

核心结论

把 ThreadLocal 想成“每个线程都有一个自己的储物柜”。虽然大家使用的是同一个 ThreadLocal,但每个线程放进去、拿出来的都是自己的东西,互不干扰。

二、🧭 理论:它是什么

2.1 核心概念

假设两个用户同时访问系统:

  • 线程 A 正在处理张三的请求。
  • 线程 B 正在处理李四的请求。

如果把用户信息放进普通共享变量,线程 A 和线程 B 可能互相覆盖。放进 ThreadLocal 后,效果就像这样:

线程 A 的储物柜:张三
线程 B 的储物柜:李四

Controller 把用户 ID 放进去后,Service、DAO 都可以从当前线程的“储物柜”里取,不必每个方法都传一遍参数。

类比中的角色Java 中的真实对象
员工Thread 线程
员工自己的储物柜线程内部的 ThreadLocalMap
柜门钥匙ThreadLocal 对象
柜子里的物品用户 ID 等实际数据

使用边界

ThreadLocal 只能保证每个线程的“储物柜”是独立的。如果两个柜子里放的是同一个共享对象,线程之间仍然会互相影响。

2.2 为什么 ThreadLocal 不能代替锁

想象线程 A 和线程 B 各有一个储物柜,但两个柜子里放的都是同一辆公共自行车的钥匙:

线程 A 的储物柜 → 公共自行车
线程 B 的储物柜 → 同一辆公共自行车

储物柜虽然分开了,但两个人操作的还是同一辆自行车,依然可能产生冲突。

Java 中也是一样:

// 这是两个线程共同使用的同一个 List。
List<String> sharedList = new ArrayList<>();
 
ThreadLocal<List<String>> local = new ThreadLocal<>();
 
// 如果线程 A 和线程 B 都放入 sharedList,
// 两个储物柜中保存的仍然是同一个对象。
local.set(sharedList);

因此:

  • 两个线程分别保存自己的对象,可以互不影响。
  • 两个线程保存同一个共享对象,仍要使用锁或线程安全的并发工具。

一句话记忆:柜子是独立的,不代表柜子里的东西一定是独立的。

三、⚙️ 理论:它是怎么工作的

ThreadLocal 的数据在栈里还是堆里?

ThreadLocal 保存的数据通常位于堆内存,不是线程栈。

每个线程对应的 Thread 对象会持有一个 ThreadLocalMap,Map 和其中保存的对象都在堆中。线程栈主要保存方法栈帧、局部变量以及指向堆中对象的引用。

一句话记忆:ThreadLocal 的值物理上在堆里,使用范围上归当前线程私有。

堆内存
└── Thread 对象
    └── ThreadLocalMap
        └── Entry
            ├── key:ThreadLocal 对象
            └── value:实际保存的数据

3.1 数据实际存放在线程中

更准确地说,数据不是直接存在 ThreadLocal 对象里,而是存在当前 Thread 对象持有的 ThreadLocalMap 中。

可以继续把 ThreadLocalMap 理解成当前线程的储物柜:

  • set(value):往当前线程的柜子里放东西。
  • get():从当前线程的柜子里拿东西。
  • remove():把当前线程柜子里的东西清掉。

所以即使线程 A 和线程 B 使用同一个 ThreadLocal,它们最终操作的仍是各自线程内部的数据。

3.2 为什么一定要 remove

线程池不会在请求结束后销毁线程,而是把线程留下来处理下一个请求。就像员工换班了,但储物柜还会继续使用。

如果张三的请求处理完后没有清空柜子,下一次这个线程处理李四的请求时,就可能留下两个问题:

  • 数据串用:李四可能读到张三留下的数据。
  • 内存占用:旧数据一直被长期存活的线程引用,垃圾回收器无法及时回收。

弱引用是什么意思?

普通变量对对象的引用叫强引用。只要强引用还存在,垃圾回收器就不会回收这个对象。

弱引用则像一张临时标签:它虽然指向对象,但不会阻止垃圾回收。如果一个对象只剩下弱引用,垃圾回收器就可以将它回收。

ThreadLocalMap 中的引用关系大致如下:

Entry
├── key:弱引用 → ThreadLocal 对象
└── value:强引用 → 实际保存的数据

当外部不再使用某个 ThreadLocal 时,垃圾回收后可能出现:

Entry
├── key:null
└── value:张三的用户信息

此时 key 已经被回收,但 value 仍可能被 ThreadLocalMap 持有。如果线程池中的线程长期存活,value 也可能长期占用内存。

所以,弱引用只能帮助回收作为 key 的 ThreadLocal 对象,不能保证 value 立即被清理。用完仍然要主动调用 remove()。

记忆口诀:请求进来先放,业务需要就拿,请求结束必清。

3.3 跨线程与跨服务怎么办

假设 A、B、C 是三个独立的微服务。它们拥有不同的 JVM、线程池和 ThreadLocalMap,所以:

A 调用 remove() 只会清理 A 当前线程的数据,不会清理 B、C 的数据。每个服务都要负责设置和清理自己的 ThreadLocal。

一次请求经过三个服务时,完整顺序如下:

请求进入 A:set
├── A 调用 B:通过请求头传递 traceId、userId 等必要信息
│   └── 请求进入 B:从请求头取值并 set
│       ├── B 调用 C:继续通过请求头传递必要信息
│       │   └── 请求进入 C:set → 处理业务 → finally 中 remove
│       └── B 收到响应 → finally 中 remove
└── A 收到响应 → finally 中 remove

A 不能刚 set() 就立刻 remove(),而要等 A 的整个请求处理结束后再清理:

CONTEXT.set(context);
 
try {
    // 调用 B 时,仍可读取当前上下文并放入 HTTP 请求头。
    callServiceB();
} finally {
    // B 返回,并且 A 的本次请求处理结束后再清理。
    CONTEXT.remove();
}

ThreadLocal 本身不能跨服务传播。A 调用 B 时,应把必要信息放入 HTTP 请求头,例如:

X-Trace-Id: 123456
X-User-Id: 10001

B 从请求头取出信息,放入 B 自己的 ThreadLocal;B 调用 C 时再次通过请求头传递。三个服务各自 set,也各自在 finally 中 remove。

如果只是同一个服务内切换到另一个线程池,普通 ThreadLocal 同样不会自动传过去,需要显式传递:

// 提交任务前,先从当前线程取出需要传递的数据。
String traceId = TRACE_ID.get();
 
executor.submit(() -> {
    try {
        // 在线程池的新线程中重新设置。
        TRACE_ID.set(traceId);
        doAsyncWork();
    } finally {
        // 新线程负责清理自己的数据。
        TRACE_ID.remove();
    }
});

一句话记忆:跨服务靠请求头传递,每个服务各自 set、各自 remove;跨线程要显式传递,新线程也要自己清理。

3.4 项目实例:异步导出为什么不会串导出人

在 aboss-property-hrms 的人员导出功能中,导出人没有串用,关键不只是使用了 @Async,而是采用了两层处理:

  1. 在请求线程中先取得 accountId,再把它作为普通参数传给异步方法。
  2. 自定义 TaskDecorator 复制其他上下文,并在异步任务结束后统一清理。

调用过程可以简化为:

请求线程调用 accountId(),取得当前导出人
        ↓
exportStaff(cmd, accountId)
        ↓
@Async 将任务交给 taskExecutor
        ↓
异步线程直接使用传入的 accountId
        ↓
ContextCopyingDecorator 在 finally 中清理线程上下文

请求线程在调用异步方法前就取得了导出人:

// OutsourcedStaffWebImpl
outsourcedStaffService.exportStaff(cmd, accountId());

异步方法不再从 ThreadLocal 中查询导出人,而是直接使用参数:

@Async("taskExecutor")
public void exportStaff(QueryExcelExportStaffCmd cmd, String accountId) {
    PlanImExportResultDO result = new PlanImExportResultDO();
 
    // 导出人来自调用方显式传入的参数。
    result.setCreator(accountId);
    result.setModifier(accountId);
}

因此,即使线程池中的同一个线程先处理张三、再处理李四,每个导出任务也会携带各自的 accountId,不会依赖线程中可能残留的旧用户信息。

项目还给 taskExecutor 配置了 ContextCopyingDecorator:

try {
    // 把提交任务时捕获的上下文设置到异步线程。
    ThreadLocalUtils.set(copiedContext);
    RpcInvokeContext.getContext().putAllRequestBaggage(copiedRequestBaggage);
    runnable.run();
} finally {
    // 线程池会复用线程,所以任务结束后必须清理。
    ThreadLocalUtils.remove();
    RpcInvokeContext.removeContext();
}

仍需注意的边界

ContextCopyingDecorator 清理的是 ThreadLocalUtils 和 RpcInvokeContext。UserInfoContext.CONTEXT 是另一套独立的 ThreadLocal,目前不在这个装饰器的复制和清理范围内。其他线程池代码如果调用 syncThreadLocal(),仍要补充对应的 remove(),否则仍可能残留旧用户数据。

所以,这个项目案例的准确结论是:

@Async 只负责切换线程;显式传入 accountId 保证导出人正确,TaskDecorator 的 finally 清理负责避免其他线程上下文残留。

3.5 ThreadLocal 内存泄漏问题,如何避免

ThreadLocal 的内存泄漏,可以继续用储物柜理解:任务已经结束,但线程池中的线程和它的储物柜没有销毁,柜子里的旧数据也一直没有清走。

引用关系如下。──→ 表示强引用,╌╌→ 表示弱引用:

GC Root
└──→ Thread(线程池中的线程,长期存活)
      └──→ ThreadLocalMap
            └──→ table 数组
                  └──→ Entry
                        ├╌╌→ ThreadLocal 对象(key)
                        └──→ 用户信息等实际数据(value)

这里容易产生一个误区:**Map 不是 GC 眼中不可拆分的整体。**GC 会继续检查 Map、数组和 Entry 中的每一个引用字段,并区分它们是强引用还是弱引用。

ThreadLocalMap.Entry 的结构可以简化为:

static class Entry extends WeakReference<ThreadLocal<?>> {
    // value 是普通字段,对实际数据形成强引用。
    Object value;
}

因此,GC Root 到 value 的路径全部是强引用:

GC Root → Thread → ThreadLocalMap → table → Entry → value

只要线程池中的线程还活着,value 就可以沿这条强引用链被找到,垃圾回收器不能回收它。

GC Root 到 key 的最后一段却是弱引用:

GC Root → Thread → ThreadLocalMap → table → Entry ╌╌弱引用╌╌→ ThreadLocal

如果外部没有其他强引用指向这个 ThreadLocal,弱引用不会阻止它被回收。

当 ThreadLocal 对象没有其他强引用时,key 可能被垃圾回收并变成 null,但是 value 仍可能被 ThreadLocalMap 持有:

GC Root
└──→ Thread
      └──→ ThreadLocalMap
            └──→ Entry
                  ├╌╌→ null
                  └──→ value:仍然存在

垃圾回收器只根据引用关系判断对象是否存活,不理解“key 没了,value 在业务上也没用了”。它看到 Entry 和 value 仍能通过强引用链到达,就不能擅自把它们回收。

准确理解

不是 GC Root 分别直接指向 Map 和 value,而是 GC Root 通过一整条强引用链间接到达 value;到 key 的引用链最后是弱引用,所以不能保证 key 存活。

如果线程结束,并且 Thread 对象也不再被引用,最上方的引用链断开,整个 ThreadLocalMap、Entry 和 value 才都可以被回收。线程池的问题正是线程通常会长期存活。

OpenJDK 会在部分 get()、set()、remove() 操作中顺便清理这种过期 Entry,但清理时机不能保证。线程池中的线程又可能长期不销毁,于是 value 会被保留得比预期更久,看起来就像内存泄漏。

最常见的错误写法是只 set(),没有清理:

private static final ThreadLocal<UserContext> CONTEXT = new ThreadLocal<>();
 
public void handle(UserContext userContext) {
    CONTEXT.set(userContext);
    doBusiness();
 
    // 如果 doBusiness 抛出异常,或者这里根本没有 remove,
    // 用户信息就可能留在线程池的线程中。
}

正确做法是在同一个线程中使用 try-finally:

public void handle(UserContext userContext) {
    try {
        // 当前任务开始时设置。
        CONTEXT.set(userContext);
        doBusiness();
    } finally {
        // 无论正常结束还是抛出异常,都会清理当前线程的数据。
        CONTEXT.remove();
    }
}

避免内存泄漏时记住五点:

  1. 有 set() 就要有 remove(),并把 remove() 放进 finally。
  2. 谁设置谁清理,最好在 Filter、拦截器或 TaskDecorator 这种统一边界完成。
  3. 异步线程自己清理,主线程调用 remove() 清不掉异步线程里的数据。
  4. 能显式传参就显式传参,不要把所有数据都塞进 ThreadLocal。
  5. 不要存放大对象和长期资源,也不要认为 key 是弱引用就可以省略 remove()。

在 aboss-property-hrms 中,ContextCopyingDecorator 使用 finally 清理 ThreadLocalUtils 和 RpcInvokeContext,思路是正确的;但独立的 UserInfoContext.CONTEXT 仍需要自己的清理入口和调用位置。

一句话记忆:线程不结束,数据就可能不释放;谁在线程中 set,谁就在 finally 中 remove。

四、🚀 实践:从准备到验证

4.1 前置准备

只需要 JDK,无须引入第三方依赖。

4.2 可以拿来干什么

  • 保存当前登录用户:Controller 放入用户 ID,Service 直接读取。
  • 保存链路追踪 ID:同一次请求打印的日志都带上相同标识。
  • 框架内部保存上下文:例如把事务或数据库连接绑定到当前线程。

它不适合跨线程传递数据。代码切换到异步线程后,相当于“换了一个员工”,自然也换了一个储物柜。

4.3 完整实践:请求上下文

public class ThreadLocalDemo {
    // USER_ID 相当于打开“用户 ID 储物格”的钥匙。
    private static final ThreadLocal<String> USER_ID = new ThreadLocal<>();
 
    public static void main(String[] args) throws InterruptedException {
        Thread threadA = new Thread(() -> {
            try {
                // set:把张三放入线程 A 自己的储物柜。
                USER_ID.set("张三");
 
                // get:线程 A 只能拿到自己保存的张三。
                System.out.println("线程 A:" + USER_ID.get());
            } finally {
                // remove:用完立即清空,防止线程复用时遗留旧数据。
                USER_ID.remove();
            }
        });
 
        Thread threadB = new Thread(() -> {
            try {
                // 线程 B 使用同一把“钥匙”,但打开的是自己的储物柜。
                USER_ID.set("李四");
                System.out.println("线程 B:" + USER_ID.get());
            } finally {
                USER_ID.remove();
            }
        });
 
        threadA.start();
        threadB.start();
        threadA.join();
        threadB.join();
    }
}

编译并运行:

javac ThreadLocalDemo.java
java ThreadLocalDemo

结果类似下面这样,顺序可能不同:

线程 A:张三
线程 B:李四

两段代码使用的是同一个 USER_ID,但线程 A 取不到李四,线程 B 也取不到张三。

五、🎤 面试时怎么流利地说

5.1 30 秒标准回答

ThreadLocal 可以理解成每个线程自己的储物柜,用来保存线程私有数据。它的底层原理是每个线程内部都有一个 ThreadLocalMap,ThreadLocal 是 key,实际数据是 value。它常用于保存用户 ID、链路 ID 和事务上下文。因为线程池会复用线程,所以用完要在 finally 中调用 remove(),避免上一个请求的数据影响下一个请求。

5.2 高频追问

ThreadLocal 能解决线程安全吗?

不能直接这么说。它只是让每个线程保存自己的数据,从而减少共享;如果多个线程仍然修改同一个对象,还是需要锁或并发工具。

为什么 key 使用弱引用仍可能内存泄漏?

key 被垃圾回收后,value 仍可能留在线程的 Map 中。线程池的线程又长期不销毁,value 就可能一直占着内存,所以必须主动 remove()。

六、📌 总结

  • ThreadLocal 就像每个线程自己的储物柜。
  • set 是放,get 是拿,remove 是清空。
  • 常用于保存用户 ID、链路 ID、事务上下文。
  • 它不能跨线程传值,也不能代替锁。
  • 在线程池中一定要在 finally 里 remove()。

七、🔗 官方资料