ThreadLocal
ThreadLocal 是 一个用于存储线程私有变量的工具,它 为每个线程维护独立的变量副本,实现线程间数据隔离,避免了多线程并发访问的安全问题;还可在同一线程内完成上下文参数的隐式透传,避免方法间显式传参。
在并发编程中,我们习惯于讨论如何用“锁”来控制对共享资源的访问。但 ThreadLocal 提供了一种完全不同的思路:与其争抢,不如隔离。
ThreadLocal 的 设计初衷并非为了解决多线程“共享”变量 的问题,而是为了实现 线程封闭(Thread Confinement)。它通过物理隔离,让每个线程拥有独立的变量副本,从根本上切断了竞争的源头,实现了无锁的线程安全。
它不仅仅是一个存储工具,更是 Java 这种基于线程模型的语言中,处理上下文(Context)和事务(Transaction)的基石。
两大核心场景
ThreadLocal 在 Java 生态中主要扮演两个角色:隐式参数通道 和 事务管理基石。
场景一:全链路参数透传
假设你正在维护一个复杂的业务链路:Controller $\rightarrow$ ServiceA $\rightarrow$ ServiceB…… $\rightarrow$ Dao。
现在的需求是:在数据库新增一个字段 modified_by(修改人),需要从 Controller 层获取当前登录用户的 ID,一直透传到 Dao 层进行存储。
- 笨办法(显式传参):你需要修改链路上所有方法的签名,把
userId一层层传下去。哪怕中间的Service B根本不需要它,也必须被迫充当“搬运工”。代码侵入性极强。 - 错办法(静态变量):搞一个
public static String currentUserId?绝对不行。Web 服务器是多线程的,线程 A 刚赋值“张三”,线程 B 可能瞬间把它改成了“李四”,数据全乱套。
解法:ThreadLocal 隐式通道
基于 Web 容器(如 Tomcat 或 Jetty)的 Thread-Per-Request 模型(一个请求对应一个独立线程),我们可以利用 ThreadLocal 将数据绑定到当前线程上。
1 | |
有人可能会问:USER_HOLDER 是 static 的,全局只有一份,怎么能存不同线程的数据?
这跟 ThreadLocal 的底层设计有关,下面讲源码时会讲到:因为 ThreadLocal 仅仅是一个访问入口 (Key) 和管理工具,真正存储数据 (Value) 的容器是 Thread 线程对象自身内部的 ThreadLocalMap。
场景二:数据库连接与事务管理
如果说参数传递只是“便利性”问题,那么在数据库连接管理上,ThreadLocal 解决的是生与死的“正确性”问题。
JDBC 的 java.sql.Connection 是 非线程安全 的。
假设我们要执行更新:
- prepareStatement:创建 SQL 模板(
UPDATE account SET balance = ?)。 - Statement:这是一个“占位符容器”,是执行 SQL 的骨架。
- executeUpdate:传入参数,执行。
假设我们为了省事,创建一个全局共享的 static Connection conn。
- T1 (线程 A):拿到
conn,创建 Statement,设置参数balance = -100。 - T2 (线程 B):拿到同一个
conn,创建 Statement,设置参数balance = +100。 - 竞态条件:
Connection内部通常维护一个当前的Statement引用或缓冲区。T2 的操作可能会覆盖 T1 的上下文。 - 结果:T1 执行提交时,可能把 T2 的数据提交了,或者抛出异常。
失败的尝试
方案 A:加锁 (Synchronized)
- 做法:所有数据库操作都加锁。
- 代价:系统吞吐量雪崩。如果有 1000 个并发请求,它们将排队等待这一个连接。这退化成了单线程应用。
方案 B:多连接 + 复杂调度
- 做法:手动写代码管理连接池,谁用谁取。
- 死锁风险:如果通过连接 A 操作库 1,连接 B 操作库 2。线程 1 拿了 A 等 B,线程 2 拿了 B 等 A,系统直接死锁。
解法:连接复用与隔离
这是 Spring @Transactional 声明式事务的魔法来源。
Spring 的事务管理器(TransactionManager)利用 ThreadLocal 确保:同一个线程,在同一个事务范围内,使用的是同一个数据库连接。
内部核心类 TransactionSynchronizationManager 大致逻辑如下:
1 | |
ThreadLocal<Map<Object, Object>>:最核心。Key 是 DataSource,Value 是 ConnectionHolder(包装了 JDBC Connection)。确保同一线程、同一事务内复用同一个连接。ThreadLocal<Set<...>>:存储当前线程的事务回调(如 commit 后发消息、清理缓存)。ThreadLocal<String>:存储当前事务名称,用于监控和日志。
执行流程 (DataSourceTransactionManager):
- 开启事务:DSTM 从连接池获取一个全新的
Connection。 - 绑定资源:调用
TSM.bindResource(),将这个Connection放入当前线程的ThreadLocal (resources)中。 - 业务执行:Dao 层执行 SQL 时,不是自己去连接池拿连接,而是通过
DataSourceUtils从ThreadLocal中获取当前事务绑定的那个连接。这样,虽然 ServiceA、ServiceB、ServiceC 在不同方法,但因为在同一个线程,拿到的都是 同一个Connection。这保证了它们在同一个事务内。 - 提交/回滚:利用该连接进行 commit/rollback。
- 清理:调用
TSM.unbindResource(),清空ThreadLocal,将连接归还给连接池。
这不仅避免了昂贵的锁竞争,还完美解决了跨方法的事务传播问题。
核心原理:Thread 才是真正的仓库
误区:认为 ThreadLocal 内部维护了一个 Map<Thread, Object>,把数据存得满地都是。
真相:数据其实是存在线程(Thread)自己的“口袋”里的。
内存关系图
- Thread (宿主):每个
Thread对象内部都有一个ThreadLocalMap类型的成员变量threadLocals。 - ThreadLocalMap (口袋):这是
ThreadLocal的静态内部类,本质是一个定制版的 HashMap。 - ThreadLocal (钥匙):它只是一个访问入口。
1 | |
1 | |
为什么 ThreadLocalMap 不用链表?
ThreadLocalMap 是 ThreadLocal 的静态内部类,虽然名为 Map,但它与 HashMap 有显著不同:
- 没有链表/红黑树:它完全采用数组存储 (
Entry[] table)。 - 解决冲突策略:采用 开放寻址法 中的 线性探测。计算 hash 对应下标,如果该位置已被占用,则
index + 1向后寻找下一个空位,直到找到为止。 - 优势:结构简单,省内存(无链表指针开销)。数组内存连续,利用 CPU 缓存行机制,在冲突较少时访问效率极高。
- 劣势:如果冲突严重,探测路径变长,性能会急剧下降。这也是为什么建议 ThreadLocal 存储对象不宜过多。
- 应对:JDK 引入了 黄金分割数魔数
0x61c88647(与 黄金分割点 有关。$ ( \sqrt{5} - 1 ) / 2 \times 2^{32} \approx 2654435769 $ (即 0x61c88647))。每创建一个 ThreadLocal,哈希值就累加这个数。实验证明,这能让数据在 $2^n$ 大小的数组中分布得极其均匀,最大程度减少冲突。对使用“线性探测法”的 Map 至关重要。
- 应对:JDK 引入了 黄金分割数魔数
隐患:内存泄漏
既然数据存在线程里,而线程(特别是线程池核心线程)是长期存活的,那这些数据岂不是永远无法释放?
先来看看这个情况:
如果我们新建了一个线程,这个线程做完自己的事,然后被销毁:
Thread 创建 ThreadLocal 并调用 set 方法。创建 ThreadLocalMap,并与之绑定。
现在的引用关系:
- Thread 引用 ThreadLocalMap。
- Enrty 的 key 又存储了 ThreadLocal 的引用,value 存储了我们 set 的对象的引用。
Thread 的任务执行完毕,Thread 被销毁:
- 此时 Thread 对 ThreadLocalMap 的引用消失。ThreadLocalMap 当前没有任何引用,下一次 GC 被回收。
- 此时 Enrty 数组也失去了引用,下一次 GC 被回收。
- Enrty 中的 key(ThreadLocal),value 不再被引用,GC 回收。
上面是一个线程正常的生命流程,没有任何的内存泄漏。
但如果这个线程是 线程池的核心线程,十天半个月甚至几个月都不销毁,那么 Thread 就会一直引用着 ThreadLocalMap,进而导致 key 和 value 一直不能被回收。这样就发生了内存泄漏。
为了缓解这个问题,JDK 采用了一种“弱引用”策略。但这只是 半个 解法。
ThreadLocalMap 的 Entry 定义如下:
1 | |
- Key (ThreadLocal):若 Key 为强引用,只要线程存活(如线程池核心线程),Map 就持有 Key 的引用。即使外部局部变量
tl销毁,ThreadLocal对象也无法回收,导致必然的内存泄漏。改为弱引用后,外部强引用消失,GC 会回收ThreadLocal对象,Map 中的 Key 变为null。 - Value (业务数据):强引用。必须强引用,Value 是业务数据,其 生命周期必须由业务代码控制。若为弱引用,可能在业务逻辑执行中途被 GC 清理,导致
NullPointerException。
1 | |
所以,value 怎么办呢?当 Key 被 GC 回收变成 null 后,引用链就变成了:
Current Thread-> ThreadLocalMap-> Entry-> Value-> Object
- 问题:Key 没了,你再也访问不到这个 Value 了。
- 后果:只要线程不结束,这条强引用链就一直存在,这块内存永远无法回收。这就是内存泄漏。
虽然 ThreadLocalMap 采用了一种“探测式清理”策略。在调用 set()、get()、remove() 时,它会顺便检查哈希槽里有没有 Key 为 null 的脏 Entry,如果有就清理掉。但这种清理是被动的 。一旦你不再调用这些方法,或者线程归还给线程池后处于空闲状态,垃圾就会一直堆积。
设为 static 就不会有内存泄漏的问题了?
网上会有很多教程说,设置为 static 就不会有内存泄漏问题了。那我们来看看吧。就拿一开始的应用举例。
设置为 static,全局将只有这一个 ThreadLocal。所有线程共用这一个静态的 ThreadLocal。当然,这不影响线程隔离,因为实际上实现线程隔离的是 ThreadLocalMap。
这种解决方案是,全局仅使用一个 ThreadLocal 作为一把“钥匙”。第一次请求存入了 ValueA。这条请求即使结束了,我们依旧可以用 ThreadLocal.get 定位到这个对象。因为 ThreadLocal 是全局唯一的。
而下一次,有新的请求,又调用了 set 方法,就会直接覆盖掉上一次 set 的对象。
这样子大概已经够用了。但还是会有问题。假如我们存入了一个非常大的对象,又一直没有新的请求调用 ThreadLocal.set,这个对象就会一直放在这里,浪费内存。
最佳实践
为什么 Map 存在于 Thread 对象中,而不是 ThreadLocal 对象中?
假设 ThreadLocal 自己维护一个 Map,用来记录各个线程存了什么值。
结构大概是:Map<Thread, Value>。
逻辑: 线程 A 来存数据,
ThreadLocal就记录map.put(ThreadA, "数据")。致命缺陷 1:并发冲突(性能差)
ThreadLocal对象通常是全局共享的(比如定义为static final)。- 如果有 100 个线程同时在这个
ThreadLocal中存取数据,它们访问的是 同一个 Map。 - 为了数据安全,这个 Map 必须加锁(或者用
ConcurrentHashMap)。这会导致严重的锁竞争,违背了 ThreadLocal “线程隔离”的初衷。
致命缺陷 2:生命周期不一致(内存泄漏)
- 如果线程 A 销毁了,但
ThreadLocal里的 Map 还存着Key=ThreadA的数据。你必须有一种机制去监听线程销毁事件来清理 Map,否则全是垃圾数据。
- 如果线程 A 销毁了,但
现在的结构是:Thread 对象内部有一个成员变量 ThreadLocalMap threadLocals。
这个 Map 的 Key 是 ThreadLocal 对象,Value 是具体的值。
逻辑:
ThreadLocal更像是一个 “访问入口” 或 “钥匙”。- 当你调用
tl.set("数据")时,ThreadLocal实际上是转身去找当前线程:“嘿,线程老兄,把你口袋里的 Map 拿出来,把‘我’作为 Key,‘数据’作为 Value 存进去。” - 代码逻辑:
Thread.currentThread().map.put(this, "数据")。
优势 1:绝对的线程安全(无锁)
- 每个线程只访问 自己的 Map(存在自己的 Thread 对象里)。
- 线程 A 访问线程 A 的 Map,线程 B 访问线程 B 的 Map。
- 完全不存在竞争,不需要任何锁。这就是 ThreadLocal 高效的根本原因。
优势 2:生命周期自动绑定
- 数据是存在
Thread对象里的。 - 一旦线程销毁(Thread 对象被 GC),它肚子里的
ThreadLocalMap自然也就销毁了,里面的 Value 也就随之释放了。 - (注:这指的是线程真正销毁的情况。如果是线程池,线程不死,Map 不毁,所以才需要弱引用机制来辅助回收 Key)。
- 数据是存在
铁律:Try-Finally 必须 Remove
这是防止内存泄漏的唯一绝对手段,没有之一。
1 | |
如何解决父子线程传递?
问题:ThreadLocal 里的数据,子线程(new Thread)是拿不到的。
原生方案:InheritableThreadLocal。它在创建线程时,会将父线程的 Map 浅拷贝 给子线程。
- 致命缺陷:它只在“创建线程”那一刻拷贝。但在 线程池 场景下,线程是复用的,不会重复触发创建过程。后续提交的任务无法获取父线程最新的上下文。
工业级解法:使用阿里开源的 TTL (TransmittableThreadLocal)。它通过 装饰器模式,对 Runnable/Callable 进行修饰,在任务提交和执行的时机进行动态挂载和恢复上下文。