一、💡 一句话理解
核心结论
把
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 中 removeA 不能刚 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: 10001B 从请求头取出信息,放入 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,而是采用了两层处理:
- 在请求线程中先取得
accountId,再把它作为普通参数传给异步方法。 - 自定义
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();
}
}避免内存泄漏时记住五点:
- 有
set()就要有remove(),并把remove()放进finally。 - 谁设置谁清理,最好在 Filter、拦截器或
TaskDecorator这种统一边界完成。 - 异步线程自己清理,主线程调用
remove()清不掉异步线程里的数据。 - 能显式传参就显式传参,不要把所有数据都塞进
ThreadLocal。 - 不要存放大对象和长期资源,也不要认为 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()。