注:本文基于Android 27 ThreadLocal的源码分析而来,而且这篇文章算是本人对ThreadLocal相关原理细节的笔记记录,更多的站在自己的角度来分析问题。同时,由于我绘图功底有限,所以文章中都是文字和代码截图,更多的使用文字来描述问题,望理解。
一:概念
ThreadLocal,线程本地存储区域。通常的解释是,每一个ThreadLocal的对象都会包含某种类型的对象,然后该对象在当前应用中的每一个线程里都有一份备份、副本,并且副本和线程一对一,对一个线程副本的修改,不会影响另一个线程的副本对象,线程间隔离的。但同时从设计的角度来看,可以把ThreadLocal理解为一种设计模式或者方案。在该方案下,可以在不修改Thread源码或者继承Thread类的情况下,扩充了Thread的能力,让Thread具备了过去没有的任意数量,任意类型的属性,而这些属性对应的值,对应的对象保存在ThreadLocal中,可以根据具体的ThreadLocal对象来得到某个线程对应的虚拟属性的值。
通过ThreadLocal不仅实现了线程间隔离,同时也实现了方法间共享。当多个方法需要访问同一个对象时,使用ThreadLocal来获取就可以,而不用让这个对象在方法间通过参数传递。既提高了代码可读性,同时也降低了耦合度。当然,实现线程间隔离,方法间共享不止ThreadLocal一个办法,也可以通过其他的方案来实现,但ThreadLocal无疑是最方便的。
二:代码实现
ThreadLocal内部实现了大部分的核心细节,但使用的过程中还需要借助Java 的静态特性。针对我们要添加的数据类型,如果是我们自己自定义的类型,一般直接声明一个private static final修饰的ThreadLocal变量;如果是已经存在的类型,可以创建一个辅助类,包装类来做。比如:
通过ThreadId这个辅助类,让每个线程有了一个和它对应的Int类型的Id,而这个Id的值可以通过ThreadId.get()来获得。这个例子只是演示,没有太大实际的意义。
三:实现方案
在了解了ThreadLocal的作用后,接下来我们来分析一下,可以怎么实现呢,本文会从ThreadLocal给线程添加了虚拟属性的角度来分析问题。一个ThreadLocal的对象对应一种数据类型,可以给多个线程添加属性;同样,一个Thread可以通过ThreadLocal来添加任意个,任意类型的属性。也就是说,一个Thread可能会对应多个ThreadLocal,一个ThreadLocal也可以对应多个Thread。
由于涉及到了对应关系,使用Map来维护比较方便。首先,直观的能想到的第一种解决方案是由ThreadLocal来维护一个Map,以Thread为键,数据对象为值,建立Thread-对象间的映射关系。这个方案可以实现,但是会有两个问题。第一:以Thread为键,ThreadLocal来维护Map,就意味着这个Map是个多线程共享的资源,可以在任意线程对这个Map进行结构性的改变,就可能会有线程安全问题,需要加锁来解决,对性能会有影响。
第二个问题就是内存泄漏。对于内存泄漏的问题,我们可以从三个角度来看:
A.Thread运行完毕后,无论是正常退出还是意外退出,thread对象可以被回收。
B.Thread运行完毕后,对应的数据对象也就没有存在的意义,应该也可以被回收。
C.ThreadLocal因为某种原因被回收后,在Thread存活的情况下,它给各个线程添加的数据对象应该也可以被回收。
当然,这三个方面的内存泄漏问题不是都存在,而且都可以解决或者进行优化,具体的解决办法下一个方案会分析。但即便解决了内存泄漏,还是会有锁的问题。所以Jdk没有采用这个方案,而是采用了下一个方案。
那为了解决锁的问题,就不能让这个Map是共享资源,所以就让Thread来持有这个Map,变成Thread的私有属性。这样做一方面解决了锁的问题,另一方面,从设计的角度来讲,因为Thread本身不可能预知到开发者会添加什么类型的属性以及属性的数量,所以只能通过Map这个数据结构来占位。
这种方案没有了锁的问题,但依然要考虑内存泄漏的问题。从线程的角度来将,当线程终止后,线程可以被回收。同时在没有其他强引用的情况下,这个线程对应的数据对象也可以被回收。唯一需要考虑的是ThreadLocal的回收,以及当ThreadLocal被回收后,线程还存活的情况下,对应的数据对象如何来回收。
对于这些问题,JDK使用的Map是ThreadLocalMap。这个Map属于某个线程,并且以ThreadLocal为键,数据对象为值。ThreadLocalMap中对于ThreadLocal使用的是WeakReference,这样在ThreadLocal其他的引用断开之后,不影响其回收。当ThreadLocal被回收后,对应的数据对象和Entry被认为是需要删除的stale Entry,然后会在对这个Map进行获取元素,添加元素或者删除元素等操作时,都会尽可能的对数据结构遍历,将其中key=null的Entry和Entry的value删除,断开引用。不过,最好在我们使用完了之后,还是调用ThreadLocal的remove函数来从Map中删除这个映射。
所以,JDK的方案核心在于ThreadLocalMap。首先,Thread内部定义了threadLocals字段来代表这个Map,这个字段的初始化及后续操作全部交给ThreadLocal来完成,并且由于这是线程对象的专属字段,线程间不共享,所以没有线程安全问题。然后,ThreadLocal更多的承担了桥梁和辅助作用,主要包括得到线程对应的ThreadLocalMap、对应的数据对象的初始化等,其他的操作全部交给了ThreadLocalMap来完成。
四:ThreadLocalMap
针对ThreadLocal的原理细节,我们先来分析一下ThreadLocalMap。ThreadLocalMap是ThreadLocal的静态内部类,是专门解决这个问题的数据结构。ThreadLocalMap内部有一个内部类Entry,来维护ThreadLocal和对应的数据对象的映射关系。
如上图所示,Entry本质上是一个WeakReference,而且是针对ThreadLocal的软引用,这么做是为了不对ThreadLocal的回收产生影响,因为ThreadLocal对Thread没有依赖关系。而value就是对应的数据对象。这个不是软引用,是因为只要Thread存活,这个对象就不应该被回收,这个数据对象依赖于Thread。
之所以为了这个问题,专门写了ThreadLocalMap这个数据结构,我个人认为是两方面的原因。第一是因为对ThreadLocal的软引用,并且当ThreadLocal被回收之后,它对应的Entry的key的get()函数也就返回null,在ThreadLocalMap中,key==null的Entry被称为stale entries,而ThreadLocalMap内部会在增删改查等操作中尽力对这样的Entry进行删除。
第二方面的原因是哈希碰撞。传统的HashMap是通过拉链表的办法来解决哈希碰撞,而ThreadLocalMap采取了另外的方案。
如上图所示,ThreadLocal通过threadLocalHashCode字段来代表hashCode,而hashCode的计算,ThreadLoal引入了黄金分割数的概念。由于本人是个数学学渣,具体的细节可以参考这篇文章。其中,HASH_INCREMENT字段代表黄金分割数的取正,而ThreadLocalMap 的长度都是2的整数次幂,扩容也是2倍的扩,然后计算对应的index的散列函数为 threadLocalHashCode & (INITIAL_CAPACITY - 1),其中INITIAL_CAPACITY是数组的长度。通过刚才的限制,会让最终的元素在数组容器中接近完美的散列摆放,没有冲突而又把容器填满。但当扩容之后,还是有概率会出现哈希冲突的,ThreadLocalMap通过开发地址法--线性探测下一个可用的空位置来解决冲突。
所以ThreadLocal的哈希算法,首先从根本上就以接近完美的方式来散列摆放,减少并尽量避免了哈希冲突,但扩容了之后还是会可能冲突,用开发地址来解决,所以ThreadLocalMap中还是会出现某个元素所在的index和它应该在的index不一致的情况,这种情况ThreadLocalMap内部也会有rehash的逻辑。因为冲突和开发地址法,所以ThreadLocalMap对于每一个ThreadLocal对象都没有采用fast path,也就是根据ThreadLocal的哈希算法,直接定位到对应的index,获取这个位置上的Entry,而是通过循环查找,毕竟它有可能出现在其他的位置。代码中有很多的循环,当出现了冲突,就找到下一个可用的空位置,而这个算法本身也避免了死循环,在这个算法下,循环的关键判断条件就是相应的Entry是不是null。至于,解决哈希冲突拉链法和开发地址法之间的优劣,笔者也只是略知一二,就不班门弄斧了,等回头研究透了再来和大家一起分析。
五:ThreadLocal实现细节
了解完了ThreadLocalMap,以及为什么有这么个Map。接下来来分析一下ThreadLocal的重要函数。
1)get()
我们只能通过ThreadLocal的get()函数来获取线程对应的数据对象,代码逻辑如下:
首先通过Thread.currentThread()来获取当前线程,然后通过getMap()来获取当前线程的ThreadLocalMap。getMap函数也很简单,就是获取当前线程的threadLocals字段,可能为null。
根据得到的map是不是null,走不同的逻辑。我们先来看map==null的情况下,调用setInitialValue()函数。
setInitialValue()函数中先调用initialValue()来获取初始值,这个函数默认返回为null,我们可以根据自己的业务逻辑返回具体的对象。或者可以像Looper那一样,先调用一次get(),在get()返回null的情况下,在调用set()来设置初始值,都可以。拿到了初始值,在map==null的情况下,调用createMap()函数来构造Map对象。而createMap()也只是调用了ThreadLocalMap的构造函数而已,
在ThreadLocalMap 的构造函数中,初始化了数组,数组长度为16。根据传入的ThreadLocal和数据对象构造Entry,并放到指定的位置。并且设置了一个阀值,大小为数组长度的三分之二,并且会根据这个值来确定要不要扩容。
刚才分析了map==null的情况,那如果当前线程的map不为null,则首先会调用getEntry来获取对应的Entry对象:
getEntry()根据传入的ThreadLocal的threadLocalHashCode,经过计算后得到对应的index。如果该index位置上不为空,并且它的key和传入的ThreadLocal一样,就返回该Entry,然后回到get()函数中,将刚才Entry的value返回。否则,调用getEntryAfterMiss()函数。
源码对于getEntryAfterMiss函数的解释如下:
意思就是在ThreadLocal直接对应的位置(direct hash slot)上没有找到Entry,有两个原因,一是因为刚才的Entry==null,也就是这个index位置上为空,则返回null,返回到get()函数中继续走createMap()的逻辑。二是因为冲突了,目标index上已经有了其他的Entry对象了。接下来就会对Map进行遍历,因为该ThreadLocal由于冲突的原因,可能放到其他的位置了。然后将k==key的Entry返回,也有可能遍历完了还是找不到,找不到的话依然返回null,还是走createMap()的逻辑。其间如果遇到了k==null的stale entries,就会通过expungeStaleEntry()函数将这个Entry删除。
expungeStaleEntry()函数一方面会把那个Entry删除,另一方面也会从这个位置开始,直到下一个null的Entry截止,将其中遇到的stale Entry一并删除。
除了删除这些陈旧的Entry(stale Entry)之外,expungeStaleEntry函数还会从当前位置到下一个为null的位置之间index不一致的数组元素进行rehash操作。
也就是根据遍历到的Entry计算它的index,和它实际所在的index进行对比。如果不一致,就会努力把它放在应该在的index位置上,如果那个位置不为空,就通过nextIndex()函数放到其后的第一个空的位置上。
回过头来再看setInitialValue()函数,刚才分析了返回的Entry==null的情况,但也有可能不为null,通过遍历找到了对应的Entry。这个时候就会调用entry的set函数,可能是添加映射,也可能是更改这对映射的value,这部分内容可以看下面关于set()函数的分析。
通过对get()函数的分析我们直到,当我们通过ThreadLocal的get()函数获取当前线程对应类型的对象时,如果对应的index位置为空或者出现了冲突,就会调用getEntryAfterMiss(),从而尽可能的对当前的Map遍历,清除stale Entry,并进行rehash操作,从而尽可能的避免内存泄漏。
2)set()
接下来,我们看一下set()函数的代码逻辑:
首先还是得到当前线程的Map,在Map==null的情况下,走createMap逻辑,这一部分上面的get()函数已经分析过了。我们重点来看map.set()函数的逻辑:
在ThreadLocalMap的set()函数中,首先根据传入的ThreadLocal进行哈希计算找到对应的index,也没有通过fast path直接定位,而是从这个index开始向后遍历查找。如果查找到的Entry的key和传入的ThreadLocal相同,就意味着这个ThreadLocal对应的Entry已经存在了,直接替换value即可。如果Entry不为空,但是k==null,说明该Entry可以被删除了,就调用replaceStaleEntry()来替换掉这个stale Entry,同时退出set()函数。如果该index为空或者遍历完了还是没发现符合的Entry,则直接构造新的Entry对象,并存储到数组中。那接下来我们分析两个重要的函数:replaceStaleEntry和cleanSomeSlots。
首先来看replaceStaleEntry,这个函数一方面肯定会完成ThreadLocal参数对应的Entry的添加或者数据更新,另一方面对数组中每一次run遇到的stale Entry进行清理删除,所谓的run就是两个null Entry之间的所有节点。而且这个函数是在set函数中,从传入的ThreadLocal对应的index向后查找时调用的,也就说明调用replaceStaleEntry()的位置与刚才的index之间都没有空位置。由于这个函数截图不方便,所以没有整体函数的图片,一点点来分析:
刚才说了这个函数也会对stale Entries进行清理,而且是一次就完成清理,所以定义了slotToExpunge变量,来代表这次清理的开始位置,初始值为staleSlot。所以一开始从当前的staleSlot插槽位置向前查找,看看有没有key==null的Entry,如果有的话,就用slotToExpunge变量来记录其位置;如果没有,那么slotToExpunge就还是staleSlot。
如前面分析,set()函数调用replaceStaleEntry的时候循环还没走完,只是遇到了k==null,所以 replaceStaleEntry()会继续向后查找有没有k==key的Entry,直到遇到空位置。如果我们发现了k==key的Entry,由于刚才staleSlot位置的Entry对象k==null了,可以被删除了,而且staleSlot才是我们应该呆的位置,那么就将这两个Entry 的位置交换,从而维护正确的哈希排序。而这个时候最开始staleSlot的位置已经变成了现在的位置i,如果slotToExpunge==staleSlot,就把slotToExpunge设置为i,然后调用expungeStaleEntry将slotToExpunge的Entry删除。expungeStaleEntry内部又会从slotToExpunge开始,到下一个null slot的位置结束,对其中的stale Entry检查删除,然后把null slot 的位置返回给cleanSomeSlots。cleanSomeSlots又会从null slot的下个位置开始,进行Log2N次的扫描,以此来清理stale Entry。如果没有发现k==key的Entry,但是又发现了k==null的Entry,就会在slotToExpunge == staleSlot的前提下更新slotToExpunge的值,而slotToExpunge可以理解为这次清理的起点。如果循环完了,还是没有找到k==key的元素,那么就可以直接更改staleSlot位置的元素了:
直接将staleSlot的位置的value==null,释放这个value对象,并新建Entry对象,放在staleSlot位置上,而这也是这次插入的ThreadLocal元素应该在的,正确的位置。如果循环完了,这个时候slotToExpunge == staleSlot,就意味着staleSlot是唯一需要清理的Entry,刚才的操作也就清理掉了。如果不相等,还有其他的需要清理的Entry,才会继续调用expungeStaleEntry和cleanSomeSlots。
这个函数中还有一个问题,就是slotToExpunge的赋值,刚才的分析虽然能理清ThreadLocalMap相关函数的工作原理,但由于个人没能完全理解透黄金分割数,ThreadLocal的哈希函数还有循环的时候通过null来判断,所以这个地方还是有一些困惑,只能把slotToExpunge理解为一次清理的起点。后续明白了之后,会更新一下。也欢迎大神不吝赐教,必将感激涕零。
cleanSomeSlots函数的作用是试探性的对元素扫描,将其中遇到的stale Entry清除,而且为了平衡,采取了Log2N的方式,N就是传入的参数n。
让我们回到set函数,当新添加了Entry之后,并且在调用cleanSomeSlots()函数后没有stale Entry被删除,元素个数而又超过threshold阀值的时候,就会进行rehash操作。
rehash也比较简单,先调用expungeStaleEntries删除所有的stale Entry,
因为size可能会有变化,所以在expungeStaleEntries之后对size再次进行判断,如果还需要扩容,则调用resize。
resize将数组长度乘以2,对原来的元素重新哈希计算,放在新数组中,遇到冲突的线性后移。其间也会进行key==null的检查,最后重新设置table,size和threshold字段。
3)remove()
ThreadLocal还有一个remove函数,这个比较简单,将映射关系删除,一看代码就了解了:
六:方案对比
JDK方案的原理细节分析完了,回过头来,再来看一下内存泄漏问题。这种方案下唯一的内存泄漏情况是当ThreadLocal被回收了之后,相关的线程还长期存活,而且可能不止一个线程,这种情况下Java也只是做到了惰性删除,没有办法马上把对应的Entry和value从Map中删除,只有在你调用这些线程的ThreadLocalMap的get(),set()和remove()函数的时候,ThreadLocal才会尽可能的删除其中的stale Entry,并且这也不是一定的:
get()--只有在Thread的ThreadLocalMap存在,ThreadLocal对应的Entry不存在或者冲突的情况 下,才会调用getEntryAfterMiss,进而调用expungeStaleEntry对key==null的Entry进行检 查并删除。
set()--set函数被调用的时候,只有一种情况不会尝试遍历数组,以此来清除其中key==null的 Entry,那就是ThreadLocal对应的Entry已经存在,并且index正确,没有冲突,那么直 接替换value而已。否则,其他的情况都会通过replaceStaleEntry或者 cleanSomeSlots来尝试清除stale Entries.
remove就不说了,本来就是删除。所以分析下来,ThreadLocal也没办法做到百分之百的避免内存泄漏。而且源码的注释也说明了,只有在Map空间将要用尽(run out of space)的时候,才保证会把数组中的stale Entries全部清除。因为这个时候会扩容,也就会调用rehash()函数,进而调用expungeStaleEntries()来完成清除。
如果Thread 长期存活,比如线程池的情况,而该Thread的ThreadLocalMap相关函数没有被调用,就有内存泄漏的风险了,所以最好还是在不需要的情况下,调用remove()。
最后,我们再来分析一下最初设想的方案,由ThreadLocal来维护一个Map,建立Thread-value 的映射关系。这个方案除了锁的问题外,也会有内存泄漏的问题。因为ThreadLocal的Map持有Thread和对应的value的引用,在Thread运行完毕的时候,就有可能内存泄漏。当然,这种内存泄漏,可以参考JDK方案的思路来解决,但还是有锁的问题,所以这个方案不是最优解。
参考文章:http://www.jasongj.com/java/threadlocal/
https://www.jianshu.com/p/48eca67fb790