前言:写了好多天,算是从源码上相对详细的分析了整个ThreadLocal存取的过程。整体分析大致分为三层,从最外层的ThreadLocal的存取方法,到ThreadLocalMap对应的存取方法,到ThreadLocalMap更深一层的清理方法和rehash方法。后面有些地方还存有疑惑,暂且没有查阅到答案,在文中以(?具体问题?)标注,等后面有更深入的学习后填坑。
问题
- ThreadLocal的作用和意义是什么?
- ThreadLocal是如何实现这样的作用的?
- ThreadLocal执行过程的源码分析?
个人理解
ThreadLocal的作用和意义
阅读学习过几篇博客后,我对ThreadLocal的理解已经写在了题目上——与很多网上的大牛所说的一样,ThreadLocal的作用就是提供一个线程的局部变量。
何谓线程的局部变量?我们知道,对于多线程并发的程序来说,如果一个局部变量暴露在所有的线程下,任何一个线程都可以在另一个线程正在对这个变量进行写操作的同时读这个变量,造成线程读到的数据和我们期望的大相径庭,那么我们称这个变量是线程不安全的。为了处理这种并发问题,Java提供了两种思路,一种是我们耳熟能详的加锁,比如使用Syncronize、Lock、volatile等;一种即为将变量在每个线程里拷贝一份副本,也就是将原先共享的局部变量变成线程自己独享的局部变量,每个线程对自己独占的副本的操作不影响其他的副本。
这两种方式,前者为共享,后者为独占,共享是所有线程都操作一份数据,独占是每个线程都有自己的一个副本,都有各自适应的并发问题,共享是通过Syncronize等实现,而独占就是通过ThreadLocal实现了。
如何实现
下面是ThreadLocal的类声明和构造函数:
|
|
可见ThreadLocal对象的初始化比较简单,不需要传入什么参数,但是需要通过泛型指定需要拷贝的局部变量类型。
|
|
上面是Android消息处理机制中对ThreadLocal的典型使用,我们知道,在Looper类中,为了使Looper对象在每个线程中只有一个单例,很巧妙的利用了ThreadLocal类作为线程局部变量的特点。
继续以Looper为例,下面是Looper中使用到ThreadLocal的两个地方:
|
|
可以看到,Looper中对ThreadLocal的使用主要就是set和get两个方法,set方法将一个Looper对象作为参数存到ThreadLocal中,get方法甚至不用传参就可以取出这个对象。
当然,事实并没有一个get一个set那么简单,经过前面的理解我们已经知道,这看似简简单单的两个方法造成的效果就是:如果在多个线程里使用Looper.prepare(),则每个线程都会有一个属于当前线程的Looper对象实例,通过在某个线程中调用Looper.myLooper()可以获取到这个线程的Looper对象(在调用过prepare方法后)。
那么ThreadLocal是如何将线程和每个线程的变量副本绑定的?线程局部变量(线程的变量副本)存储在哪?是不是每个ThreadLocal只能存储一个变量副本?如果要存储多个变量副本怎么办?
源码分析
出于带着问题看源码的原则,回顾一下先前提出的四个问题:
- ThreadLocal是如何将线程和每个线程的变量副本绑定的?
- 变量副本存储在哪?
- 是不是每个ThreadLocal只能存储一个变量副本?
- 如果要存储多个变量副本怎么办?
ThreadLocal中可以被其他类访问的方法只有四个,get、set、initialValue和构造函数,我们从ThreadLocal中set方法的源码开始来分析一下整个保存局部变量的过程。
set方法
|
|
方法的注释是“将当前线程的本地变量设置为指定的值,大多数的子类不需要重写这个方法,仅仅靠initialValue方法设置本地线程变量的初始值”。
从注释中我们可以获得两个信息,一个是这个set方法可以用来设置需要保存的局部变量,且这个方法一般不需要重写,还有一个是有一个initialValue方法可以重写,用来设置局部变量的初始值。
ThreadLocal对象调用set方法时,首先传入了一个泛型类型的value参数,set方法内执行了如下操作:
- 获取当前ThreadLocal对象所在的线程t
- 获取当前线程对象t的成员对象threadLocals,它是一个ThreadLocalMap对象map,这显然是一个key-value类型的数据结构
- 如果map不为空,则直接将当前ThreadLocal对象作为key,要存储的泛型value作为value存储到已存在的map对象中
- 如果map为空,则初始化这个map对象,注意初始化的不是现在这个方法内的局部变量map,而是线程t内的成员对象threadLocals(这里也就直接说明线程的局部变量是存储在每个线程内部,而不是ThreadLocal对象内部)初始化ThreadLocalMap对象的语句是 123void createMap(Thread t, T firstValue) {t.threadLocals = new ThreadLocalMap(this, firstValue);}
上述set方法内部的过程中执行到2步骤之后,根据线程内部的ThreadLocalMap成员map是否为null分为两种情况,不为null则执行ThreadLocalMap对象的set方法,为null则初始化ThreadLocalMap对象,即调用ThreadLocalMap的构造方法,即对set方法来说,在ThreadLocal内的操作到此为止,而剩余的对数据的操作则封装在ThreadLocalMap类中。
显然,调用这个方法后,如果当前线程本身的threadLocalMap成员没有初始化,则会被初始化该成员并将当前ThreadLocal对象和传入的value作为key-value对存储到该成员对象里;如果该ThreadLocalMap成员对象已经存在,则会直接进行存储操作。
具体的存储和初始化操作实现封装在ThreadLocalMap类内部,后面再详细说明。
接下来看一下get方法。
get
|
|
与set方法一样,刚开始先获取了当前线程,然后通过当前线程对象t获取到t内的threadLocalMap对象,如果获取到的ThreadLocalMap对象不为空并且该map通过当前ThreadLocal对象获取的value不为空,则返回以当前ThreadLocal对象为key存储在map中的value,否则返回setInitialValue方法的返回值。
这个方法甚至不需要传参,默认获取当前线程内的threadLocalMap对象,然后获取以调用get方法的threadLocalMap对象为key的value。
一个set一个get,也就完成了线程局部变量的存储和取值过程。当然,具体的获取值的过程(map.getEntry)仍然是封装在ThreadLocalMap类内部的。
这里要注意的是,如果通过当前线程获取到的threadLocalMap对象为null或者通过当前ThreadLocal对象获取的value为null,就会调用到setInitialValue方法,当前线程内的threadLocalMap对象为null说明在进行了get方法之前并没有set值进去,这时就会返回一个默认值,这个默认值就与setInitialValue方法有关。
initialValue
|
|
正如方法的注释所说,setInitialValue方法就是一个变异的set方法,整个setInitialValue方法内部源码只有第一句和最后一句与set方法不同,第一句从 initialValue方法里获取一个初始化值,最后一句将这个初始化值返回出去,其他语句与set方法内完全相同。
结合get方法内的逻辑不难理解,这个方法就是在类似于用户没有set过值就直接调用get方法的时候返回一个默认值,并将这个默认值set到线程的threadLocalMap对象中。
要注意的是这个方法是private的,当然,源码上提供了另一个protected的方法,也就是initialValue方法,这个显然就是留给我们覆写用的,需要改变初始化值的时候只需要重写这个方法并改变return后的值即可,当然也可以不重写,可以从源码看到默认的初始化值为null。
重写一般是下面这种匿名类的形式:
|
|
根据对以上几个方法源码的分析,不难发现最终对局部变量的存储、取值的具体实现都是封装在ThreadLocalMap类中的。
Entry
分析ThreadLocalMap之前先看一下Entry。Entry是在ThreadLocalMap存取过程中作为元素的实体类,在整个ThreadLocal系列功能中作用巨大,ThreadLocal进行set或get操作时,最终操作的都是以当前ThreadLocal对象作为key、set要存储或get要取出的元素作为value存储的entry对象,它的实现很简单但是很有意思:
|
|
可以看到虽然这是个用于存储的实体类,但是它继承的是WeakReference(弱引用)类,并且泛型里传入的是ThreadLocal类,也就是Entry类本质上是一个只接受ThreadLocal类对象的弱引用类,它是怎么把这样一个弱引用类做成一个可以存储key-value对的实体类的呢?
通过Entry类的构造函数,可以传入一个作为k(键)的ThreadLocal对象和一个作为v(值)的Object对象,key被传入父类的构造函数中,而v的值被赋值给了Entry唯一的属性成员value。
前面已经说过,Entry父类是WeakReference,所以k传入了父类的构造函数也就是传入了WeakReference的构造函数,这里弱引用的使用方法可以顺便复习一下:
|
|
由此可见通过将k传入父类构造函数,正在实例化的Entry对象实质上成了一个持有k对象弱引用的WeakReference对象,通过entry.get()可以获得所引用的ThreadLocal对象;而这个Entry对象的value属性则存储了ThreadLocal对象传进来的线程局部变量,这是个强引用,通过entry.value可以获取到这个局部变量值。
弱引用的特点是其所持有的对象只能存活到下次垃圾回收启动之前,如果下次gc启动的时候该entry对象持有的ThreadLocal对象没有被其他地方引用,则会直接被回收,到时使用entry.get()就会获取到一个null值,但此时value可能仍然存活在这个entry对象中,但此时这个entry对象已经无法通过key取值,也就成了一个无效entry。作为一个存储工具类,ThreadLocalMap有一定的策略不定期对这些无效元素进行清理以保证空间不会最终被无效元素占满。
ThreadLocalMap
|
|
首先,这个ThreadLocalMap类是在ThreadLocal类内部的静态内部类,但是ThreadLocal内部并没有使用它,唯一使用到它的位置就是Thread类内。
这个类的注释是这样说的:“ThreadLocalMap是一个自定义的专用于存储线程局部变量的hash map,类是包级私有的,允许在Thread类中声明。”可见这个类是专用于ThreadLocal类的相关操作的,那么把它作为ThreadLocal类的静态内部类也不难理解了。
经过对ThreadLocal类源码的分析我们知道,调用了ThreadLocal对象的set方法后,方法内最后会调用到ThreadLocalMap对象的set或构造函数。
|
|
ThreadLocalMap的构造方法如下:
|
|
ThreadLocalMap对象初始化的时候和直接调用map.set时传入的参数相同,都是以当前threadLocal对象为key存储线程局部变量。
构造函数做了下面几件事:
- 初始化一个用于存储key-value对的大小为16的数组
- 通过获取当前threadLocals对象的哈希码映射到数组的下标
- 将ThreadLocal对象和局部变量作为key-value初始化一个Entry实例并存储到数组里之前哈希映射到的位置里
- 以默认的存储大小16设置阈值
简单地说就是开辟了一块连续的存储空间,然后使用哈希码的方式获取数组下标(思考:如何获取?),然后将存储key-value的实体对象存入对应下标的位置。
结合前面对ThreadLocal中方法的分析,ThreadLocalMap对象初始化的时候总会有key-value对传进来,这个key-value对或者是由ThreadLocal对象的set方法传进来的,或者是threadLocalMap对象尚未初始化时直接调用get方法的时候通过setInitialValue方法传进来的默认值。
再看一下set方法:
|
|
先用一个局部变量获取到构造函数中初始化好的表,然后同样通过ThreadLocal对象的哈希码和表长度获取一个下标i,注意这步操作是在构造函数和set方法中都有的:
|
|
i就是通过ThreadLocal对象的hash码和数组长度相与得到的一个下标值。接下来是一个循环,循环首先取出下标志i取出对应位置的entry对象,如果该对象不为null,则取出该entry的key(即曾经存储在这个entry中的ThreadLocal对象):
然后对该key与当前正在进行set操作的ThreadLocal对象进行比较,如果完全相同,说明是进行了类似于以同一个ThreadLocal对象set了两个不同的值的操作,那么ThreadLocalMap的处理就是直接覆盖之前的旧value,然后方法返回:
|
|
而如果通过i取出的entry对象取出的key为null(思考:为什么会有存储在ThreadLocalMap对象中的entry实体中的key为null?):
则调用到replaceStaleEntry方法,进行一次将map中已存的不在hash码对应位置的元素向正确位置移动的操作(如果有这个必要的话)并返回,也可能并没有移动元素的操作,没有移动元素则直接将这个key为null的entry的value也置为null,然后将新的key-value对初始化一个新的entry对象放置到这个位置,最后进行一次清除废弃元素的操作,然后replaceStaleEntry方法返回,然后当前的set方法也返回。
而循环中时,如果从循环到的当前i位置取出的entry对象为null,则跳出循环,执行下面的语句:
在i位置初始化一个以当前ThreadLocal对象为key、线程局部变量为value的entry对象,然后将记录表中存储的实体数目的size数量加一,如果不再有无用的元素可以从表里清除(cleanSomeSlots方法)、且当前存储的实体数目已经超过了2/3的阈值(sz>=threshold),则进行rehash操作——将存储表扩容为原先长度的两倍,且将旧表中的元素存储到新表中。
以上即是对应于ThreadLocal对象的set方法的ThreadLocalMap中的set方法的分析,接下来对应于ThreadLocal的get方法,ThreadLocalMap实现了一个getEntry方法:
|
|
相比于set方法,getEntry方法表面看起来简洁的多,同样是先通过传入的ThreadLocal对象的hashCode和存储表的长度获取对应于这个hash码的坐标i,然后从表中取出i位置的entry实体e,如果e不为null且e.get获取到的ThreadLocal对象与参数中的ThreadLocal对象相同,则直接将e返回(思考:为什么调用e.get来获取作为key存储到Entry对象中的ThreadLocal对象);如果不符合前面的判断条件,则会调用到getEntryAfterMiss(key, i, e)方法。
方法名字很通俗易懂,这是一个aftermiss(错过后)的时候调用的方法,为什么还要在错过后有这样一个处理?就像前面在set方法分析的时候看到的,一个线程局部变量值在存储的时候未必会恰好存储在对应的ThreadLocal的hash码与存储表长度相与所得到的位置,ThreadLocalMap包容两个ThreadLocal对象的hash码相撞的情况并提供了支持,一个元素根据key的hash码得到的位置处如果已经有了一个有效元素(元素的key不为null),则这个元素会往正确位置的后方寻找空位或存储着无效元素的位置。
所以getEntry方法会在第一次根据hash码获取到的下标没有获取到正确元素的情况下做了一手补救措施,也就是getEntryAfterM
iss方法。
|
|
这个方法传入了作为key的ThreadLocal对象、根据ThreadLocal对象的hash码求出的存储表下标、以及在该下标位置获取到的实体对象。
方法内首先获取了存储表和表长度,然后判断传入的entry对象是否为null,如果不为null则进入循环,为null则直接返回null。这个地方我绕了一段时间,之前没想明白为什么作为一个错失entry后补救的方法发现传入的entry是null就直接返回null了,不应该往后遍历寻找一下相同ThreadLocal为key但是因为hash碰撞而没有放到正确位置的entry实体吗?
后来想了一下,结合之前的set方法,entry放入map的时候大概有下面几种情况:
- hash码对应的正确位置为null,这是最理想的,可以直接将entry放进正确位置,此时正确位置不为null且存储着当前entry
- hash码与之前的某个存入map的元素得到的位置相同,且之前的元素还是有效的,这时这个新的entry进行set操作的时候就要从正确位置往后寻找为entry为null或者entry中的key为null的位置再进行插入操作,此时正确位置不为null但存储着以前的尚且有效的entry
- hash码与之前的某个存入map的元素得到的位置相同,但之前的元素的key已经失效了(为null),此时当前entry进行set操作的时候就会直接将这个新值放到正确的位置,此时正确位置不为null且存储着当前的entry
可见根据ThreadLocalMap的set方法的实现,如果正确位置为null,确实可以直接返回null;而如果正确位置不为null,则可能存储着当前需要获取到的entry或者是发生hash碰撞的不正确的entry。这里又有下面几种情况:
正确位置存储着不正确的entry,且在上次get/set方法后到目前时刻这个不正确的entry仍然有效(key不为null)
这种情况没有办法,正确的元素肯定不在这里,只能往后找,取下一个坐标,然后获取到entry继续以新获取的entry循环
正确位置存储着正确的entry,且从上一次get/set方法后到目前时刻这个entry仍然有效
这当然是我们最喜欢的情况,但是执行到这里的都不存在这种情况,只能是循环到后面的元素出现找到对应key的entry,直接将这个entry返回
正确位置存储着不正确的entry,且在上次get/set方法后这个entry已经失效了
这种情况会调用清除失效元素的方法,这个方法会将占据着当前位置的无效元素清理掉并将遍历到的有效entry进行rehash,使entry向正确位置靠近,然后e引用到新的当前位置的元素,进行下一轮循环,直到遍历到为null的元素。
通过getEntry和getEntryAfterMiss方法,ThreadLocalMap完成了从map中通过ThreadLocal对象获取线程局部变量的逻辑,整个ThreadLocalMap这一层的变量存取过程也基本讲述清楚。
还有一些处理我将它们作为更深的一层来分析,也就是前面没有详细分析的ThreadLocalMap的几个功能性的方法和Entry实体类。
replaceStaleEntry
这个方法出现在ThreadLocalMap的set方法源码中:
|
|
根据之前的分析,这个方法调用的时机是在调用set方法时要存储的元素与旧元素发生hash碰撞(根据hash码求出的坐标相同)、当前entry在向正确位置后方依次寻找可以插入的位置时检测到有key失效的元素的时候。结合方法名,不难看出这个方法是为了将无效的旧元素替换掉,具体怎么替换呢?
|
|
代码注释里写的分析比较详细了,首先这个方法的调用情景是进行set操作时发生哈希碰撞后、元素在从正确的hash位置向后遍历寻找可以插入的位置的时候遍历到了key为null的位置的时候调用(遍历到key相同的位置直接覆盖value,遍历到key不同且有效的位置继续循环,遍历到元素为null的位置直接初始化entry存入)。
在key为null的位置处就可以直接将值保存进去了吗?事实是我们可能在set当前值之前曾经set过另一个key相同的值,那个值也发生过hash碰撞,并且当时现在这个key为nul的位置key还有效,所以那个值可能存在当前位置的后面。所以源码中继续做了一个向后遍历的循环,用来查找当前位置后面的key值相同的元素,直到查找到为null的元素,则不再查找,直接将元素插入到当前这个key为null的位置;而查找到了key相同的元素,就将该元素与当前这个key为nul的元素交换位置。
这里有个问题是,为什么这个方法里遍历到为null的元素就可以确保null元素的后面不存在key相同的元素?这涉及到整个存储过程,我们在调用set方法根据hash码存储元素的时候有下面几种情况:
- hash码对应位置的元素为null,直接将元素存到该位置
- hash码对应位置的元素不为null,但key与要存储的元素的key相同,直接覆盖该位置元素的value
- hash码对应位置的元素不为null,但key为null,向后遍历,如果遍历到为null的entry就直接将key为null的entry换为当前要保存的entry;遍历到key相同的元素就将该元素与key为null的元素交换位置
- hash码对应位置的元素不为null,且key与要存储的元素的key不同,坐标位置移向下一位,用新的位置的元素再次进行上面的判断,步骤和上面相同,将上面的“hash码对应位置”换成“新位置”
在这样的存储策略下,如果调用到replaceStaleEntry方法,则必然在向后查找相同key时不会出现null(否则就应该在set方法里直接跳出循环在null的位置存储新元素了)。
在方法里记录了一个slotToExpunge标记,用于调用expungeStaleEntry方法。
expungeStaleEntry
除了replaceStaleEntry方法中,其实在之前的getEntryAfterMiss方法中也有对expungeStaleEntry方法的调用,字面上看,这个方法的作用就是删除无效元素。在replaceStaleEntry方法中,传入expungeStaleEntry方法的参数是标记的除要交换元素外的无效元素位置,看一下源码的实现:
|
|
可以看到方法的作用正像之前说的,删除了标记位置的无效元素,但是又不限于此,方法在删除标记的无效元素后还向后遍历,将标记位后面的无效元素也删除,对有效元素进行rehash(降低了之后添加进来的元素发生hash碰撞的几率),直到碰到一个元素为null的位置。
注意这个方法在get和set方法中都有被调用到的机会,结合之前对get和set的分析,有下面几种调用到expungeStaleEntry方法的情景:
- 调用set方法时除了碰到key相同的元素直接替换的情况,其他情况(直接插入元素为null的位置、遍历到key失效的位置)都会调用到这个方法
- 调用get方法时,除了可以直接根据ThreadLocal取出正确的entry、或没有直接通过传入的ThreadLocal对象获取到entry但是在向后遍历时查找到了key相同的entry的情况,其他情况(也就是查找到了key失效的entry的情况)会调用到这个方法。
cleanSomeSlots
有几处调用到expungeStaleEntry方法的地方,将expungeStaleEntry的返回值作为了cleanSomeSlots的参数。cleanSomeSlots的源码:
|
|
这个方法的方法名直译过来是“清理一些槽”,所谓槽其实就是指作为存储表的entry数组中的每一个entry。这里有个不太明白的地方就是在set方法中调用replaceStaleEntry方法时会在交换或者覆盖一个无效元素时调用这样一条语句:
|
|
根据先前对expungeStaleEntry方法的分析已经知道,expungeStaleEntry方法会清除参数位置的无效元素,并从参数位置往后遍历清除无效元素并对有效元素进行rehash,直到遍历到一个null元素,然后会将这个null元素的位置返回。
从功能上来看,expungeStaleEntry方法显然也达到了“清理一些槽”的效果,那么(?为什么要将expungeStaleEntry方法的返回值作为参数再次调用cleanSomeSlots方法清理槽?二次清理会达到什么特定的效果吗?)这里是我所没有想明白的。
暂时抛开这个疑问,这个方法的源码实现主要是一个do-while循环,也就是先执行循环体再做判断。方法的参数有两个,第一个参数传入了一个代表位置的数,按照注释说的,这应该是一个确保所持有元素不是无效元素的位置(也就是该位置的entry可以为有效元素,也可以为null,只要不是key无效的entry就好);第二个参数比较神奇,源码里对它有两个不同的使用,一个是传入了存储表的长度len,一个是传入了表内的元素数量sz。
循环内的代码首先用i取了i的下一个位置的元素,然后对该元素进行一次有效性判断,如果是无效的就使n等于存储表长度(?这一步操作的原因是什么?),将removed标志位置为true,然后将该位置作为参数调用expungeStaleEntry方法,并将该方法的返回值赋给i,然后进行循环条件判断。这个循环条件的判断很有意思,用了一个对n的移位运算,将n带符号位右移一位,判断不为0则继续下次循环。这个判断条件使循环次数取决于n的带符号位二进制数,我查阅了很多博客也没有弄明白为什么这么实现,只好暂且留下疑问,以后明白了再来填坑。
总体来看这个方法实现的功能就是从第一个参数往后一个位置向后遍历,遍历次数是第二个参数带符号位右移直到为0的次数+1(do-while的特性使循环体执行次数比for多一次),中间如果遍历到无效的entry即会对该位置调用expungeStaleEntry方法清理该位置到下一个null位置中所有的失效元素并对这期间的有效元素进行rehash,然后再从null位置的下一个位置继续循环,循环条件会在遍历到无效entry的时候重新设置为存储表的原长度,然后判断带符号右移不为0继续循环(感觉是比expungeStaleEntry更彻底的一次大的清理活动)。
expungeStaleEntries
expungeStaleEntry方法的复数形式(滑稽),就是一个删除表中所有无效元素的方法:
|
|
源码上就是一个对表中所有元素的遍历,对每一个遍历到的元素进行有效性判断,判断为无效就调用expungeStaleEntry方法进行清除,完全没有管expungeStaleEntry方法内部会对当前遍历到的位置直到下一个为null的位置进行一次清理,继续一个个元素遍历下去进行清理,我个人理解为时比cleanSomeSlots更彻底、更大规模的一个清理方法,整个ThreadLocal这一系列源码中唯一调用到这个方法的就是rehash方法内部。
rehash
之前提到过很多次rehash,需要说明一下这个方法和之前提的rehash不同,之前提的rehash是说在expungeStaleEntry方法中,在传入的参数(确保是一个无效元素)和下一个为null的元素的位置之间如果碰到有效元素则重新对其求下标、并尝试将其放到正确下标或离正确下标更近的位置的操作;而这个rehash方法只在set方法里有调用,在cleanSomeSlots方法返回了false且当前元素数已经大于了2/3的阈值的前提下,rehash方法会被调用:
|
|
方法中首先调用了expungeStaleEntris方法,经过之前的分析知道这个方法对所有元素进行了一个彻底的遍历和无效元素清理,在这次清理后又对元素数量进行了一次判断,注释写的是“使用较低的阈值避免滞后”,这里使阈值减去了本身的四分之一,如果经过一次彻底的清理后元素数量已经小于等于这个新的阈值就不需要进行更深一步的操作,否则就会调用到resize方法。
resize
这个方法只在rehash中被调用,看源码之前我们可以先分析一下这个方法可能的作用,这个方法被调用是在rehash方法里,rehash被调用说明已经无法通过cleanSomeSlots程度的清理使元素数量降到2/3的阈值,而resize的调用说明进行了一次expungeStaleEntries级别的彻底的清理后仍然无法满足存储需求,在一个存储空间无法满足存储需求的时候调用的方法可能的作用是什么?正像方法名所描述的,resize(重新分配空间),我理解为扩容。
|
|
扩容的策略在源码上表示的很清楚,就是扩大两倍然后重新进行hash码&表长度得到下标的运算,将旧表中尚且存在的有效元素引用到新表的新位置中,如果发生hash碰撞就直接向右侧移动到一个null位置再插入。
这里对rehash过程中碰撞的处理和expungeStaleEntry中一样,相对set中对碰撞的处理显得比较轻量,具体为什么我还没有查阅到,大致的猜测是类似于expungeStaleEntry清理无效元素的过程中的rehash和扩容时的rehash时无效元素已经基本被清理了,不为null的位置大多为有效元素,所以为了提高效率直接向后方线性查找为null的位置插入元素就可以了;甚至resize过程中表容量扩展为原先的两倍,发生hash碰撞的几率应该极小极小,这么考虑的话从效率上看确实不需要在这里的进行太多对碰撞的处理。
ThreadLocalMap的hash存取和魔数0x61c88647
前面ThreadLocalMap类中基本所有的方法都涉及到了一句关键的代码:
|
|
其中key是传入的ThreadLocal对象,len是存储表长度,i是通过key的threadLocalHashCode和表长度所得到的数组下标。
接下来就看看ThreadLocal类的threadLocalHashCode:
|
|
这是一个final常量,也就是说只在初始化的时候赋值一次就不再变化,这说明每个ThreadLocal对象的threadLocalHashCode都是不变的(但可能不是唯一的,也就是发生了hash碰撞),得到这个常量值是通过一个静态方法nextHashCode,这个方法返回nextHashCode.getAndAdd(HASH_INCREMENT)。
nextHashCode是一个常量的原子整数对象,所有的ThreadLocal类对象共用这一个原子类对象,它的getAndAdd操作是一个原子操作,功能是原子性的获得当前原子对象的值并在这个值的基础上加上后面参数的值。整个ThreadLocal类中唯一需要进行线程同步的地方也通过这里的原子类操作解决了。
原子整数初值为0,这个在ThreadLocal类加载到虚拟机中时就已经初始化好了,然后每初始化一个ThreadLocal实例,就会初始化一个threadLocalHashCode值,这个值通过静态方法nextHashCode获取值,nextHashCode方法会使类变量nextHashCode(这个变量名和方法名一样不要混淆了)在原值的基础上加上十六进制数0x61c88647,比如我初始化的第一个ThreadLocal对象的threadLocalHashCode是0+0x61c88647=0x61c88647,第二个ThreadLocal对象的threadLocalHashCode就是0x61c88647+0x61c88647=0xC3910C8E,这样就获得了每个ThreadLocal对象不变的hashCode。
那么为什么是0x61c88647?
这个数比较有意思,被称为“魔数”,怎么算出这个数的我也不是很清楚,只查阅到这个数与黄金比例和斐波那契数有关,而通过递加这个数所得到的hash码与存储表长度-1相与计算出的数组下标可以更好的分布在2的n次方大小的数组里。
这里引用网上查到的对该数的一个测试:
(take 16 (magic-hash 16))
(0 7 14 5 12 3 10 1 8 15 6 13 4 11 2 9)(take 32 (magic-hash 32))
(0 7 14 21 28 3 10 17 24 31 6 13 20 27 2 9 16 23 30 5 12 19 26 1 8 15 22 29 4 11 18 25)(take 64 (magic-hash 64))
(0 7 14 21 28 35 42 49 56 63 6 13 20 27 34 41 48 55 62 5 12 19 26 33 40 47 54 61 4 11 18 25 32 39 46 53 60 3 10 17 24 31 38 45 52 59 2 9 16 23 30 37 44 51 58 1 8 15 22 29 36 43 50 57)
可以看到,这组数分别对16、32、64大小的数组进行了测试,通过递加魔数的方法最终获取到的下标确实是相对比较均匀的分布在整个数组中。
这也就是为什么ThreadLocalMap的默认数组长度上注释写着“必须为2的次方数”,实质上求下标的时候并不是直接用数组长度与hash值相与,而是用数组长度减一相与,2的n次方减1也就是一个低n-1位都为1的二进制数,将它与hash值相与也就是取hash值的低n-1位,通过这种方式就能在数组里更好的分布并且在最大程度上减少hash碰撞的几率,提高效率。
还有一个问题就是存取的时候为什么不直接按照0,1,2…的下标顺序存取?像之前分析的,我们在这个map里存取的是继承WeakReference的Entry类,它持有的key是弱引用,随时可能被回收,导致entry失效需要被清理或者覆盖,这种情况下如果按照顺序存取,越到后面的处理就会越复杂。
ThreadLocal的内存泄漏
分析Entry类的时候提到过,Entry本身是一个只接受ThreadLocal对象的弱引用类,也就是Entry存储的key是弱引用,但是它所存储的value是强引用,弱引用不用担心,下次gc的时候就会被回收,那么持有强引用的value会不会引起内存泄漏?
经过前面ThreadLocaMap中很多方法的分析不难发现,ThreadLocalMap本身对帮助垃圾回收做了很多处理,每次调用get/set方法的时候都会对无效元素进行一次清理,表内空间不足的时候更是会进行一次彻底的rehash,其中在调用到expungeStaleEntry方法时都会将key已经失效的entry的value和entry本身置null,释放这些强引用;而且ThreadLocalMap对象本身是Thread对象的普通属性成员,如果在一个线程中用到了ThreadLocal,在这个线程结束被回收后强引用断开,这个ThreadLocalMap以及map中的entry、value也都会被回收。有这两方面的防护,普通的使用ThreadLocal一般不会引起内存泄漏。
但是有情况仍然会造成内存泄漏,那就是使用线程池。线程池的特性是重复使用已有线程,避免创建多余线程占用资源,这也就无法实现之前所说的线程结束后的回收,因为线程会永远存在在线程池中,所以对value的引用就一直得不到释放,导致内存泄漏。
所以包括不使用线程池的情况在内,最稳妥的方法还是在使用完ThreadLocal后及时调用THreadLocal的remove方法。该方法会查找到以当前ThreadLocal对象为key的entry,及时清理这个元素的key、value和entry本身,消除内纯泄漏的隐患。