ThreadLocal源码解析
一、ThreadLocal概述
- ThreadLocal是一个线程的本地变量,也就意味着这个变量是线程独有的,是不能与其他线程共享的。这样就可以避免资源竞争带来的多线程的问题。
- 但是,这种解决多线程安全问题的方式和加锁方式(synchronized、Lock) 是有本质的区别的,区别如下所示:
1> 关于资源的管理
当资源是多个线程共享的,所以访问的时候可以通过加锁的方式,逐一访问资源。
ThreadLocal是每个线程都有一个资源副本,是不需要加锁的。
2> 关于实现方式
锁是通过时间换空间的做法。
ThreadLocal是通过空间换时间的做法。
- 由于使用场景的不同,我们可以选择不同的技术手段,关键还是要看你的应用场景对于资源的管理是需要多线程之间共享还是单线程内部独享。
二、ThreadLocal的使用方式
- ThreadLocal的使用方法如下面所示:
- ThreadLocal threadLocal = new ThreadLocal();
- threadLocal.set("muse");
- threadLocal.get();
三、ThreadLocal源码分析
3.1> ThreadLocal、Thread、ThreadLocalMap、Entry之间的关系
- 四者之间的关系如下图所示:
3.2> ThreadLocal的set(T value)方法
- 源码和注释如下所示:
【解释】红框中的代码,会是我们下面着重要介绍的。
- 当我们创建ThreadLocal后,第一次调用set方法赋值的时候,由于ThreadLocalMap还没有被创建,所以会执行createMap(t, value)方法来对ThreadLocalMap进行初始化。其中,源码和注释如下所示:
【解释】从上面源码中我们可以看到,ThreadLocalMap是当前线程Thread的一个全局变量。从这里,我们就可以看出来,为什么说ThreadLocal是当前线程的本地变量了。
- 而在ThreadLocalMap的构造方法里,蕴含着初始化创建table数组的逻辑,源码和注释如下所示:
【解释】从上面源码中我们可以看到,数组默认大小是16,设定的阈值为0.75倍的数组长度,并且根据传入的参数,创建了table数组中的第一个Entry元素对象。其中,size用来记录数组中存在的Entry元素的个数。
- 我们了解完createMap(t, value)方法之后,那么就把我们的视角切换到红框中的map.set(this, value)方法,这才是我们下面要分析的重点。由于里面代码涉及较多,所以下面我们会分为几部分来详细介绍。不过,在介绍之前,我们还是要先看一下map.set(this, value)方法的相关源码和注释:
【解释】关于set方法其实有两个,他们之间的关系就是——通过ThreadLocal的set方法来调用ThreadLocalMap的set方法
- 在上面源码的四个红框中,我们下面会一一进行详细介绍。
- 为了方便大家理解,我这里画了一张流程图,大家可以作为参考:
【解释】通过上面的流程图,我们可以总结set方法有如下几个处理步骤:
- 首先,通过入参key(即:ThreadLocal对象),计算应该插入table数组的下标。
- 如果该下标所在的位置是空闲的,那么就把新插入的值封装为Entry插入进去。
- 如果该下标所在的位置已经被别的Entry占据了,那么来进行如下判断:
- 如果已存在的Entry的key值与我们的key值相同(即:是同一个ThreadLocal实例对象),那么我们只是将value值更新为方法入参的value即可。
- 如果key值不同,那么来判断,已存在的Entry的key是不是key为空(即:是一个“陈旧的”元素,那么我们进行替换操作)
- 如果都不满足,那就往后遍历其他的Entry元素,直到满足上述条件为止,否则会一直循环。
3.3> nextIndex和prevIndex
- 我们先来看第一个红框中的方法nextIndex(i, len),其实通过该方法,我们还可以引出prevIndex(i, len)方法。源码和注释如下所示:
【解释】
- 通过源码,我们可以得出以下结论:
- nextIndex就是从指定的下标i开始,向后获取下一个位置的下标值。
- preIndex就是从指定的下标i开始,前向获取上一个位置的下标值。
- 那么,如果越界了怎么办呢?它们会采用循环查找法。即:获取队尾的下一个下标就会返回队首的下标;获取队首的上一个下标就会返回队尾的下标。如下所示:
3.4> 开放地址法
- 当我们看到这里,就发现这个ThreadLocalMap有些“奇怪”,它并没有按照我们之前在学习HashMap的方式去解决哈希冲突,即:数组+链表。而它其实使用的是一种叫做“开放地址法”作为解决哈希冲突的一种方式。
- 什么是开放地址法呢?
开放地址法的基本思想就是:一旦发生了冲突,那么就去寻找下一个空的地址;那么只要表足够大,空的地址总能找到,并将记录插入进去。
- ThreadLocalMap和HashMap的区别是什么呢?
- HashMap
数据结构是数组+链表
通过链地址法解决hash冲突的问题
里面的Entry内部类的引用都是强引用
- ThreadLocalMap
数据结构仅仅是数组
通过开放地址法来解决hash冲突的问题
Entry内部类中的key是弱引用,value是强引用
- 链地址法和开放地址法的优缺点是什么呢?
- 开放地址法
容易产生堆积问题,不适于大规模的数据存储。
散列函数的设计对冲突会有很大的影响,插入时可能会出现多次冲突的现象。
删除的元素是多个冲突元素中的一个,需要对后面的元素作处理,实现较复杂。
- 链地址法
处理冲突简单,且无堆积现象,平均查找长度短。
链表中的结点是动态申请的,适合构造表不能确定长度的情况。
删除结点的操作易于实现。只要简单地删去链表上相应的结点即可。
指针需要额外的空间,故当结点规模较小时,开放定址法较为节省空间。
- ThreadLocalMap采用开放地址法原因是什么?
ThreadLocal往往存放的数据量不会特别大(而且key 是弱引用又会被垃圾回收,及时让数据量更小)。
采用开放地址法简单的结构会更节省空间,同时数组的查询效率也是非常高,加上第一点的保障,冲突概率也比较低。
- 那么,当我们了解了开发地址法的原理之后,我们就有了看懂下面代码的重要武器了。好,那我们继续看下面的源码
3.5> replaceStaleEntry(key, value, i)
- 当发现待插入的位置上已经被其他Entry占用了,并且它的key值与我们不同(即:不是同一个ThreadLocal实例),那么,当这个已存在的Entry元素的key等于null的时候,逻辑上就走到了第二个红框里的方法——replaceStaleEntry(key, value, i),该方法是用来替换“陈旧的”Entry的。下面我们来看一下这个方法的代码和注释:
- 流程图如下所示:
- 向前遍历的示意图如下所示:
- 向后遍历的示意图如下所示:
- 结合向前遍历和向后遍历(以case2为例)的两部分,最终的结果如下所示:
3.6> expungeStaleEntry(int staleSlot)
- 上面介绍完了replaceStaleEntry方法之后,我们发现,方法里面都调用了如下方法:
【注意】方法的入参是slotToExpunge,它代表的含义是——我们上面“施工”范围内,最左侧的“陈旧”Entry下标位置。其实也就是说,下面的清理工作,是以slotToExpunge作为起点,然后在“施工”范围内,向后一个个遍历处理“陈旧”Entry。
- cleanSomeSlots这个方法在开篇的set方法的源码截图中用红框标注过,也算是我们见过面的方法了。但是expungeStaleEntry方法我们是第一次见到了,那么我们就先来分析它吧。还是老样子,源码和注释如下所示:
【解释】以slotToExpunge作为起点进行遍历,如果发现k==null(即:“陈旧”Entry),那么就赋值e.value=null,当前位置的Entry=null,这样gc就可以对其进行回收了。下面还会对每个k不为null的正常Entry进行重新的下标定位,目的就是让后面的元素往前面移动,因为开放地址寻找元素的时候,遇到null就停止寻找了,由于上面if代码中,k==null的时候已经设置entry为null了,不移动的话,后面的元素就访问不到了。找到新的位置后,把Entry放到新的位置上,即:tab[h]=e;
- 流程图如下所示:
3.7> cleanSomeSlots(int i, int n)
- 该方法返回的是boolean值。返回true:表示存在“陈旧”的Entry且已经被清除(但并不表示完全清除所有的“陈旧”Entry,只表示执行过这种操作)
- 由于上面的expungeStaleEntry方法,已经在“施工”范围内,清除了所有“陈旧的”Entry,并且由于在这个范围内,是不包含空位置的,所以可以顺利的把这个范围内的所有“陈旧”Entry清除掉。
- 那么cleanSomeSlots方法,则是以log2(n)的粒度,去清除一些“陈旧”Entry。
- 方法上的注释翻译如下,可以理解为是对于提升插入速度和table数组内“陈旧”Entry整理耗时的一种平衡处理方案:启发式扫描一些单元格以查找陈旧条目。当添加新元素或删除另一个陈旧元素时调用此方法。 它执行对数扫描,作为不扫描(快速但保留垃圾)和扫描次数与元素数量成正比之间的平衡,这将找到所有垃圾但会导致某些插入花费 O(n) 时间。
- 方法代码很少,源码和注释如下所示:
【解释】removed如果为false,则可以理解为table数组里基本没有“陈旧”Entry。rehash是否执行的判断依据,其实用到了removed这个结果。
- 这就表示table数组中基本都是正常的Entry,并且触达到了阈值长度,那么就可以执行rehash操作了。从而避免了table数组由于存在大量“陈旧”Entry而导致rehash的情况发生。
- 流程图如下所示:
3.8> rehash()
- rehash其实包含两部分内容
1> 遍历table数组,清除表中的所有“陈旧”Entry。
2> 如果满足数组中存在的Entry数量 >= 3/4threshold,则进行resize()扩容操作。
- 源码和注释如下所示:
- 流程图如下所示:
3.9> expungeStaleEntries()
- 该方法内部比较简单,就是遍历table数组里的Entry,调用expungeStaleEntry方法(expungeStaleEntry详情上面介绍了,这里就不再赘述了)
- 源码和注释如下所示:
3.10> resize()
- 扩容操作执行如下操作:
1> 按照原table数组长度,创造长度为2倍的新table数组。
2> 将旧table数组中的Entry插入到全新的table数组中,具体插入方式采用“开发地址法”。(前面也说过了,这里也不赘述了)
3> 根据新的table数组,更新全局变量:table、size、threshold。
- 源码和注释如下所示:
四、ThreadLocal内存溢出问题:
- 通过上面的分析,我们知道
expungeStaleEntry()
方法是帮助垃圾回收的,根据源码,我们可以发现 get 和set 方法都可能触发清理方法expungeStaleEntry()
,所以正常情况下是不会有内存溢出的。 - 但是如果我们没有调用get和set的时候就会可能面临着内存溢出。
- 养成好习惯不再使用的时候调用remove(),加快垃圾回收,避免内存溢出。
- 退一步说,就算我们没有调用get和set和remove方法,线程结束的时候,也就没有强引用再指向ThreadLocal中的ThreadLocalMap了,这样ThreadLocalMap和里面的元素也会被回收掉。
- 但是有一种危险是,如果线程是线程池的,在线程执行完代码的时候并没有结束,只是归还给线程池,这个时候ThreadLocalMap和里面的元素是不会回收掉的。