很多人用了很久ThreadLocal,却没仔细想过一件事:同一个业务场景下,只需要声明一个ThreadLocal实例,几十上百个线程同时跑,全都共用这一个对象,没有任何线程安全问题。
为啥?
这个特性有点反直觉。通常我们说「多线程共享同一个对象」,第一反应是加锁、同步。但ThreadLocal完全不需要,每个线程只能看到自己的,互不干扰。
为什么能做到这一点,值得看一下。
ThreadLocal和Thread,谁存的数据?
很多人以为数据是存在ThreadLocal对象里的。这个理解是错的,也是后续很多困惑的根源。
ThreadLocal不存数据,它只是一把钥匙。数据存在每个线程自己身上。
具体来说,每个Thread对象内部有一个字段叫threadLocals,类型是ThreadLocal.ThreadLocalMap。这个Map是Thread的实例字段,不是ThreadLocal的字段。每个线程有自己独立的一份,线程和线程之间完全隔离。
当你调用threadLocal.set(value),实际发生的是:拿到当前线程,往这个线程自己的Map里写入一条记录,键是这个ThreadLocal实例,值是你传进去的数据。
当你调用threadLocal.get(),同样是:拿到当前线程,从这个线程自己的Map里,用这个ThreadLocal实例作为键,查出对应的值。
所以ThreadLocal实例在这里扮演的角色,是「键」,不是「容器」。
知道这个逻辑后,「同一个业务场景只需要一个ThreadLocal实例」这件事就不难理解了。同一把键,线程1拿着它去查线程1自己的Map,线程2拿着它去查线程2自己的Map,查出来的是完全不同的数据,互不影响。哪怕有100个线程同时在用这同一个ThreadLocal实例,也不存在竞争关系,因为每个线程操作的是自己的Map,不碰别人的。
线程池场景下的坑
想清楚上面这个设计,就会意识到线程池场景有个问题值得特别注意。
线程池里的线程不会死,它处理完一个任务,会被回收回去等待下一个任务。这就意味着线程的ThreadLocalMap会一直存在。上一个任务set进去的数据,如果没有主动remove,下一个任务get出来的可能就是上一次的脏数据。
更严重的是内存泄漏的问题。ThreadLocalMap里的Entry用的是弱引用指向ThreadLocal键。当ThreadLocal实例被GC回收之后,Entry的键变成了null,但值那一侧是强引用,只要线程不死,这个值就一直占着内存,无法被回收。
这就是为什么「线程池里必须调用remove()」不是可选项,而是硬性要求。Spring的TransactionSynchronizationManager在事务结束时会执行一个clear()方法,把6个ThreadLocal全部remove掉,这不是写法习惯,是防止线程池里内存泄漏的必要操作。
标准的写法是:
try {
HOLDER.set(value);
// 业务逻辑
} finally {
HOLDER.remove();
}
finally保证了不管业务逻辑是否抛异常,remove都会被执行。
大厂和开源框架的实际做法
Spring里的TransactionSynchronizationManager同时声明了6个独立的static finalThreadLocal实例,分别存事务名称、隔离级别、只读标志、活跃状态等。每个维度单独一个ThreadLocal,而不是把所有数据塞进一个Map再用一个ThreadLocal存。这种做法让代码语义更清晰,也方便单独reset某一个维度的状态。
RequestContextHolder同时维护了两个ThreadLocal,一个普通的,一个InheritableThreadLocal。后者的特性是父线程创建子线程时,子线程会继承父线程的值。这在某些需要把请求上下文透传给异步线程的场景下会用到,不过实际项目里更常见的是用阿里开源的TransmittableThreadLocal,它对线程池的支持更完整。
RocketMQ的ThreadLocalIndex用ThreadLocal存的是每个线程的轮询计数器,目的是在不同的Broker之间做负载均衡。这个用法比较小众,但说明ThreadLocal的使用场景不限于请求上下文,只要是「每个线程独立维护一份状态」的需求,都可以用。
业务代码里最常见的模式就是存用户信息:
private static final ThreadLocal<UserContext> HOLDER = new ThreadLocal<>();
然后在拦截器里set,在finally里remove。这个模式在团队里稳定用了很多年,没有出过内存相关的问题,关键就在于remove的位置写对了。
使用时需要注意的几件事
静态声明是基本要求。 如果ThreadLocal不是static的,每次创建对象都会new一个新的ThreadLocal实例,效率差不说,语义也乱。几乎所有框架代码和业务代码都是static final声明。
线程池里的remove是硬性要求。 普通请求线程用完就死,线程池里的线程会复用,这两种场景的处理方式不一样。
跨线程传值不能用普通的ThreadLocal。 父线程set了值,submit到线程池的任务里get不到,因为子线程有自己独立的Map,父线程的数据没有过去。需要跨线程传递上下文的场景,用TransmittableThreadLocal(TTL)是目前比较成熟的方案,它在任务提交时会把当前线程的ThreadLocal值捕获,在任务执行时注入到子线程,任务结束后恢复原状。
小结
ThreadLocal的设计有一个值得留意的地方:它把「存在哪里」和「用什么访问」分开了。数据存在Thread里,ThreadLocal只是访问数据的键。这个分离让同一个业务场景下的ThreadLocal实例只需声明一个,同时让每个线程的数据完全隔离。
多线程开发里有一类常见问题是「共享状态的同步」,用锁、用原子变量来解决。ThreadLocal提供了另一种思路:不共享,每个线程自己维护一份。很多问题绕开了同步,也就绕开了锁竞争。
两种思路没有优劣之分,取决于场景。需要多线程协作操作同一份数据的,只能靠同步。需要每个线程独立处理自己数据的,ThreadLocal更合适。实际项目里,请求上下文传递、事务状态管理、动态数据源切换,这些用ThreadLocal都是合理的。
唯一要盯紧的,是线程池场景下的remove。这个问题不在于ThreadLocal的设计有缺陷,而在于线程池改变了「线程生命周期」这个前提,要主动补上数据清理这一步。
最近在知乎出了
- 「应付6000万会员的秒杀系统专栏」
- 「几亿用户,百万并发的C端商品系统实战」
- 「技术团队DDD领域驱动设计三年落地实战」
- 「应付亿级用户规模的支付系统代码实战」
专栏,感兴趣的可以订阅一下。至于知识星球的,可以搜:
- 老码头的技术浮生录
它是一个能实际帮你解决难题的星球。有问题的,找知心的Sam哥,支持无限次语音一对一解决你遇到的难题。「另外后续我新写的所有对外的付费专栏,在星球内都是免费的,且可以拿到所有源代码。」
当前星球里免费看的专栏是:
- 「应付6000万会员的秒杀系统专栏」
- 「几亿用户,百万并发的C端商品系统实战」
- 「技术团队DDD领域驱动设计三年落地实战」
- 「应付亿级用户规模的支付系统代码实战」
知识星球内后续将推出20+个付费专栏,覆盖电商全链路:
| 选购线 | 用户会员营销线 | 中后台 |
|---|---|---|
| 购物车服务 | 营销系统 | 订单系统 |
| 商品服务 | 用户系统 | 支付系统 |
| 菜单服务 | 结算服务 |
从前台选购到中后台结算,星球成员全部免费,后续新增也不额外收费。
我的知乎账号:
- SamDeepThinking