ThreadLocal类源码剖析
本章主要参考梦想之家的原文附加上自己的理解过程:ThreadLocal源码深度解析 | wxweven 梦想之家
一、ThreadLocal类的简介
该类提供线程本地
变量,这些变量与其他正常对应变量的不同之处在于,访问一个ThreadLocal
变量(通过其get
或set
方法)的每个线程都有其==自己的、独立初始化的变量副本==。ThreadLocal实例通常是类中希望将状态与线程关联起来的私有静态字段例如用户ID或事务ID。举个例子,下面的类生成每个线程本地的唯一标识符。线程的ID在第一次调用threadId.get()时分配,并在后续调用中保持不变。
1 | import java.util.concurrent.atomic.AtomicInteger; |
当使用ThreadLocal维护变量时,ThreadLocal为每个使用该变量的线程提供独立的变量副本,所以每一个线程都可以独立地改变自己的副本,而不会影响其它线程所对应的副本。
二、ThreadLocal类的架构
ThreadLocal类的整体架构其实并不难理解,之前我们在Thread类的源码中曾经看到过两个字段threadLocals
和inheritableThreadLocals
,我们先暂时不用理会后一个,其实对ThreadLocal
对象的get
、set
、remove
都是针对当前线程即Thread
对象的ThreadLocalMap
类型的threadLocals
字段。
简洁的说,ThreadLocal类只是表面上所有线程都操作的对象,真正底层处理的都是线程私有的threadLocals
字段,上面也说了这是一个Map
结构,存储的是ThreadLocal
到对应的线程本地Value
的映射对Entry
。
也许你注意到了图1中的Entry对象的Key到ThreadLocal之间是一个虚线,这是因为Key本身是一个
弱引用对象
,当ThreadLocal对象没有外部强引用并且发生GC时,弱引用会被直接回收,此时Entry对象中的Key为null,我们称这种Entry是过期失效
的。过期Entry的Value还是正常存在,因为一直存在着threadRef->thread->threadLocalMap->Entry->Value的强引用链关系,除非线程被销毁回收,不过实际项目中基本都是利用线程池技术实现线程复用,因此如果我们不人为干涉加以清理失效Entry,就会发生内存泄漏问题!所幸ThreadLocal本身就考虑到了内存泄漏问题并使用了两种清理手段:探索式清理
和启发式清理
!
1 | static class Entry extends WeakReference<ThreadLocal<?>> { |
三、ThreadLocal类的属性
ThreadLocal类的属性就是threadLocalHashCode
哈希码值用来计算其在ThreadLocalMap中哈希表数组的映射位置,为了使哈希分布更加均匀减少哈希冲突,ThreadLocal类自定义哈希码值nextHashCode
,每创建一个ThreadLocal对象其哈希值就递增HASH_INCREMENT
即0x61c88647
,这其实是一个斐波那契数,至于为什么可以使哈希分布更加均匀就需要一定的数学基础了,这个不重点了解即可。
1 | private final int threadLocalHashCode = nextHashCode(); |
四、ThreadLocal类的创建
无参构造函数这种谁都懂的我就不专门贴出来了,一般我们都是建议创建ThreadLocal的同时并赋初始化值,可以通过ThreadLocal的子类化并重写initialValue
函数实现,也可以通过ThreadLocal的静态方法withInitial
实现(其实它也是上一种方法的具体化,代码中可以看出)。
1 | // 该方法将在线程第一次使用get方法访问变量时被调用,除非该线程之前调用过set方法,在这种情况下,该线程将不会调用initialValue方法。 |
五、ThreadLocal类的方法
1.set方法
1 | public void set(T value) { |
可以看出,当我们第一次调用ThreadLocal的set方法时会在Thread对象中创建一个新的ThreadLocalMap,并把键值对加入其中;后续调用set方法会直接操作ThreadLocalMap对象,最重要的还是ThreadLocalMap的
set
方法!
2.get方法
1 | public T get() { |
可以看出,当我们第一次调用ThreadLocal的get方法时(之前没有任何set操作)会创建并初始化Thread对象中的ThreadLocalMap对象;后续调用get方法会直接操作ThreadLocalMap对象拿到对应的值,最重要的还是ThreadLocalMap的
getEntry
方法!
1 | private T setInitialValue() { |
这个方法就是set方法的一个变种
,唯一的区别是:set方法中的value是传入的,而这个方法的value是调用initialValue
方法获得的。
3.remove方法
1 | public void remove() { |
remove方法删除此线程本地变量的当前值。如果当前线程随后读取此线程本地变量,则将通过调用其initialValue方法重新初始化其值,除非当前线程在此期间设置了其值。这可能会导致当前线程中多次调用initialValue方法。
六、ThreadLocalMap类的原理
在详细讲解ThreadLocalMap之前,我们先要了解ThreadLocalMap的Hash冲突处理
,因为这是整个ThreadLocalMap最核心的地方,理解了这个,ThreadLocalMap其他的内容也就比较好理解了。
首先我们回顾下Java中的HashMap,我们知道HashMap的实现方式是数组+链表+红黑树,其中数组用于Hash桶定位,链表用于解决Hash冲突,红黑树用于加快查找速度。ThreadLocalMap,本质上来讲也是一个Map,也用到了Hash算法,那么它在实现上与HashMap有什么区别呢?这里先把结论给出来:
Hash冲突的处理方式不一样,HashMap使用
链地址法
来解决Hash冲突,而ThreadLocalMap使用开放地址法
来解决Hash冲突。
每一个ThreadLocal对象都有一个threadLocalHashCode
哈希值字段,在将ThreadLocal对象及其对应的Value放入ThreadLocalMap中时,需要现根据threadLocalHashCode
哈希值对哈希表数组长度取模(因为数组长度是2的幂次方,因此可以通过threadLocalHashCode&(length-1))找到数组中的槽位,然后构造出一个键值对Entry放入该槽位中。
尽管threadLocalHashCode
哈希值映射后的分布相对均匀,但仍然无法避免哈希冲突
的问题,此时采用开发地址法
依次往后探查直到遇到空槽位存入。当然,ThreadLocalMap的这个过程中还有很多其他细节,如遇到Key相同的直接更新,遇到过期Entry需要清理。下图ThreadLocalA、ThreadLocalB、ThreadLocalC发生哈希冲突的情况图:
1.构造方法
在前面分析ThreadLocal的set方法中,我们知道,如果当前Thread对应的ThreadLocalMap为null,则会调用createMap方法创建ThreadLocalMap:
1 | void createMap(Thread t, T firstValue) { |
即调用了ThreadLocalMap的构造函数,我们来看看构造函数源码:
1 | ThreadLocalMap(ThreadLocal<?> firstKey, Object firstValue) { |
我们多提两个小函数,后面会用到这里先看一下,功能也很简单就是向前
或向后
移动,看成循环数组。
1 | private static int nextIndex(int i, int len) { |
2.set方法
1 | private void set(ThreadLocal<?> key, Object value) { |
详细过程已经在源码中附加了注释,其中的『注释4』和『注释7』是跟ThreadLocal的内存泄露相关的,我们将在『ThreadLocal类的内存泄露』章节介绍到。
3.getEntry方法
1 | private Entry getEntry(ThreadLocal<?> key) { |
get方法的作用,也无需多说,跟HashMap的get方法一样,根据key去找value。同样,考虑到Hash冲突,会有未直接命中的情况,需要做特殊处理,即调用getEntryAfterMiss方法:
1 | private Entry getEntryAfterMiss(ThreadLocal<?> key, int i, Entry e) { |
同样,这里需要特别注意的是『注释3』:当前数组位置key为null的情况,也是跟内存泄露相关的,『ThreadLocal类的内存泄露』章节会介绍到。
4.remove方法
1 | private void remove(ThreadLocal<?> key) { |
remove方法,根据key去删除map中的元素,这一过程中的特殊处理,也是跟内存泄露相关,会在『ThreadLocal类的内存泄露』章节介绍。
5.rehash方法
1 | private void rehash() { |
expungeStaleEntries
的源代码如下:
1 | private void expungeStaleEntries() { |
resize
的源代码如下:
1 | private void resize() { |
rehash的逻辑比较简单,我们就不详细介绍了,其实就是把哈希表中的原数组元素拷贝到扩容后的新数组,注意这个过程中不考虑过期Entry,并且正常Entry需要重新计算放置的槽位位置。
七、ThreadLocal类的内存泄漏
1.内存泄漏的原理
我们再回过头来看看ThreadLocal的底层实现:
在ThreadLocal的生命周期中,都存在这些引用,如下图实线代表强引用,虚线代表弱引用。
ThreadLocal的实现是这样的:==每个Thread维护一个ThreadLocalMap映射表,这个映射表的key是ThreadLocal实例本身,value是真正需要存储的Object。==也就是说ThreadLocal本身并不存储值,它只是作为一个key来让线程从ThreadLocalMap获取value。值得注意的是图中的虚线,表示ThreadLocalMap是使用ThreadLocal的弱引用
作为key的,弱引用的对象在GC时会被回收。
ThreadLocalMap使用ThreadLocal的弱引用作为key,如果一个ThreadLocal没有外部强引用
来引用它,那么系统GC的时候,这个ThreadLocal势必会被回收
,这样一来,ThreadLocalMap中就会出现key为null的过期Entry
,就没有办法访问这些key为null的过期Entry的value,如果当前线程再迟迟不结束的话(典型情况就是线程池方式,线程并真正不结束,只是归还到线程池中),这些key为null的Entry的value就会一直存在一条强引用链:
Thread Ref -> Thread -> ThreaLocalMap -> Entry -> Value
导致value永远无法回收,造成内存泄漏。
在ThreadLocalMap的set、getEntry、remove方法中,都提到了『特殊处理』,这个『特殊处理』就是为了解决内存泄露问题,它会清理掉不再被使用的过期Entry对象。
2.过期Entry清理原理
ThreadLocalMap特殊的Hash冲突处理方式,导致了:
清理ThreadLocalMap时候要保证将一个index指向的Slot清理之后,需要连带着将挨着该index的非空Slot内的ThreadLocal对象全部Rehash一遍。
因为这些Slot内存储的ThreadLocal对象和index指向的Slot内存储的ThreadLocal对象可能
都Hash到了同一个ThreadLocalMap内的Slot,如果把开头Slot清理后面的不去Rehash就无法找到他们了,这一过程详见『ThreadLocalMap类的原理』。
JDK源码中,执行清理ThreadLocalMap的操作的有三个地方:
- 主动调用ThreadLocalMap内的remove时执行expungeStaleEntry
- set值到ThreadLocalMap时调用replaceStaleEntry和cleanSomeSlots
- getEntry时如果发现key找不到会执行expungeStaleEntry
3.remove方法(使用expungeStaleEntry)
1 | private void remove(ThreadLocal<?> key) { |
其中的清理工作就是在expungeStaleEntry
方法中执行的。我们来看看这个神秘的expungeStaleEntry
方法。
1 | private int expungeStaleEntry(int staleSlot) { |
expungeStaleEntry的工作是传入一个Slot的index,将该index指向的Slot清理,并且将该index之后同一个run范围内的所有Slot都检查一遍,发现Slot指向的ThreadLocal被GC则也清理该Slot,没被GC就将该ThreadLocal对象重新rehash到ThreadLocalMap的其它合适Slot上。最终会返回目标index所在run范围的终点序号,也即一个run末尾的空Slot的index值。
这就是ThreadLocalMap中的第一种清理手段:探索式清理
!
4.set方法(使用cleanSomeSlots和replaceStaleEntry)
1 | private void set(ThreadLocal<?> key, Object value) { |
set操作是传入一个ThreadLocal对象和其绑定的value,将这个ThreadLocal和value存入ThreadLocalMap中。存的时候也是需要先对ThreadLocal对象做Hash找到其在ThreadLocalMap中的Slot,如果Slot被占用,会有三种情况:
Slot内存储的ThreadLocal对象就是当前待存储的ThreadLocal对象,此时只需要用新Value替换原来的Value就结束了;
Slot内存储的ThreadLocal不是当前待存储的ThreadLocal对象,并且之前存的ThreadLocal对象已经被GC掉,Slot内过期Entry的WeakReference读取后返回空,这种情况下需要将原来的过期Entry清理并建立新的Entry指向这个新的ThreadLocal对象,存入当前的Slot。这个替换过程使用的是
replaceStaleEntry
方法;如果不是上面两种情况,则需要继续查看紧挨着的Slot直到遇到空Slot。找到空Slot说明我们找到一个空位置,则创建全新的Entry指向当前ThreadLocal对象,存入这个找到的空Slot;
如果是上面第三种情况,添加完新的 Entry 之后,还会执行一次 cleanSomeSlots
方法,源码如下:
1 | private boolean cleanSomeSlots(int i, int n) { |
在当前新添加的Entry所在Slot之后,连续的找
logN
个Slot,判断这些Slot内存储的Entry是否指向一个已经被GC的ThreadLocal对象,是的话就对这个Slot执行expungeStaleEntry
做清理。它执行对数次扫描,作为不扫描(快速但保留垃圾)
和与元素数量成正比的扫描(这将找到所有垃圾,但会导致某些插入花费O(n)时间)
次数之间的平衡。
这就是ThreadLocalMap中的第二种清理手段:启发式清理
!
对于上面第二种情况中使用的 replaceStaleEntry
其实现还比较复杂,拿下图来说:
假设当前要set的是ThreadLocalB,并且ThreadLocalA、B、C在这个ThreadLocalMap都具有相同的Hash值,从而都Hash到同一个Slot即现在ThreadLocalA所在的Slot。也正因为碰撞所以ThreadLocalB、C都是紧挨着ThreadLocalA存储的。3号位Slot指向null表示它本来是存一个ThreadLocal对象的,但这个对象被GC了,所以按照上面对set方法的描述,再次set ThreadLocalB的时候发现3号位是null就会执行replaceStaleEntry
,希望将3号位replace为ThreadLocalB并绑定上最新的Value。
但是因为我们只检查到3号位,我们只能确认2、3两个位置没有ThreadLocalB对象,但ThreadLocalB对象可能存在于3号位之后的Slot中,所以直接将ThreadLocalB存入3号位是不行的,需要从3号位向后遍历着查找一下看看3号位之后还有没有ThreadLocalB对象了,如上图所示3号位之后还确实是有ThreadLocalB对象,并且因为发现3号位原来的ThreadLocal对象已经被 GC,所以replaceStaleEntry需要将4号位的ThreadLocalB挪到3号位,并且将该ThreadLocalB对象绑定上新的Value。交换之后4号位我们知道是需要被清理的,所以会调用expungeStaleEntry
将该位置的Slot清理,并且将4号位之后的Slot都进行rehash。
当前面expungeStaleEntry执行之后,还是会调用cleanSomeSlots来探测当前run之后,也即6号位Slot之后logN个Slot看看有没有被GC掉的ThreadLocal,有的话就用expungeStaleEntry做清理。
需要注意的是如果在4号位找到ThreadLocalB,则4号位之后是不可能再有ThreadLocalB的,所以找到4号位做完交换和更新Value之后不需要从4号位再往后找有没有ThreadLocalB了。
除了上面说的这一大堆之外,replaceStaleEntry实际还会检查同一个run内3号位之前的Slot,看看这些Slot的ThreadLocal对象有没有被GC掉,虽然这些Slot在replaceStaleEntry执行之前,在set方法内已经检查过一次。从replaceStaleEntry内注释来看主要原因是想避免连续的rehash。我个人推测,主要是因为set操作三种情况中,最耗时的就是第二种需要执行replaceStaleEntry的情况,无论是直接找到被更新的ThreadLocal对象直接更新绑定的Value还是在一个run内没有发现被GC的ThreadLocal对象直接将新的ThreadLocal存在一个run的末尾的空Slot内,耗时都是比较小的,而需要执行replaceStaleEntry时因为清理一个Slot需要将后续所有Slot全部Rehash所以耗时最大,所以要尽可能的避免replaceStaleEntry的执行。而GC是任意时刻都可能执行的,虽然set操作内检查过上图2位,但是GC过后可能2号位的ThreadLocalA也被GC掉了,所以再次检查一下能更好的避免replaceStaleEntry的执行。
如果发现3号位之前有ThreadLocal对象被GC,则在替换完3号位后,会直接从3号位之前这个被GC的ThreadLocal对象所在Slot开始,完整的执行一遍expungeStaleEntry,全部执行完后相当于是从expungeStaleEntry执行开始的Slot到一个run的末尾所有被GC掉的ThreadLocal都会被清理。
replaceStaleEntry方法源码如下:
1 | private void replaceStaleEntry(ThreadLocal<?> key, Object value, |
5.getEntry方法(使用expungeStaleEntry)
ThreadLocalMap的getEntry方法,在未直接命中时,会调用getEntryAfterMiss,该方法也会做一次清理:
1 | private Entry getEntryAfterMiss(ThreadLocal<?> key, int i, Entry e) { |
在key为null时,会调用expungeStaleEntry方法进行清理,前文已经分析过expungeStaleEntry过程,不再赘述。
6.内存泄漏总结
大多数情况下,使用ThreadLocal不会产生内存泄露问题,因为在后续的set、get过程中,ThreadLocal会自动进行内存清理。
ThreadLocal自动清理机制需要依赖于用户调用ThreadLocalMap下的set和getEntry两个方法,即ThreadLocal的set、get方法,如果一个ThreadLocal对象已经被GC,用户不再向同一个Thread绑定新的ThreadLocal对象,也再不读取Thread上的其它ThreadLocal对象,就无法触发ThreadLocalMap的set和getEntry方法,导致ThreadLocal内存储的Value对象永久驻留内存。
==所以即使ThreadLocal有自动内存清理机制,依然建议使用remove方法来手动清理内存。使用完ThreadLocal变量后,手动remove是个非常好的习惯!==