JDK1.8中ThreadLocal源码解析

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

  • 4
    点赞
  • 23
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 1
    评论
ConcurrentHashMap是Java并发访问的哈希表实现,它在多线程环境下提供了高效的并发操作。 在JDK 1.8,ConcurrentHashMap的实现基于数组和链表结构,同时引入了红黑树来提高性能。下面是对ConcurrentHashMap的源码解析: 1. 分段锁:ConcurrentHashMap使用了分段锁(Segment)的机制,将整个数据结构分成多个Segment。每个Segment维护了一部分键值对,它们之间是相互独立的。这样在并发访问时,只需要锁住对应的Segment,不同的Segment可以并发执行,极大地提高了并发访问的效率。 2. 数据结构:ConcurrentHashMap内部使用了一个由Segment数组组成的table来存储数据。每个Segment都是一个独立的哈希表,继承自ReentrantLock来保证线程安全。每个Segment包含一个HashEntry数组,每个HashEntry是一个链表或红黑树的节点。 3. put操作:当进行put操作时,首先计算键的哈希值,然后通过哈希值的高位和Segment数组长度进行运算,确定需要操作的Segment。在对应的Segment进行插入操作,使用lock()方法获取Segment对应的锁。如果插入时发现链表过长(默认阈值为8),会将链表转换为红黑树,提高插入和查找的速度。如果插入的键已存在,会更新对应的值。 4. get操作:当进行get操作时,也首先计算键的哈希值,然后确定需要操作的Segment。在对应的Segment进行查找操作,使用lock()方法获取Segment对应的锁。在链表或红黑树查找键对应的值。 5. remove操作:当进行remove操作时,同样需要计算键的哈希值,确定需要操作的Segment。在对应的Segment进行删除操作,使用lock()方法获取Segment对应的锁。在链表或红黑树查找键,并删除对应的节点。 总结来说,ConcurrentHashMap通过分段锁和内部数据结构的优化,在多线程环境下提供了高效的并发访问。它支持高并发的插入、查找和删除操作,同时保证数据的一致性和正确性。但需要注意,在遍历ConcurrentHashMap时,由于Segements之间是独立的,可能会存在一些不一致的情况,因此在遍历时需谨慎。
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

小志的博客

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值