ThreadLocal底层实现原理详解
热衷学习,热衷生活!😄
沉淀、分享、成长,让自己和他人都能有所收获!😄
一、ThreadLocal简介
ThreadLocal
顾名思义可以根据字面意思理解成线程本地变量。也就是说如果定义了一个ThreadLocal
,每个线程都可以在这个ThreadLocal
中读写,这个读写是线程隔离的,线程之前不会有影响。
每个Thread
都维护自己的一个ThreadLocalMap
,所以是线程隔离的。
1 | /* ThreadLocal values pertaining to this thread. This map is maintained |
通过这个ThreadLocalMap
实现数据的读写,既然是Map
肯定有key
和value
,但是这个ThreadLocalMap
的key
可以简单的看成是ThreadLocal
,实际是并不是ThreadLocal
的本身,而是它的一个弱引用。
二、ThreadLocal学习大纲
学习大纲思维导图如下图:
三、ThreadLocal方法和成员变量
API
ThreadLocal
的API
很少就包含了4个,分别是get()
、set()
、remove()
、withInitial()
,源码如下:
1 | public T get() {} |
get()
:获取当前线程对应的ThreadLocalMap
存储的值,key
为当前TheadLocal
(实际为TheadLocal
的弱引用),也就是获取当前线程本地变量的值。set(T value)
:给当前线程对应的ThreadLocalMap
的设置值,也就是给当前线程本地变量设置值。remove()
:清除前线程对应的ThreadLocalMap
存储的TheadLocal
,也就是清除当前线程本地变量的值。withInitial()
:用于创建一个线程局部变量,变量的初始化值通过调用Supplier的get方法来确定
成员变量
1 | // 调用nextHashCode()方法获取下一个hashCode值,用于计算ThreadLocalMap.tables数组下标 |
四、ThreadLocalMap
ThreadLocalMap
是ThreadLocal
类的一个静态内部类,在上面有说到每个线程都维护着一个ThreadLocalMap
,这个``ThreadLocalMap` 就是用来储存数据的。
ThreadLocalMap
内部维护着一个Entry
节点,这个节点继承了WeakReference
类,泛型为ThreadLocal
表示是弱引用,节点内部定义了一个为Object
的value
,这个value
就是我们存放的值,Entry
类的构造方法只有一个,传入key
和value
,这个key
就是ThreadLocal
,实际为ThreadLocal
的弱引用。
1 | static class Entry extends WeakReference<ThreacLocal<?>> { |
Thread、ThreadLocalMap、ThreadLocal结构关系
每个Thread
都有一个ThreadLocalMap
变量,ThreadLocalMap
内部定义了Entry
节点类,这个节点继承了WeakReference
类泛型为ThreacLocal
类,节点类的构造方法ThreadLocal<?> k, Object v
,所以可以得到下面的结构关系图:
GC之后key是否为null?
思考一个问题,既然ThreadLocalMap
的key
是弱引用,GC
之后key
是否为null
?在搞清楚这个问题之前,我们需要先搞清楚Java
的四种引用类型:
- 强引用:
new
出来的对象就是强引用,只要强引用存在,垃圾回收器就永远不会回收被引用的对象,哪怕内存不足的时候。 - 软引用:使用
SoftReference
修饰的对象被称为软引用,在内存要溢出的时候软引用指向的对象会被回收。 - 弱引用:使用
WeakReference
修饰的对象被称为弱引用,只要发生垃圾回收,被弱引用指向的对象就会被回收。 - 虚引用:虚引用是最弱的引用,用
PhantomReference
进行定。唯一的作用就是用来队列接受对象即将死亡的通知。
这个问题的答案是不为null,可以看下面的图:
通过上图我们知道ThreadLocal
的强引用是仍然存在的,所以不会被回收,不为null
ThreadLocalMap成员变量
1 | // 初始化容量 必须为2的幂,位运算取代模运算提升计算效率,可以试hash值发生碰撞的概率更小,尽可能的使 |
五、ThreadLocal.set()方法源码详解
set()
方法用于给本地线程变量设值,我们先来看看set()
方法的源码,从源码来一步一步解析实现原理,源码如下:
1 | pubic void set(T value) { |
ThreadLocal.set()
方法还是很简单的,核心方法在ThreadLocalMap.set()
方法,ThreadLocal.set()
方法流程如下:
- 获取当前线程的
ThreadLocalMap map
。 - 如果
map
不为null
则调用map.set()
方法设置值。 - 如果
map
为null
则调用createMap
方法创建。 createMap()
方法通过ThreadLocalMap
的构造方法创建,构造方法主要做了初始化Entry[] table
容量16,通过ThreadLocal
的threadLocalHashCode
调用nextHashCode()
方法获取hashCode
值计算出下标,table
数组通过下标赋值,初始化存储的元素数量,初始化数组扩容阙值。
ThreadLocalMap
在构造方法里处理的时候用到了我们学习大纲里说到的hash
算法,源码如下:
1 | int i = firstKey.threadLocalHashCode & (INITIAL_CAPACITY - 1); |
这里最关键的就是threadLocalHashCode
值的计算,ThreadLocal
中有一个属性为HASH_INCREMENT = 0x61c88647
,没创建一个ThreadLocal
就会调用一次nextHashCode()
方法,这个HASH_INCREMENT
值就会增长0x61c88647
,这个值很特殊,是斐波那契数也叫黄金分割数,这个值可以让hash
分布非常均匀。
可以下一个小demo测试一下:
1 | public static void main(String[] args) { |
上面测试输出如下:可以看出数据在算列数组中分布的很均匀。
1 | 0在桶中的位置:7 |
ThreadLocalMap.set()方法源码详解
ThreadLocalMap.set()
方法分为好几种情况,主要有以下四种情况,针对不同的情况我们通过画图来说明。
说明: 下面所有图中,绿色块
Entry
代表正常数据,灰色代表Entry
的key
值为null
,已被GC
回收,白色代表Entry
为null
。
第一种情况:通过hash
计算得到的下标,该下标对应的Entry
为null
:
这种情况直接将该数据放入该槽位即可。
第二种情况:通过hash
计算得到的下标,该下标对应的Entry
不为null
,但是key
相同:
这种情况直接该槽位的value
值。
第三种情况:通过hash
计算得到的下标,该下标对应的Entry
不为null
,且key
不相同,这种时候会遍历数组,线性往后查找,查找Entry
为null
的槽位,且在找到Entry
为null
之前没有遇到key
过期的Entry
,就该数据放入该槽位中,如果遍历过程中,遇到了key
相等的槽位,直接更新value
即可:
注意:每次循环查找都会判断key
是否相等,如果相等则更新value
直接返回。
第四种情况:基于第三种情况,如果在找到Entry
为null
之前遇到了key
过期的Entry
,如下图:
如上图散列数组下标为7位置对应的Entry
数据key
为null
,说明此数据key
值已经被垃圾回收掉了,此时会执行replaceStaleEntry()
方法,该方法含义是替换过期数据的逻辑,以index=7
为起点开始向前遍历,进行探测式数据清理工作。
初始化探测式清理过期数据扫描的开始位置:slotToExpunge = stateSlot = 7
。
以当前stateSlot
开始向前迭代找到,找到其他过期的数据,然后更新过期数据起始扫描下标的slotToExpunge
,直到找到了Entry
为null
的槽位则结束。
如果找到过期数据,继续向前迭代,直到遇到Entry=null
的槽位则停止迭代,如下图所示,slotToExpunge
被更新为0:
上图以当前节点index = 7
向前迭代,检测是否有过期的Entry
数据,如果有则更新slotToExpunge
的值,遇到Entry
为null
则结束探测,以上图为例slotToExpunge
被更新为0。
上面向前迭代的操作是为了更新探测清理过期数据的起始位置soltToExpunge
的值,这个值是用来判断当前过期槽位staleSlot
之前是否还有过期元素。
接着开始staleSolt
位置index = 7
向后迭代,如果找到了相等key
的Entry
的数据则更新value
值,如下图:
从当前节点staleSolt
位置开始向后寻找key
相等的Entry
位置,如果找到了key
相等的Entry
,则会交换staleSlot
元素的位置,且更新value
值,然后进行过期Entry
的清理工作,如下图:
如果没有找到相等key
的Entry
的数据,如下图:
从当前节点staleSlot
向后查找key
值相等的Entry
,如果没有找到,则会继续往后查找直到找到Entry
为null
停止,然后创建新的Entry
,替换stableSlot
的位置。
替换完成之后也是进行过期元素的清理工作,清理工作的方法主要有两个expungeStaleEntry
和cleanSomeSlots
,具体详情后面会讲到。
上面已经图解了set()
方法实现的原理,接下来我们结合源码再来看看,源码如下:
1 | private void set(ThreadLocal<?> key, Object value) { |
上面代码流程主要如下:
- 首先获取
Entry
表,Entry
表长度,通过hashCode
计算下标,然后for
循环Entry
表。 - 如果循环查找过程中找到了
key
相等的Entry
则更新value
对应我们上面说的第二种情况。 - 如果循环查找过程找到了
key
为null
的Entry
,说明key
过期了,替换过期元素,需要初始化探测式清理的其实位置,调用replaceStaleEntry()
方法,这个方法我们下面再说,这个对应我们上面说的第四种情况。 for
循环查找完毕,说明在查找过程中该下标对应的Entry
为null
,则在新建一个Entry
放入该槽位,然后调用启发式清理工作。- 如果启发式清理未清理任务数据,且
size
超过扩容阙值(2/3),则调用rehash()
方法,该方法会先进行一次探测式清理,清理过期元素,清理完毕之后如果size >= threshold - threshold / 4
,则会进行扩容操作。
接下来看核心方法replaceStaleEntry()
,该方法在查找过程中遇到key = null
数据的时候会执行,该方法提供了替换过期数据的功能,可以对应上面说第四种情况来看,源码如下:
1 | private void replaceStaleEntry(ThreadLocal<?> key, Object value, |
上面代码主要流程如下:
- 首先获取
Entry
表,Entry
表长度,定义探测式清理起始位置slotToExpunge = staleSlot
。 - 从staleSlot开始向前迭代查找是否有
key=null
的entry
,如果有则更新slotToExpunge
。 staleSlot
开始向后循环,如果查找到了key
相等entry
,则替换staleSlot
和i
的位置,且更新value
的值,然后判断slotToExpunge == staleSlot
,说明向前循环的没有查找到key
过期的entry
, 然后更新slotToExpunge
值,则会调用启动式过期清理,先会进行一遍过期元素探测操作,如果发现了有过期的数据就会先进行探测式清理。- 如果找到了
key
为null
且向前循环的没有查找到key
过期的entry
,则更新slotToExpunge
。 - 循环结束,方法没有退出,说明没有找到
k == key
的数据,且碰到Entry=null
的数据,则将数据放入该槽位。 - 最后判断
slotToExpunge != staleSlot
,说明从staleSlot
开始向前迭代查找有key=null
的entry
,则调用启动式清理,在启动式清理之前,先会进行一次过期元素探测,如果发现了有过期的数据就会先进行探测式清理。
ThreadLocalMap.set()
方法到这里已经解析完毕,我们接下来看看ThreadLocalMap
过期 key 的启发式清理流程。
ThreadLocalMap过期 key 的启发式清理流程
上面我们提到的ThreadLocalMap
两种过期key
数据清理方式:探测式清理和启发式清理。
探测式清理
探测式清理方法expungeStaleEntry
,遍历散列数组,从开始位置向后探测清理过期数据,将过期数据的Entry
设置为null
,遍历过程如果遇到未过期的数据则会将此数据rehash
后重新在table
数组中定位,如果定位的位置已经有了元素,则会将未过期的数据放在最靠近此位置的Entry = null
的桶中,使rehash
后的Entry
数据距离正确的桶位置更近一点。这种优化会提高整个散列表查询性能。
如下图所示:
探测式清理迭代的过程中遇到了空的槽位,则终止探测,这样子一轮探测式清理就工作完成,我们看看具体的源码实现,源码如下:
1 | // staleSlot探测式清理起始位置 |
启发式清理
启发式清理被作者定义为:Heuristically scan some cells looking for stale entries
源码如下:
1 | private boolean cleanSomeSlots(int i, int n) { |
从i
的下一个位置判断元素是否需要清除,如果遇到key==null
的元素则会重置n
,需要清除且更新i
的值,判断且清除完毕之后,n = n >>> 1
直到n = 0
则退出清理。
ThreadLocalMap.get()方法详解
上面已经说完了set()
方法的源码,接下来我们看看get()
方法的操作原理,主要包含两种情况,一种是hash
计算出下标,该下标对应的Entry.key
和我们传入的key
相等的情况,另外一种就是不相等的情况。
相等情况:相等情况处理很简单,直接返回value
,如下图:
上图中比如get(ThreadLocal1)
计算下标为4,且4存在Entry
,且key
相等,则直接返回value = 11
。
不相等情况:不相等情况,先看图:
以get(ThreadLocal2)
为例计算下标为4,且4存在Entry
,但key
相等,这个时候则为往后迭代寻找key
相等的元素,如果寻找过程中发现了有key = null
的元素则回进行探测式清理操作。如下图:
迭代到index=5
的数据时,此时Entry.key=null
,触发一次探测式数据回收操作,执行expungeStaleEntry()
方法,执行完后,index 5,8
的数据都会被回收,而index 6,7
的数据都会前移,此时继续往后迭代,到index = 6
的时候即找到了key
值相等的Entry
数据,如下图:
ThreadLocalMap.get()
源码如下:
1 | public T get() { |
ThreadLocalMap的扩容机制
在ThreadLocalMap.set()
方法最后,如果执行完启发式清理工作之后,未清理任何数据,且当前散列数组中元素已经超过扩容阙值len*2/3
,则执行rehash()
逻辑:
1 | if (!cleanSomeSlots(i, sz) && sz >= threshold) |
rehash()
方法源码如下:
1 | private void rehash() { |
rehash()
方法源码流程如下:
- 首先进行探测式清理工作
- 如果探测式清理工作完毕之后,如果
size >= threshold - threshold / 4
, 也就是size >= threshold * 3/4
,也就是size >= len * 1/2
,则调用resize()
扩容。
扩容方法resize()
方法源码如下:
1 | private void resize() { |
扩容方法执行之后tab
的大小为原先的两倍oldLen * 2
,然后变量老的散列表,重新计算hash
位置,然后放到新的散列表中,如果出现hash
冲突则往后寻找最近的entry
为null
的槽位放入,扩容完成之后,重新计算扩容阙值。
六、ThreadLocal.get()方法源码详解
ThreadLcoal.get()
方法源码详解已经在ThreadLocalMap.get()
方法源码解析中完成。
七、ThreadLocal.remove()方法源码详解
ThreadLocal.remove()
方法流程比较简单,我们结合源码来说明,源码如下:
1 | public void remove() { |
ThreadLocal.remove()
核心是调用ThreadLocalMap.remove()
方法,流程如下:
- 通过
hash
计算下标。 - 从散列表该下标开始往后查
key
相等的元素,如果找到则做清除操作,引用置为null
,GC
的时候key
就会置为null
,然后执行探测式清理处理。
八、InheritableThreadLocal
我们在使用ThreadLocal
的时候,在异步场景下是无法给子线程共享父线程中创建的线程副本数据的。
为了解决这个问题,JDK
中还有一个InheritableThreadLocal
类,我们来看个例子:
1 | public static void main(String[] args) { |
上面代码输出结果为:
1 | 子线程获取父类ThreadLocal数据:null |
实现原理是子线程通过父线程中调用new Thread()
方法创建子线程,Thread#init
方法在Thread
的构造方法中被调用,init()
方法中拷贝父线程数据源到子线程中,源码如下:
1 | private void init(ThreadGroup g, Runnable target, String name, |
但InheritableThreadLocal
仍然有缺陷,一般我们做异步化处理都是使用的线程池,而InheritableThreadLocal
是在new Thread
中的init()
方法给赋值的,而线程池是线程复用的逻辑,所以这里会存在问题。
当然,有问题出现就会有解决问题的方案,阿里巴巴开源了一个TransmittableThreadLocal
组件就可以解决这个问题,这里就不再延伸,感兴趣的可自行查阅资料。