ThreadLocal源码学习笔记(二)

目录

内存泄漏

ThreadLocal的内存泄漏

强引用链路导致的内存泄漏

ThreadLocal源码对内存泄漏的处理

set方法深入解析

replaceStaleEntry

cleanSomeSlots

expungeStaleEntry

rehash

使用者应该注意的地方


内存泄漏

我对java的内存泄漏理解是某个对象你访问不到他,但是GCRoots可达性分析可以访问到,所以这个对象既无法被使用,也无法被回收,这样一来就造成了这块内存空间被浪费

ThreadLocal的内存泄漏

强引用链路导致的内存泄漏

回顾一下这张图的引用关系,可以发现除了key对于threadLocal是弱引用关系,其余都是强引用。

如果threadLocal外部强引用被置为null(threadLocalInstance=null)的话,threadLocal实例就没有一条强引用链路可达,很显然在gc的时候势必会被回收,因此entry就存在key为null的情况,无法通过一个Key为null去访问到该entry的value。

然而从图中可以看出存在这样一条强引用链路:

Thread对象引用--->当前线程对象中的threadLocalMap引用--->threadLocalMap中的entry引用--->value引用--->value对象本身

这条强引用链路导致key为null的情况下entry和value都不能被回收,且外部无法访问到,因此就造成了内存泄漏

当然,线程执行结束后,Thread对象引用会断掉,如果没有用static修饰的话threadLocal,threadLocalMap,entry都会被回收掉。可是,在实际使用中我们都是会用线程池去维护我们的线程,比如在Executors.newFixedThreadPool()时创建线程的时候,为了复用线程是不会结束的,所以threadLocal内存泄漏就值得我们关注。

ThreadLocal源码对内存泄漏的处理

set方法深入解析

private void set(ThreadLocal<?> key, Object value) {

    Entry[] tab = table;
    int len = tab.length;
    int i = key.threadLocalHashCode & (len-1); // 用key的hashCode计算槽位
    // hash冲突时,使用开放地址法
    // 因为独特和hash算法,导致hash冲突很少,一般不会走进这个for循环
    for (Entry e = tab[i];
         e != null;
         e = tab[i = nextIndex(i, len)]) {
        ThreadLocal<?> k = e.get();

        if (k == key) { // key 相同,则覆盖value
            e.value = value; 
            return;
        }

        if (k == null) { // key = null,说明 key 已经被回收了,进入替换方法
            replaceStaleEntry(key, value, i);
            return;
        }
    }
    // 新增 Entry
    tab[i] = new Entry(key, value);
    int sz = ++size;
    if (!cleanSomeSlots(i, sz) && sz >= threshold) // 清除一些过期的值,并判断是否需要扩容
        rehash(); // 扩容
}

关键点:

  • 开放地址法:与我们常用的Map不同,java里大部分Map都是用链表发解决hash冲突的,而 ThreadLocalMap 采用的是开发地址法。而且处理哈希冲突的时候采用线性探测固定1的步长寻找下一个可用的地址。

  • 探测过程中slot都不无效,并且顺利找到key所在的slot,直接替换即可
  • 探测过程中发现有无效slot,调用replaceStaleEntry,效果是最终一定会把key和value放在这个slot,并且会尽可能清理无效slot。              在replaceStaleEntry过程中,如果找到了key,则做一个swap把它放到那个无效slot中,value置为新值。                                              在replaceStaleEntry过程中,没有找到key,直接在无效slot原地放entry

  • hash算法:hash值算法的精妙之处上篇已经讲了,均匀的 hash 算法使其可以很好的配合开方地址法使用。

  • 过期值清理:关于过期值的清理,也是解决内存溢出的一种办法。

  • 探测没有发现key,则在连续段末尾的后一个空位置放上entry,这也是线性探测法的一部分。放完后,做一次启发式清理,如果没清理出去key,并且当前table大小已经超过阈值了,则做一次rehash,rehash函数会调用一次全量清理slot方法也即expungeStaleEntries,如果完了之后table大小超过了threshold – threshold / 4,则进行扩容2倍
     

replaceStaleEntry

因为开发地址发的使用,导致 replaceStaleEntry 这个方法有些复杂,它的清理工作会涉及到slot前后的非null的slot。

private void replaceStaleEntry(ThreadLocal<?> key, Object value,
                               int staleSlot) {
    Entry[] tab = table;
    int len = tab.length;
    Entry e;

    // 向前找到第一个脏entry
    int slotToExpunge = staleSlot;
    for (int i = prevIndex(staleSlot, len);
         (e = tab[i]) != null;
         i = prevIndex(i, len))
        if (e.get() == null)
            slotToExpunge = i;

    // 找到 key 或者 直到 遇到null 的slot 才终止循环
    for (int i = nextIndex(staleSlot, len);
         (e = tab[i]) != null;
         i = nextIndex(i, len)) {
        ThreadLocal<?> k = e.get();

        // 如果在向后环形查找过程中发现key相同的entry就覆盖并且和脏entry进行交换
        if (k == key) {
            e.value = value;

            tab[i] = tab[staleSlot];
            tab[staleSlot] = e;

            // 如果在查找过程中发现脏entry,那么就以该位置作为cleanSomeSlots的起点
            if (slotToExpunge == staleSlot)
                slotToExpunge = i;
            //搜索脏entry并进行清理
            cleanSomeSlots(expungeStaleEntry(slotToExpunge), len);
            return;
        }

        // 如果向前未搜索到脏entry,则在查找过程遇到脏entry的话,后面就以此时这个位置
        // 作为起点执行cleanSomeSlots
        if (k == null && slotToExpunge == staleSlot)
            slotToExpunge = i;
    }

   //如果在查找过程中没有找到可以覆盖的entry,则将新的entry插入在脏entry
    tab[staleSlot].value = null;
    tab[staleSlot] = new Entry(key, value);

    // 如果还有其他过期的entries存在 run 中,则清除他们
    if (slotToExpunge != staleSlot)
        cleanSomeSlots(expungeStaleEntry(slotToExpunge), len);
}

cleanSomeSlots

private boolean cleanSomeSlots(int i, int n) {
    boolean removed = false;
    Entry[] tab = table;
    int len = tab.length;
    do {
        i = nextIndex(i, len);
        Entry e = tab[i];
        if (e != null && e.get() == null) {
            n = len;
            removed = true;
            i = expungeStaleEntry(i); // 清除方法 
        }
    } while ( (n >>>= 1) != 0);  // n = n / 2, 对数控制循环 
    return removed;
}

remove和set方法都会调用cleanSomeSlots,该方法会试探性地扫描一些 entry 寻找过期的条目。它执行 对数 数量的扫描,是一种基于不扫描(快速但保留垃圾)所有元素扫描之间的平衡。每次扫描到失效元素时就会扩大log2(length)个扫描元素。

此方法并没有真正的清除,只是找到了要清除的位置,而真正的清除在 expungeStaleEntry(int staleSlot) 里面。

expungeStaleEntry

这里是真正的清除,并且不要被方法名迷惑,不仅仅会清除当前过期的slot,还回往后查找直到遇到null的slot为止。开发地址法的清除也较难理解,清除当前slot后还有往后进行rehash。

private int expungeStaleEntry(int staleSlot) {
    Entry[] tab = table;
    int len = tab.length;

    // 清除当前过期的slot
    tab[staleSlot].value = null;
    tab[staleSlot] = null;
    size--;

    // Rehash 直到 null 的 slot
    Entry e;
    int i;
    for (i = nextIndex(staleSlot, len);
         (e = tab[i]) != null;
         i = nextIndex(i, len)) {
        ThreadLocal<?> k = e.get();
        if (k == null) {
            e.value = null;
            tab[i] = null;
            size--;
        } else {
            int h = k.threadLocalHashCode & (len - 1);
            if (h != i) {
                tab[i] = null;

                while (tab[h] != null)
                    h = nextIndex(h, len);
                tab[h] = e;
            }
        }
    }
    return i;
}
  • 清理当前脏entry,即将其value引用置为null,并且将table[staleSlot]也置为null。value置为null后该value域变为不可达,在下一次gc的时候就会被回收掉,同时table[staleSlot]为null后以便于存放新的entry;
  • 从当前staleSlot位置向后环形(nextIndex)继续搜索,直到遇到哈希桶(tab[i])为null的时候退出;
  • 若在搜索过程再次遇到脏entry,继续将其清除。

rehash

private void rehash() {
    expungeStaleEntries();

   // 在上面的清除过程中,size会减小,在此处重新计算是否需要扩容
   // 并没有直接使用threshold,而是用较低的threshold (约 threshold 的 3/4)提前触发resize
    if (size >= threshold - threshold / 4)
        resize();
}

private void expungeStaleEntries() {
    Entry[] tab = table;
    int len = tab.length;
    for (int j = 0; j < len; j++) {
        Entry e = tab[j];
        if (e != null && e.get() == null)
            expungeStaleEntry(j);
    }
}

使用者应该注意的地方

  1. 每次使用完ThreadLocal,都调用它的remove()方法,清除数据。

  2. 在使用线程池的情况下,没有及时清理ThreadLocal,不仅是内存泄漏的问题,更严重的是可能导致业务逻辑出现问题。所以,使用ThreadLocal就跟加锁完要解锁一样,用完就清理。

参考资料:

ThreadLocal内存泄漏真因探究:https://www.jianshu.com/p/a1cd61fa22da

一篇文章,从源码深入详解ThreadLocal内存泄漏问题:https://www.jianshu.com/p/dde92ec37bd1

Threadlocal 和 ThreadLocalMap 原理解析:https://blog.csdn.net/zhuzj12345/article/details/84333765

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值