一、ThreadLocal概述
ThreadLocal 是一个线程的本地变量,也就意味着这个变量是线程独有的,是不能与其他线程共享的。这样就可以避免资源竞争带来的多线程的问题。
但是,这种解决多线程安全问题的方式和加锁方式(synchronized、Lock) 是有本质的区别的,区别如下所示:
(1)、关于资源的管理
当资源是多个线程共享的,所以访问的时候可以通过加锁的方式,逐一访问资源。
ThreadLocal是每个线程都有一个资源副本,是不需要加锁的。
(2)、关于实现方式
- 锁是通过时间换空间的做法。
- ThreadLocal是通过空间换时间的做法。
二、ThreadLocal的使用方式
- ThreadLocal的使用方法很简单,如下面所示:
ThreadLocal threadLocal = new ThreadLocal();
threadLocal.set("xz");
threadLocal.get();
- 由上面代码示例可知,要看源码出发点自然就是set和get方法了。
三、ThreadLocal源码分析
3.1、ThreadLocal、Thread、ThreadLocalMap、Entry之间的关系
3.2、ThreadLocal的set(T value)方法
- 上面截图中红框中的代码,会是我们下面着重要介绍的。
- 当我们创建ThreadLocal后,第一次调用set方法赋值的时候,由于ThreadLocalMap还没有被创建,所以会执行createMap(t, value)方法来对ThreadLocalMap进行初始化。其中,源码和注释如下所示:
void createMap(Thread t, T firstValue) {
t.threadLocals = new ThreadLocalMap(this, firstValue);
}
- 从上面源码中我们可以看到,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占据了,那么来进行如下判断:
(1)、如果已存在的Entry的key值与我们的key值相同(即:是同一个ThreadLocal实例对象),那么我们只是将value值更新为方法入参的value即可。
(2)、如果key值不同,那么来判断,已存在的Entry是不是key==null(即:是一个“陈旧的”元素,那么我们进行替换操作)
(3)、如果都不满足,那就往后遍历其他的Entry元素,直到满足上述条件为止,否则会一直循环。
3.3、nextIndex和prevIndex
- 我们先来看第一个红框中的方法nextIndex(i, len),其实通过该方法,我们还可以引出prevIndex(i, len)方法。源码和注释如下所示:
- 上图源码解释
- nextIndex就是从指定的下标i开始,向后获取下一个位置的下标值。
- preIndex就是从指定的下标i开始,前向获取上一个位置的下标值。
- 如果越界了怎么办呢?它们会采用循环查找法。即:获取队尾的下一个下标就会返回队首的下标;获取队首的上一个下标就会返回队尾的下标。如下所示:
3.4、开放地址法
3.4.1、开放地址法
- ThreadLocalMap并没有按照我们之前在学习HashMap的方式去解决哈希冲突,即:数组+链表。而它其实使用的是一种叫做“开放地址法”作为解决哈希冲突的一种方式。
- 开放地址法的基本思想就是:一旦发生了冲突,那么就去寻找下一个空的地址;那么只要表足够大,空的地址总能找到,并将记录插入进去。
3.4.2、ThreadLocalMap和HashMap的区别
- HashMap
(1)、数据结构是数组+链表
(2)、通过链地址法解决hash冲突的问题
(3)、里面的Entry内部类的引用都是强引用 - ThreadLocalMap
(1)、数据结构仅仅是数组
(2)、通过开放地址法来解决hash冲突的问题
(3)、Entry内部类中的key是弱引用,value是强引用
3.4.3、链地址法和开放地址法的优缺点
- 开放地址法
(1)、容易产生堆积问题,不适于大规模的数据存储。
(2)、散列函数的设计对冲突会有很大的影响,插入时可能会出现多次冲突的现象。
(3)、删除的元素是多个冲突元素中的一个,需要对后面的元素作处理,实现较复杂。 - 链地址法
(1)、处理冲突简单,且无堆积现象,平均查找长度短。
(2)、链表中的结点是动态申请的,适合构造表不能确定长度的情况。
(3)、删除结点的操作易于实现。只要简单地删去链表上相应的结点即可。
(4)、指针需要额外的空间,故当结点规模较小时,开放定址法较为节省空间。
3.4.4、ThreadLocalMap采用开放地址法原因
- ThreadLocal往往存放的数据量不会特别大(而且key 是弱引用又会被垃圾回收,及时让数据量更小)。
- 采用开放地址法简单的结构会更节省空间,同时数组的查询效率也是非常高,加上第一点的保障,冲突概率也比较低。
- 了解了开发地址法的原理之后继续看下面的源码
3.5、 replaceStaleEntry(key, value, i)
- 当发现待插入的位置上已经被其他Entry占用了,并且它的key值与我们不同(即:不是同一个ThreadLocal实例),那么,当这个已存在的Entry元素key==null的时候,逻辑上就走到了第二个红框里的方法——replaceStaleEntry(key, value, i),该方法是用来替换“陈旧的”Entry的。下面我们来看一下这个方法的代码和注释:
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和里面的元素是不会回收掉的。