JDK1.8中ThreadLocal源码解析

x33g5p2x  于2021-12-30 转载在 其他  
字(4.7k)|赞(0)|评价(0)|浏览(570)

一、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和里面的元素是不会回收掉的。

相关文章