Dubbo中的InternalThreadLocal的简单分析

Dubbo中存在一些优化设计,这些设计具有一定的参考价值,这里调研下 InternalThreadLocal 的优化设计。

 

以下内容的章节为:

  1. ThreadLocal的介绍
  2. InternalThreadLocal的介绍
  3. InternalThreadLocal和ThreadLocal的对比和使用范围
  4. 垃圾回收的考虑

 

1.ThreadLocal的介绍

查看org.apache.dubbo.common.threadlocal.InternalThreadLocal 的时候,会发现这里有一个特殊的设计,说是参考Netty的优化设计,对ThreadLocal进行了优化。

ThreadLocal我们知道可以通过这个类在一个线程内共享线程级变量,这里简单地介绍下线程级变量的实现原理。如下图所示:

 

 

假设有一个ThreadLocal<User> currentLocalUser 的线程级变量,那么这个线程级变量的存储逻辑为:

  • 获取当前的执行Thread,获取其内部变量threadLocals,这是一个Map结构,但是这个Map是一个自定义的Map,与我们经常使用的TreeMap和HashMap不是一回事。
  • threadLocals的Map结构,内部由Entry组成,可以简单理解为一个Pair<Key,Value>的键值对。Key可以理解为ThreadLocal对象(其实还有弱引用。后面再讲),Value可以理解为ThreadLocal的泛型里表示的线程级变量。
  • 在键值对的基础上,如何实现Map查询?threadLocals是一个Hash表的结构,内部是一个固定长度Entry数组,Key通过计算hash,映射到一个Entry的index上,实现定位。
  • 提到hash表的实现,就必须考虑到如何解决碰撞问题??JDK的HashMap采用的是拉链法和红黑树,这里的做法是开放地址法,直接在计算出index上+1,再次尝试插入,看是否碰撞,直至成功。
  • 如果采用开放地址法,那么必须考虑扩容问题,否则如果map塞满了,一直用开放地址法,也找不到空闲的位置了,这里采用的方式是有一个阈值,默认2/3的负载因子,超过就*2进行扩容。
  • threadLocals采用的是类hashmap的实现方式,那么其hash性能就很重要,这里有一个有趣的数字0x61c88647,它的值是2的32次方乘以0.618黄金比例。每次有set一个新key的时候,内部有个全局计算器,加上0x61c88647之后的值,除以内部的Entry数组的长度,作为hash过的index的值。Entry数组的长度是2的次方,每次扩容也是乘以2。大家可以查一下资料,0x61c88647是一个性能比较好的数值,在不停累加,而且取2的整数次方的余数后,离散性能很好,这里不再赘述,网上很多文章,我也不能完全推理出来。

总结一下,如果有一个ThreadLocal的线程级变量要初始化并插入,那么其简单步骤为:

  1. 通过当前的线程,获取其内部的threadLocals的Map结构。
  2. 这个ThreadLocal的内部变量threadLocalHashCode初始化,每次在全局计数器的基础上增加0x61c88647这个魔数,作为threadLocalHashCode的值。
  3. 计算出的threadLocalHashCode对当前的Map里的Entry数组的长度取余数,就得到具体的index下标的存储位置。当然了,如果扩容了,会有resize来处理,这里只讨论简单情况,不考虑扩容和hash碰撞的问题。
  4. 创建出一个Entry对象,可以简单理解为Pair<WeakReference<ThreadLocal>,User> 这么一个键值对,Entry就放在上一步计算的index的Entry数组里。
  5. 线程级变量插入完毕,继续其他的业务。
  6. 这里会发现,对这个Map的操作没有加锁操作,是因为这个Map本来就是当前的线程的内部变量,只有当前线程可以操作内部的Map,每个线程都操作自己的map,故没有线程间共享抢占的问题。

 

如果想要获取currentLocalUser当前的线程级变量里的user,其简单步骤为:

  1. 通过当前的线程,获取其内部的threadLocals的Map结构。
  2. 通过currentLocalUser这个ThreadLocal,得到其内部变量threadLocalHashCode,除以Map结构的Entry数组的长度,获取其index,获取一个Entry对象,里面是Pair<WeakReference<ThreadLocal>,User>这种结构。
  3. 注意:这是hash版本的Map,那么可能存在碰撞,所以会判断WeakReference<ThreadLocal>里的ThreadLocal对象,是不是currentLocalUser这个变量,如果不是,+1计算下一个index,直至相等,此时取出User对象
  4. 此时的这个User对象就是线程级变量。如果这个User对象没有通过其他的引用与其他的线程分享,那么User就是线程安全的。

整体总结:

通过自增一个特殊数字的方式,再对数组取余,实现了hash元素定位,在冲突的时候,采用+1的方式,再次定位,直至找到一个空闲的槽位,或者在查找的时候,直至吵到等于本对象的槽位。你可能意识到,这种hash的方式肯定会存在碰撞,而且碰撞后,通过自增的方式重定位之后,极端情况下存在线性时间复杂度的开销,一直有冲突,需要找到自己的槽位。

 

 

2.InternalThreadLocal的介绍

Dubbo存在一种InternalThreadLocal的优化对象,来模拟ThreadLocal的操作,来优化性能,他的基本结构和JDK自己的方案差不多,只是在hash方案上有些出入。

上面提到,ThreadLocal与Thread线程里的内部变量threadLocals有关系,那么Thread线程与InternalThreadLocal压根没关系,所以需要InternalThread的配合,InternalThread继承了Thread对象,内部增加了一个threadLocalMap的内部Map对象,来存在InternalThreadLocal和对象的线程级变量的映射关系。InternalThreadLocal和InternalThread的关系,与ThreadLocal和Thread的关系几乎一样,这里不再画图。

重点在InternalThread内部的map的hash方案上,这里的hash方案也是index的自增,不过不是自增0x61c88647,而是直接加1,每次计算出的index,就是内部的Map的Entry数组的下标。直接定位,不会出现冲突。因为:每个InternalThreadLocal对象的index的值,都是自增的,是不会出现冲突的。

 

那么问题又来了:如果每次都自增,随着程序的运行,这个index会不会越来越大,内部的Entry数组越来越大,最后OOM?

答案是:正常情况下不会。通过检索Dubbo的源码,会发现所有的InternalThreadLocal的使用,都是static的使用,所以InternalThreadLocal实例的个数是确定的,其index也不会无限制的增加。

整体总结:

InternalThreadLocal作为dubbo内部的高性能的线程级变量的实现,虽然表面上是Map的名字,实际上是数组的访问速度。所以在多线程频繁访问线程级变量的情况下,一定速度很快。但是也有局限性,最好作为dubbo的内部变量使用,外部不要直接使用。如果外部确实要使用,也要使用static的方式,如果伴随着业务代码,一直在new InternalThreadLocal,会造成内部的index一直累加,导致Map内部的数组也一直膨胀,直到OOM。就算不OOM,内部也会触发逻辑:

 

 

3.InternalThreadLocal和ThreadLocal的对比和使用范围

 

  优势 劣势
InternalTreadLocal 速度高,数组访问级别的速度。因为没有hash碰撞的问题,性能一直是O(1)

在dubbo内部使用,最好不要在自己的代码中使用

确实要使用,使用static的方式,防止OOM

ThreadLocal 通用性强 在没有碰撞的情况下,访问速度是O(1),在最坏的情况下,访问速度接近于O(map的容量)

 

 

 4.垃圾回收的考虑

经过简单分析这个结构后,这个线程级共享变量机制的一个重要问题是垃圾回收。Java不是自动垃圾回收么,为什么要考虑垃圾回收?

因为这些线程级变量是跟线程有关的,而在GC的时候,JVM扫描变量可达性的时候,部分可达性分析会以Thread为根开始扫描,此部分可以搜索GC ROOT的概念。与GC ROOT有强引用的内存是不会回收的。

先分析ThreadLocal的垃圾回收场景:

  • 一个正在执行的线程肯定是不可以回收的,那么Thread内部的threadLocals的这个Map结构肯定也不会回收的,这是一个强引用StrongReference;
  • map结构的内部,主要分为3部分:Entry结构体(相当于Pair<Key,Value>结构),Key部分就是WeakReference<ThreadLocal>,value部分就是ThreadLocal的泛型内表达的值。
  • Entry部分回收不计,这部分的回收取决于内部的keyValue的回收,在回收的时候,一并回收。
  • Key部分很特殊,是WeakReference弱引用,弱引用的部分,如果GC的时候,内部的ThreadLocal会被回收。但是,业务逻辑执行中,是不会被回收的,分为两种情况:如果你声明的ThreadLocal是static的,那么存在一个永生带到这个static的ThreadLocal实例的强引用,而永生带属于GC ROOT的一部分,跟Thread作为GC ROOT的待遇一样。如果ThreadLocal是new的临时变量,在业务逻辑执行中,JVM的栈上会对这个临时变量有一个强应用,栈区也是GC ROOT的一部分,所以在业务过程中,WeakReference即使想回收ThreadLocal也不会真的回收。
  • 但是如果业务逻辑执行完了,而且ThreadLocal是new出来的临时变量,那么其ThreadLocal的实例可能被回收,此时,WeakReference<ThreadLocal>的Key部分,如果查询,会发现存储的ThreadLocal已经为null。此时问题来了:Entry作为Map内部的数组的一部分,是一个强引用,而Value部分是Entry的一个强引用,此时Entry和Value都无法回收,岂不是造成了内存泄漏的问题,但是实际上正常使用是不会内存泄漏的,因为ThreadLocal的set和get方法内部自带了垃圾回收,如果发现key部分已经回收了,就把value置为null,entry置为null,帮助JVM回收。
  • ThreadLocal的回收部分实际更复杂,可以搜索【ThreadLocal set get】检索文章查看细节,更复杂的地方是:插入的时候,有hash碰撞,采用了index+1的开放地址法处理冲突,如果中间有个Entry回收了,需要把后面的有效的Entry向前移动,否则后面的节点在在get的时候,用index+1的方式进行探测的时候,会增加额外的代码复杂度和存储空间消耗。

 

再分析InternalThreadLocal的垃圾回收场景:

答案是没有这么复杂的垃圾回收,因为没有垃圾产生。

考虑上面的结论:InternalTreadLocal内部使用一个数组,而且set和get均使用一个index的下标方式(不是hash的方式,再对数组长度取余),直接进行读写,而且都是static的方式,InternalTreadLocal实例是不会被JVM回收的,可以理解为Key部分不会被回收,只有Value部分可能被临时覆盖,导致老值被回收。

这里也从另外一个侧面解释了为什么InternalTreadLocal更快:因为InternalTreadLocal的set和get就是数组直接访问,且根本不考虑垃圾回收。ThreadLocal要清理里面的Map的垃圾数据,又没有定时线程主动触发清理(实际上也没有其他线程可用,因为每个线程只能管自己的map结构),只能依赖set和get函数来被动地触发垃圾清理,更导致了性能在极限情况下更慢一点。

posted @ 2021-01-19 20:09  learncat  阅读(1153)  评论(0编辑  收藏  举报