一个业务场景只需要一个ThreadLocal实例

很多人用了很久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掉,这不是写法习惯,是防止线程池里内存泄漏的必要操作。

标准的写法是:

Java 复制代码
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的使用场景不限于请求上下文,只要是「每个线程独立维护一份状态」的需求,都可以用。

业务代码里最常见的模式就是存用户信息:

Java 复制代码
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
相关推荐
带刺的坐椅1 小时前
Solon 热加载与插件热插拔:Debug 模式 × E-Spi × H-Spi 全解析
java·solon·插件·plugin·热插拨
Rick19931 小时前
mysql联合索引经典实例
java·数据库·mysql
方也_arkling1 小时前
【Java-Day02】语法篇:变量/数据类型/标识符/运算符/类型转换
java·开发语言
她的男孩2 小时前
从自然语言到数据大屏:Forge Report Studio 的 AI 生成链路
人工智能·后端·架构
她的男孩2 小时前
大屏动态数据接入:从静态 Mock 到真实业务 API
后端·架构
学代码的真由酱2 小时前
WebSocket背景知识及简单实现-Java
java·websocket
lld9510272 小时前
(一)云回测:量化策略上线前的必经之路
java·服务器·数据库
云云只是个程序马喽2 小时前
海外短剧系统开发_云微传媒:多语言短剧平台定制与变现解决方案
java·php
往上跑山2 小时前
基于 Harness 工程规范的多智能体交互过程实现
后端