ThreadLocal 变量存储为什么不用 Map
反直觉问题的答案
为了提高性能>为了提高性能>为了提高性能。
有兴趣的同学可以接着往下看,本文对 ThreadLocal 的设计做了一点生硬的解读。
ThreadLocal 解决的问题
这涉及到 ThreadLocal 设计的初衷,为什么需要 ThreadLocal?
ThreadLocal 翻译过来就是“线程本地(变量/对象)”,那么线程的变量/对象为什么需要本地化呢?
这涉及到一个更本质的东西:
一个对象它如果不是本地化的,那就是可共享的,而如果对象是可共享的,那就存在并发问题,存在并发问题就需要用一系列的并发处理手段去防止,就会导致无论是执行阶段的性能还是代码维护阶段的成本都会大打折扣。
所以我们需要一种更优雅的解决方案:
变量本地化,避免共享。
而变量本地化的本质就是当前线程用到的变量都是从属于当前线程的,类似于“线程封闭”。
由此我们得出结论:
ThreadLocal 的提出是为了解决不同线程的变量共享导致的并发问题,而解决的方式就是不共享。
数据结构:对象如何存储
在了解 ThreadLocal 的用途后,我们尝试自己设计一个 ThreadLocal 并不断完善,逐步演绎出 Java 目前的 ThreadLocal 数据存储结构,以说明为何 Java 中的设计是较为合理的。
小白设计:Map 存储
依照直觉,既然 ThreadLocal 需要将变量绑定在当前线程,那么最简便的方式就是用一个 Map 存储,Map 的 key 就用线程,而 value 就是需要存储的本地变量。
ThreadLocal 的内部结构应是这样:
存在的问题
一个线程内部,可能存在多个本地变量,所以 values 中的 value 应是一个 Map 对象。
二次设计:对象重构
修改后的 ThreadLocal 内部结构如下:
存在的问题
如果 value 是一个 Map 对象,那么 Map 对象的 key 是什么?可选项包括 Thread ID 与 ThreadLocal 的变量 ID,而 Map 对象存在多个 KV 值,所以 Thread ID 不能作为 key,那么 ThreadLocal 的变量 ID 是合适的可选项。
这时再看如果要取出一个 ThreadLocal 变量,需经过两步:
1)从 ThreadLocal 中通过 values.get(Thread ID)取出当前线程所有的本地变量(locals);
2)再从当前线程所有变量中取出某个 ThreadLocal 变量,locals.get(ThreadLocal 的变量 ID);
此时观察第一步,如果线程本地变量 locals 属于线程本身,那么 ThreadLocal 中就无需维护 values 这样的 Map 对象。即是说 values 这种 Map 对象可以不存在。
三次设计:对象转移
按上述思路,修改后的 ThreadLocal 内部结构如下:
修改后的 Thread 内部结构如下:
可以看到此时 Thread 内部结构与小白设计中的对象几乎相同,只是变量含义发生了变化。
存在的问题
到这里实际上本地变量本身存储是没有问题的,但一个比较严重的问题是 Map 中的 ThreadLocal 对象和 Object 对象是强引用类型,在 Thread 销毁前都无法 GC 掉,会导致内存泄漏,所以我们需要把 ThreadLocal 对象和 Object 对象变成弱引用,这样就可以使不需要的 KV 对象快速回收。
四次设计:设置弱引用
按上述思路,修改后的 Thread 内部结构如下:
存在的问题
然而如果将 value 设置为弱引用会存在的问题是:
如果 value 对象除了 Thread 内部的 values 使用的话,还被其他对象引用,那么在 GC 后,value 对象将不存在,通过 values 查找时会出现 value 为 null 的情况,所以 value 不能为弱引用。
这里也可以看到的是:正是 value 不能为弱引用会导致内存泄漏。
那么如何解决呢?我们在‘内存泄漏问题’小节说明。
五次设计:弱引用调整
按上述思路,修改后的 Thread 内部结构如下:
到这里发现其实使用 Map 也是可以的,那么使用 Map 的话会失去什么?
是的,答案是性能。
我们发现 ThreadLocal 变量对 Map 有如下的特点:
1)操作上,只需支持 get()、set()、remove()等简单操作,不需要像 Java Map 一样支持复杂的操作;
2)类型范围上,键值对只需要支持对应的变量和值就足够了,不需要像 Java Map 那样支持任意类型的键值对。
3)存储量级上,ThreadLocal 存储的数据量通常比较少,不需要像 Java Map 那样处理大量的键值对。
根据以上特点,ThreadLocal 设计了 ThreadLocalMap 来提升哈希性能,简化逻辑。
六次设计:哈希性能
经过上述分析,我们了解到用 Map 也行,但 JDK 有更优的做法,我们直接看源码。
JDK 中的 Thread 内部结构如下:
ThreadLocalMap 对象:
在 ThreadLocalMap 的实现中,我们观察到 ThreadLocal 中看到 HASH_INCREMENT 变量,值为 0x61c88647。
所以为什么是 0x61c88647?
0x61c88647 是有斐波那契构造而成的黄金比例数字,经过实验测试,这个数字生成的 hashcode 可以很大程度的保证 hash 值能够在数组中均匀的分布。
0x61c88647 被用作哈希函数中的乘数,它可以使得不同的 ThreadLocal 对象产生不同的哈希值,并且这些哈希值在均匀分布的情况下能够较少地发生碰撞。因此,在理论上,ThreadLocalMap 的哈希性能应该比 HashMap 更好。
和 HashMap 的对比
然而,在实践中,ThreadLocalMap 和 HashMap 之间的效率差异主要取决于它们所存储的数据量和操作类型。由于 ThreadLocalMap 通常只包含少量的变量,而且只会被同一个线程访问,因此其哈希表的大小相对较小。另一方面,HashMap 通常需要处理大量的键值对,因此其哈希表的大小相对较大。
因为 ThreadLocalMap 中的哈希表相对较小,在散列表中的元素数量较少的情况下,哈希函数的影响可能会更加明显,从而导致更少的哈希冲突。但是在数据集合很大时,由于哈希表的扩容和散列运算等操作会带来额外的开销,所以 HashMap 的表现可能会优于 ThreadLocalMap。
所以实践上看 ThreadLocalMap 的性能是会优于 HashMap。
Entry 对象的设计
另外可以看到,Entry 对象本身也是键值对,并且 key 为弱引用,value 为强引用,与上文设计中的思路一致。
存储方面采用了类似 HashMap 的方式,但在哈希时采用了 HASH_INCREMENT 魔数,提升存取性能。
内存泄漏问题
在了解 ThreadLocal 的设计后,我们继续看‘四次设计’小节提到的内存泄漏问题。
内存泄漏的原因
再回顾下。
values 对象的 key 是弱引用的,当发生 GC 时会被回收,如果 value 对象不存在外部强依赖,就无法触达,此时需要被 GC 回收掉,但由于 values 对象在强引用 value,导致 value 无法被回收,从而出现内存泄漏。
如果线程对象存在的时间较短,values 会被及时回收,那么 value 也会被及时回收,问题并不大,但为了避免线程反复创建带来的资源消耗,一般采用线程池技术,线程对象的生命周期变长,从而内存泄漏问题的影响不容忽视。
如何处理
实际上,ThreadLocal 提供了两种机制,一种是主动删除引用的方式,另一种是被动删除的方式。
主动方式
即在使用完后,主动设置 ThreadLocal.remove()。
可以在 java.lang.ThreadLocal.ThreadLocalMap#expungeStaleEntry 函数中看到对 value 设置了 null 值,从而保障 GC 正常回收。
被动方式
不推荐。
无需做任何操作,ThreadLocal 在 set、get 时会调用 ThreadLocalMap#expungeStaleEntry 进行清理操作。
具体而言:
1)set 时,若发生哈希冲突且旧 key 已经被回收了,此时会替换掉旧的 value。如果没有哈希冲突,仍然会调用 cleanSomeSlots 清理部分节点。
2)get 时,如果没有命中,会调用 getEntryAfterMiss()函数查找,最终会调用 expungeStaleEntry 清理过期节点。
可见 ThreadLocal 的清理机制只是最大限度保障不出现内存泄漏,而不能保证,所以此种方式不推荐。
以上。
如果本文对您有帮助,欢迎关注[微服务骑士]并转发~
声明:以上言论仅代表个人观点。
版权声明: 本文为 InfoQ 作者【Karl】的原创文章。
原文链接:【http://xie.infoq.cn/article/b8203f7de7f46602155cf2155】。
本文遵守【CC-BY 4.0】协议,转载请保留原文出处及本版权声明。
评论 (3 条评论)