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 身上”这一点,剩下的都顺了。那就找个传参传烦了的地方,拿它试试吧。

更多推荐

章节目录