ThreadLocal 原理和它的正确用法
ThreadLocal 让每个线程拥有一份独立的变量副本,互不干扰。它到底怎么实现的、为啥能做到线程隔离、业务里拿它干啥?这篇把 ThreadLocal 的原理、内部的 ThreadLocalMap 结构,还有那个绕不开的内存泄漏坑一起讲清楚。
多线程环境下共享变量,我们第一反应是加锁。但有一类需求,加锁其实是走偏了——我压根不想让线程们共享,我想让每个线程各玩各的。
这时候就该 ThreadLocal 上场了。
名字直译“线程本地”,意思很直白:变量只属于当前这个线程,别的线程看不见也碰不着。
这篇我们把它的用法、原理、还有那个著名的内存泄漏坑,一次讲透。
它解决的是什么问题
先想个场景。
加锁是让多个线程排队用同一个变量。而 ThreadLocal 的思路反过来:干脆一人发一个,谁也别抢谁的。
打个比方,公司发笔记本电脑。
- 加锁:全公司共用一台电脑,谁用谁先申请,用完还回去,排队
- ThreadLocal:每人发一台,各自用各自的,互不干扰
后者显然更省事,前提是——这个变量确实不需要共享,每个线程要的就是一份自己的副本。
所以 ThreadLocal 不是用来做线程同步的,它是用来做线程隔离的。这两个概念别搞混,一个是排队共享,一个是各存各的。
基本用法
用起来很简单,就三个动作:set、get、remove。
// 定义一个 ThreadLocal,装个用户名
private static ThreadLocal<String> userHolder = new ThreadLocal<>();
// 某个线程里存值
userHolder.set("mebugs");
// 同一个线程里取值,拿到的是自己存的那个
String name = userHolder.get(); // mebugs
// 用完清理掉
userHolder.remove();
关键在于:A 线程 set 进去的值,B 线程用 get 是取不到的。哪怕它俩操作的是同一个 userHolder 对象。
每个线程存取的,都是只属于自己的那一份。这就是“线程隔离”的直观效果。
那问题来了:同一个 ThreadLocal 对象,凭啥能给每个线程存一份不一样的值?这就得看它内部怎么实现的了。
原理 值到底存在哪
很多人第一直觉:值是不是存在 ThreadLocal 对象自己里面?
不是。 这是理解 ThreadLocal 最关键的一个反直觉点。
真相是:值存在每个线程自己身上。
每个 Thread 线程对象内部,都揣着一个 map,叫 ThreadLocalMap。你 set 的值,其实是存进了当前线程的那个 map 里。
Thread-A 对象 Thread-B 对象
| |
+-- ThreadLocalMap +-- ThreadLocalMap
key: userHolder key: userHolder
value: "mebugs" value: "laowang"
注意看这张图的妙处:
- 两个线程用的是同一个 userHolder(当 key)
- 但值分别存在各自线程的 map 里,一个是 mebugs,一个是 laowang
所以 userHolder.set(x) 这个操作,真实发生的事情是:
- 拿到当前线程
- 找到当前线程身上的那个 ThreadLocalMap
- 以 userHolder 这个 ThreadLocal 对象为 key,把 x 存进去
get() 也是同理,从当前线程的 map 里,用 userHolder 当 key 把值捞出来。
想通这一层就全通了:值跟着线程走,ThreadLocal 只是那把用来存取的 key。 线程隔离是天然的,因为每个线程本来就有自己独立的 map。
那个绕不开的内存泄漏坑
讲 ThreadLocal 不讲内存泄漏,等于没讲。这也是面试最爱追的点。
问题出在 ThreadLocalMap 里 key 的存法上。
那个 map 的 key(也就是 ThreadLocal 对象)是用弱引用存的。弱引用有个特点:只要没有别的强引用指着它,下次 GC 一来它就被回收了。
于是可能出现这么个尴尬局面:
- ThreadLocal 对象被 GC 回收了,map 里的 key 变成了 null
- 但对应的 value 还在,因为 value 是强引用,没人回收它
- 这个 key=null、value 还占着的条目,就成了清不掉的垃圾
单看一条不要紧,但要命的是线程池场景。
线程池里的线程是复用的,不会用完就销毁。如果每个任务都往 ThreadLocal 里塞东西又不清理,这些 value 就跟着长命的线程一直赖着不走,越积越多,最后内存就顶不住了。
解药特别简单一个词:用完 remove。
try {
userHolder.set("mebugs");
// 干活,用 userHolder.get() 取值
} finally {
userHolder.remove(); // 用完必须清,尤其是线程池场景
}
放在 finally 里,保证不管中间出没出异常,这份值都会被清掉。这个习惯,用 ThreadLocal 时几乎是强制的。
业务里到底拿它干啥
原理讲完,说点实在的,工作里它到底用在哪。
核心就一句:在同一个线程里,跨方法、跨层传递数据,又不想一路当参数传下去。
一次请求通常由一个线程从头处理到尾,正好可以把请求级别的数据挂在 ThreadLocal 上,整条链路随取随用。
几个真实场景:
- 存当前登录用户信息:在入口(比如拦截器)把用户信息 set 进去,后面 Service、DAO 任何一层要用,直接 get,不用在每个方法参数里传来传去
- 传递请求追踪 ID:给每个请求分配一个 traceId 存进去,打日志时随手取出来,一整条链路的日志就能串起来排查
- 管理数据库连接 / 会话:保证同一个线程在一次事务里,从头到尾用的是同一个连接,很多框架的事务管理就靠它
- 存放非线程安全的工具对象:比如 SimpleDateFormat 这种线程不安全的家伙,给每个线程存一份,就不用加锁也不会冲突
看出共性没?都是这份数据属于当前这次处理流程,同线程内共享,跨线程不共享的场景。ThreadLocal 就是干这个的一把好手。
小结
ThreadLocal 这东西,抓住几条就拿捏了。
- 定位:做线程隔离,一人一份副本,不是做线程同步
- 原理:值存在每个线程自己的 ThreadLocalMap 里,ThreadLocal 对象只是那把 key,值跟着线程走
- 坑:key 是弱引用,value 是强引用,线程池里不清理会内存泄漏,用完
remove(),放 finally - 用途:同线程内跨方法跨层传数据,比如当前用户、traceId、数据库连接
说到底,ThreadLocal 是换了个思路解决共享问题——不共享,各存各的。想清楚“值是存在线程身上、不是存在 ThreadLocal 身上”这一点,剩下的都顺了。那就找个传参传烦了的地方,拿它试试吧。
