ThreadLocal 我看了好几遍才看懂,原来关键在引用上
学并发编程绕不过 ThreadLocal,我第一次看的时候觉得"哦,就是给每个线程一份独立的数据嘛",然后就跳过去了。结果后面面试被问到"ThreadLocal 为什么会内存泄漏",直接卡住。
后来我发现,不把底层原理理清楚,光记住"用完了要 remove()"这句话,下次遇到类似的问题还是会懵。
于是又硬着头皮读了一遍源码,这次总算理清楚了。
它到底是干嘛的
先说作用,不然不知道学它干什么。
一个东西给多个线程共用,数据会乱。这是并发编程最原始的问题。解决思路大致有两种:一是用锁让线程排队访问(synchronized、Lock 这些),二是给每个线程一份自己的数据,大家各玩各的,不用抢。ThreadLocal 就是后者。
我遇到的场景是这样的:一个 Web 请求进来,经过拦截器、Controller、Service 好几层,中间可能需要传递用户信息、traceId 这类上下文数据。如果每个方法都通过参数传,代码会变得很啰嗦,改起来也很痛苦。ThreadLocal 可以让你在同一个线程的任意地方取到之前存进去的数据,不用层层传参。
另外还有一个好处——它不用锁。每个线程拿自己的那份副本,天然没有竞争,所以高并发下性能比加锁好很多。
底层结构——它到底把数据存哪了
这一点我最初的理解是错的。我以为值存 ThreadLocal 对象本身里面,后来才发现 ThreadLocal 只是一个"工具人",真正的数据存在当前线程对象(Thread)内部的一个成员变量里。
具体是这样的:
Thread 对象 └── threadLocals(类型是 ThreadLocalMap) └── Entry 数组 ├── Entry 1: key=ThreadLocal实例, value=存的数据 ├── Entry 2: key=ThreadLocal实例, value=存的数据 └── ...也就是说,每个 Thread 对象内部有一个叫threadLocals的 Map,这个 Map 的 key 是 ThreadLocal 实例本身,value 是你存进去的数据。
所以同一个线程里可以有很多个 ThreadLocal 变量,每个 ThreadLocal 在 Map 里是一条记录。
Entry 的设计——我在这里卡了很久
ThreadLocalMap 里面的 Entry 继承了WeakReference<ThreadLocal<?>>。这句话我第一次看的时候没多想,后来发现这才是整套设计最核心、也是最有争议的地方。
Entry 的 key 是弱引用(WeakReference),而 value 是强引用。
弱引用的意思是:如果这个对象只被弱引用指向,没有被其他地方强引用,那么下次 GC 就可以回收它。
为什么要用弱引用?我当时想了很久。后来理解的是这样:
假设我们有一个ThreadLocal<UserInfo> userLocal = new ThreadLocal<>(),用完数据之后,我们把userLocal = null置空了。如果没有弱引用,Entry 里的 key 还强引用着这个 ThreadLocal 对象,那 GC 就永远回收不了它。有了弱引用,userLocal = null之后,下一次 GC 就能把 ThreadLocal 对象回收掉,key 变成 null。
但问题在于:key 被回收了,value 还在啊。value 是强引用,只要 Entry 还挂在 ThreadLocalMap 里,而 ThreadLocalMap 又被 Thread 强引用着,那这个 value 就永远释放不掉。尤其是在线程池的场景下——线程是长期存活、反复利用的,Thread 对象一直活着,这条 Entry 和它的 value 就一直不会释放。
这就是 ThreadLocal 内存泄漏的根源。
怎么解决
官方给的解决办法很直接:用完了,显式调用remove()。
try{userLocal.set(userInfo);// 执行业务逻辑}finally{userLocal.remove();}remove()方法会找到当前线程的 ThreadLocalMap,以当前 ThreadLocal 为 key,把对应的 Entry 整条删掉。这样 Entry 被移除了,value 也就没有引用指向它了,可以被正常回收。
另外,ThreadLocalMap 的get()和set()方法内部也做了一些"顺手清理"的工作——它们遍历的时候如果发现 key 为 null 的脏 Entry,会顺手清理掉。但这只是辅助,不能完全指望它。
还有一个有意思的点——哈希冲突的处理方式
ThreadLocalMap 解决哈希冲突的方式跟 HashMap 不一样。HashMap 用的是链表+红黑树,而 ThreadLocalMap 用的是线性探测法。
线性探测什么意思?就是算出来应该放在索引 5 的位置,结果 5 已经被占了,那就看 6、7、8……直到找到一个空位。取的时候也类似,算出来位置,如果 key 不是目标对象,就往后一个个找。
这种方式的缺点是,如果 Map 里的数据多了,探测链就会变长,查找效率下降。所以单个线程里不要搞太多 ThreadLocal 变量。
不过 ThreadLocal 在哈希值的生成上做了一些优化——它用了一个叫0x61c88647的魔法数来累加哈希值,这个数跟斐波那契数列有关,能让哈希值在 2 的幂次方大小的数组中分布得非常均匀,尽量减少冲突。
总结一下我学到的
- ThreadLocal 的核心价值是给每个线程一份独立的数据副本,不用锁也能线程安全
- 数据存在 Thread 内部的 ThreadLocalMap 里,ThreadLocal 只是 key
- Entry 是"弱 key + 强 value"的设计,这是内存泄漏的根源
- 解决办法很简单:finally 块里调 remove(),这是铁律,没得商量
- 底层用线性探测法解决哈希冲突,内部通过魔法数做了优化
学这个东西的过程中我最大的感受是:很多框架和工具类的"最佳实践"背后都有它存在的理由。如果只是记住"用完了要 remove",下次遇到"为什么线程池用了 ThreadLocal 会内存泄漏"还是不会。只有把引用链走一遍,才知道那条链路到哪一步断了、到哪一步没断。
上面哪里写得不准确,欢迎指出——毕竟我也是慢慢看代码看明白的。
