专业的编程技术博客社区

网站首页 > 博客文章 正文

彻底吃透ThreadLocal(threadlocal清除)

baijin 2024-09-26 06:52:21 博客文章 4 ℃ 0 评论

1、了解ThreadLocal是什么

2、ThreadLocal的应用场景

3、ThreadLocal的原理

4、ThreadLocal是如何解决哈希冲突的

5、ThreadLocal的set源码、get源码解析

6、ThreadLocal是如何清理过期的key

7、ThreadLocal内存泄漏发生的原因以及如何避免

8、ThreadLocal的使用注意事项

ThreadLocal是什么

ThreadLocal类顾名思义可以理解为线程本地变量。也就是每个线程往这个ThreadLocal中读写变量时,这个变量是 线程隔离 , 互相之间不会影响 的。

ThreadLocal应用场景

  • 解决数据库连接、Session管理等
  • 保存登录用户信息,贯穿代码各个层面。如果不使用ThreadLocal的API,传统的做法就是通过session的传递,从控制层传到业务层,代码耦合以及冗余,通过ThreadLocal的API很容易在各个代码层面获取到用户的登录信息。
  • SpringAop 代理中,保存适配通知代理的bean的名称 。在真正对普通bean作为tartget创建代理proxy对象前,先是找该类所对应所有的可以适配的 通知类集合时,就使用到了ThreadLocal
  • MyBatis 分页插件 PageHelper 的实现

ThreadLocal的原理

1、ThreadLocal的主要原理

2、ThreadLocal的set过程、get过程

3、ThreadLocalMap的 Hash算法以及解决冲突方式

主要是通过Thread 类ThreadLocal.ThreadLocalMap 这个静态内部类来存储数据,ThreadLocal.ThreadLocalMap就是一个键值对的 Map,它的底层是 Entry 对象数组,Entry 对象中存放的键是 ThreadLocal 对象,值是 Object 类型的具体存储内容。对于如何定位数组下标和解决哈希冲突,可以通过set过程说明。

ThreadLocal的set过程

  1. 首先得到当前执行的线程
  2. 得到当前线程的的ThreadLocalMap变量
  3. 如果不为空,将当前的threadlocal引用当做key,存储的内容当做value放入到ThreadLocalMap中
    1. 第一步定位数组下标:每次定位的时候都使用Integer原始类自增黄金分割数,然后再与数组长度进行与计算。使用这个黄金分割数,使得hash分布非常的均匀。
    2. 对对应下标的值进行比对。
      1. 第一种情况: 通过hash计算后的槽位对应的Entry数据为空:直接将数据放到该槽位即可。
      2. 第二种情况: 槽位数据不为空,key值与当前ThreadLocal通过hash计算获取的key值一致:直接更新该槽位的数据。
      3. 第三种情况: 槽位数据不为空,往后遍历过程中,在找到Entrynull的槽位之前,没有遇到key过期的Entry:遍历散列数组,线性往后查找,如果找到Entrynull的槽位,则将数据放入该槽位中,或者往后遍历过程中,遇到了key 值相等的数据,直接更新即可。
      4. 第四种情况: 槽位数据不为空,往后遍历过程中,在找到Entrynull的槽位之前,遇到key过期的Entry表明此数据key值已经被垃圾回收掉了,此时就会执行replaceStaleEntry()方法,该方法含义是替换过期数据的逻辑
  1. 如果ThreadLocalMap为空,则初始化并且存入key、value.

以下是set的源码解析以及配图,详细介绍了如何定位数组的下标以及如何解决哈希的冲突


ThreadLocalMap Hash算法

既然是Map结构,那么ThreadLocalMap当然也要实现自己的hash算法来解决散列分布是否均匀问题

关键是使用斐(fěi)波那契算法,ThreadLocal中有一个属性为HASH_INCREMENT = 0x61c88647,每当创建一个ThreadLocal对象,这个ThreadLocal.nextHashCode 这个值就会增长0x61c88647 ,这个值很特殊,它是斐波那契数 也叫 黄金分割数。hash增量为 这个数字,带来的好处就是hash 分布非常均匀

int i = key.threadLocalHashCode & (len-1);

//对应的函数实质调用的是nextHashCode();
private final int threadLocalHashCode = nextHashCode();

public class ThreadLocal<T> {
    private final int threadLocalHashCode = nextHashCode();
 
    private static AtomicInteger nextHashCode = new AtomicInteger();
 
    private static final int HASH_INCREMENT = 0x61c88647;
 
    private static int nextHashCode() {
        return nextHashCode.getAndAdd(HASH_INCREMENT);
    }
}

ThreadLocalMap Hash冲突

虽然ThreadLocalMap中使用了黄金分隔数来作为hash计算因子,大大减少了Hash冲突的概率,但是仍然会存在冲突。ThreadLocal采取的是采取的是:线性探测法(开发地址法)

1、开放地址法:基本思想是一旦发生了冲突,就去寻找下一个空的散列地址

2、HashMap中解决冲突的方法是在数组上构造一个链表结构,冲突的数据挂载到链表上,如果链表长度超过一定数量则会转化成红黑树。而ThreadLocalMap中并没有链表结构,所以这里不能适用HashMap解决冲突的方式了。

探讨值存储的情况

注明: 下面所有示例图中,绿色块Entry代表正常数据,灰色块代表Entry的key值为null,已被垃圾回收。白色块表示Entry为null。

第一种情况: 通过hash计算后的槽位对应的Entry数据为空:

这里直接将数据放到该槽位即可。


第二种情况: 槽位数据不为空,key值与当前ThreadLocal通过hash计算获取的key值一致(同一个ThreadLocal):

这里直接更新该槽位的数据。


第三种情况: 槽位数据不为空,而且key也不相同,发生了hash冲突,往后遍历过程中,在找到Entry为null的槽位之前,没有遇到key过期的Entry(两个不同的ThreadLocal,产生了哈希碰撞):

遍历散列数组,线性往后查找,如果找到Entry为null的槽位,则将数据放入该槽位中,或者往后遍历过程中,遇到了key值相等的数据,直接更新即可。


第四种情况: 槽位数据不为空,往后遍历过程中,在找到Entry为null的槽位之前,遇到key过期的Entry(entry不为空,但是key为空了),如下图,往后遍历过程中,一到了index=7的槽位数据Entry的key=null:

散列数组下标为7位置对应的Entry数据key为null,表明此数据key值已经被垃圾回收掉了,此时就会执行replaceStaleEntry()方法,该方法含义是替换过期数据的逻辑,以index=7位起点开始遍历,进行探测式数据清理工作。


replaceStaleEntry()过期数据替换过程

初始化探测式清理过期数据扫描的开始位置:slotToExpunge = stateSlot = 7

以当前stateSlot开始向前迭代找到,找到其他过期的数据,然后更新过期数据起始扫描下标的slotToExpunge,直到找到了Entrynull的槽位则结束。

如果找到过期数据,继续向前迭代,直到遇到Entry=null的槽位则停止迭代,如下图所示,slotToExpunge被更新为0:

上面向前迭代的操作是为了更新探测清理过期数据的起始位置soltToExpunge的值,这个值是用来判断当前过期槽位staleSlot之前是否还有过期元素。

接着开始staleSolt位置index = 7向后迭代,如果找到了相等keyEntry的数据则更新value值,如下图:

从当前节点staleSolt位置开始向后寻找key相等的Entry位置,如果找到了key相等的Entry,则会交换staleSlot元素的位置,且更新value值,然后进行过期Entry的清理工作,如下图:

如果没有找到相等keyEntry的数据,如下图:

从当前节点staleSlot向后查找key值相等的Entry,如果没有找到,则会继续往后查找直到找到Entrynull停止,然后创建新的Entry,替换stableSlot的位置。

替换完成之后也是进行过期元素的清理工作,清理工作的方法主要有两个expungeStaleEntrycleanSomeSlots


ThreadLocal的get过程

主要包含两种情况,一种是hash计算出下标,该下标对应的Entry.key和我们传入的key相等的情况,另外一种就是不相等的情况。


第一种情况: 通过查找key值计算出散列表中slot位置,然后该slot位置中的Entry.key和查找的key一致,则直接返回:

第二种情况: slot位置中的Entry.key和要查找的key不一致(哈希冲突):

我们以get(ThreadLocal2)为例,通过hash计算后,正确的slot位置应该是4,而index=4的槽位已经有了数据,且key值不等于ThreadLocal2,所以需要继续往后迭代查找。迭代到index=5的数据时,此时Entry.key=null,触发一次探测式数据回收操作,执行expungeStaleEntry()方法,执行完后,index 5,8的数据都会被回收,而index 6,7的数据都会前移,此时继续往后迭代,到index = 6的时候即找到了key值相等的Entry数据,如下图所示:

ThreadLocalMap过期key清理方式

两种清理方式:探测式清理(expungeStaleEntry())启发式清理(cleanSomeSlots())

触发点:

1、set() 方法中,遇到key=null的情况会触发一轮 探测式清理 流程

2、set() 方法最后会执行一次 启发式清理 流程

3、rehash() 方法中会调用一次 探测式清理 流程

4、get() 方法中 遇到key过期的时候会触发一次 探测式清理 流程

5、启发式清理流程中遇到key=null的情况也会触发一次 探测式清理 流程

探测式清理(expungeStaleEntry())

探测式清理,从当前节点(hash得到的位置)开始遍历数组,key==null的将entry置为null,key!=null的对当前元素的key重新hash分配位置,若重新分配的位置上有元素就往后顺延。

启发式清理(cleanSomeSlots())

从当前节点开始,进行do-while循环检查清理过期key,结束条件是连续n次未发现过期key就跳出循环,n是经过位运算计算得出的,可以简单理解为数组长度的2的多少次幂 次

ThreadLocal的内存泄露

关于ThreadLocal是否会引起内存泄漏也是一个比较有争议性的问题。首先我们需要知道什么是内存泄露?

?

在Java中,内存泄漏就是存在一些被分配的对象,这些对象有下面两个特点,

首先,这些对象是可达的,即在有向图中,存在通路可以与其相连;

其次,这些对象是无用的,即程序以后不会再使用这些对象。如果对象满足这两个条件,这些对象就可以判定为Java中的内存泄漏,这些对象不会被GC所回收,然而它却占用内存。

?

发生内存泄漏的原因

1.key为弱引用,gc之后key为null,导致value无法被获取到
2.ThreadLocalMap的生命周期与Thread相同,导致内存泄漏一直存在

而 ThreadLocal 发生内存泄漏的原因需要从 Entry 对象说起。


Entry 对象的 key 即 ThreadLocal 类是继承于 WeakReference 弱引用类。具有弱引用的对象有更短暂的生命周期,在发生 GC 活动时,无论内存空间是否足够,垃圾回收器都会回收具有弱引用的对象。

由于 Entry 对象的 key 是继承于 WeakReference 弱引用类的,若 ThreadLocal 类没有外部强引用,当发生 GC 活动时就会将 ThreadLocal 对象回收(弱引用的特性,没有外部的强引用,发生gc的时候,就会像弱引用回收)。

而此时如果创建 ThreadLocal 类的线程依然活动,那么 Entry 对象中 ThreadLocal 对象对应的 value 就依旧具有强引用而不会被回收,从而导致内存泄漏。


ThreadLocal的内存泄露情况:

  • 线程的生命周期很长,当ThreadLocal没有被外部强引用的时候就会被GC回收(给ThreadLocal置空了):ThreadLocalMap会出现一个key为null的Entry,但这个Entry的value将永远没办法被访问到(后续在也无法操作set、get等方法了)。如果当这个线程一直没有结束,那这个key为null的Entry因为也存在强引用(Entry.value),而Entry被当前线程的ThreadLocalMap强引用(Entry[] table),导致这个Entry.value永远无法被GC,造成内存泄漏。 下面我们来演示下这个场景
 public static void main(String[] args) throws InterruptedException {  
        ThreadLocal<Long []> threadLocal = new ThreadLocal<>();  
        for (int i = 0; i < 50; i++) {  
            run(threadLocal);  
        }  
        Thread.sleep(50000);  
        // 去除强引用  
        threadLocal = null;  
        System.gc();  
        System.runFinalization();  
        System.gc();  
    }  
  
    private static void run(ThreadLocal<Long []> threadLocal) {  
        new Thread(() -> {  
            threadLocal.set(new Long[1024 * 1024 *10]);  
            try {  
                Thread.sleep(1000000000);  
            } catch (InterruptedException e) {  
                e.printStackTrace();  
            }  
        }).start();  
    }

通过jdk自带的工具jconsole.exe会发现即使执行了gc 内存也不会减少,因为key还被线程强引用着。效果图如下:

  • 针对于这种情况 ThreadLocalMap在设计中,已经考虑到这种情况的发生,你只要调用了set()、get()、remove()方法都会调用cleanSomeSlots()、expungeStaleEntry()方法去清除key为null的value。这是一种被动的清理方式,但是如果ThreadLocal的set(),get(),remove()方法没有被调用,就会导致value的内存泄漏。它的文档推荐我们使用static修饰的ThreadLocal,导致ThreadLocal的生命周期和持有它的类一样长,由于ThreadLocal有强引用在,意味着这个ThreadLocal不会被GC。在这种情况下,我们如果不手动删除,Entry的key永远不为null,弱引用也就失去了意义。所以我们在使用的时候尽可能养成一个好的习惯,使用完成后手动调用下remove方法。其实实际生产环境中我们手动remove大多数情况并不是为了避免这种key为null的情况,更多的时候,是为了保证业务以及程序的正确性。比如我们下单请求后通过ThreadLocal构建了订单的上下文请求信息,然后通过线程池异步去更新用户积分,这时候如果更新完成,没有进行remove操作,即使下一次新的订单会覆盖原来的值但是也是有可能会导致业务问题。 如果不想手动清理是否还有其他方式解决下列? FastThreadLocal 可以去了解下,它提供了自动回收机制。
  • 在线程池的场景,程序不停止,线程一直在复用的话,基本不会销毁,其实本质就跟上面例子是一样的。如果线程不复用,用完就销毁了就不会存在泄露的情况。因为线程结束的时候会jvm主动调用exit方法清理。


如何解决内存泄漏问题

  • 手动:使用完 ThreadLocal 中存储的内容主动调用它的 remove 方法
  • 自动:频繁调用get、set方法时,线程会通过线性检测法处理哈希冲突时,清理无效的 Entry

本文暂时没有评论,来添加一个吧(●'◡'●)

欢迎 发表评论:

最近发表
标签列表