ThreadLocal 源码解析(二)

629 阅读7分钟

前言

上文ThreadLocal 源码解析(一)中,结合源码分析了ThreadLocal线程隔离的原理以及ThreadLocalMap的取数据和删数据的流程,本文将紧接上文,结合源码继续分析ThreadLocalMap的删除数据、清除过期数据、扩容的流程

正文

删除数据

代码片段1
public void remove() {
    ThreadLocalMap m = getMap(Thread.currentThread());
    if (m != null)
        m.remove(this);
}

代码比较简单,通过当前线程获取 ThreadLocalMap,然后调用ThreadLocalMap#remove,代码如下:

代码片段2
private void remove(ThreadLocal<?> key) {
    Entry[] tab = table;
    int len = tab.length;
    //注释1 获取当前key的hash获取索引
    int i = key.threadLocalHashCode & (len-1);
    for (Entry e = tab[i];
         e != null;
         e = tab[i = nextIndex(i, len)]) {
         //注释2 
        if (e.get() == key) {
        //注释3
            e.clear();
            //注释4
            expungeStaleEntry(i);
            return;
        }
    }
}

通过前文插入数据的流程知道,在注释1获取的i并不一定是key真正插入的位置,因为 ThreadLocalMap 采用的是开放地址法解决的hash冲突,所以需要从i的位置遍历,找到key相等的Entry在进行删除,下面结合图分析,假设一开始 ThreadLocalMap 中的数据如下(图中的hash函数表示的是上面代码注释1处的key.threadLocalHashCode & (len-1)): image.png 假设这个删除的数据是key1,通过注释1处i=1,进入到循环遍历走到注释2处显然key1==key1,走到注释3调用了e.clear(),注意这里并没有直接删除数据中的数据,只是调用了Reference的clear,上文中,我们知道Entry是继承至WeakRefrence,调用clear之后,Entry的key为null,所以删除之后数据如下:

image.png 这个时候再去删除key4,在注释1处通过key4的hash计算出i=2,进入循环,由于key2不等于key4,继续往下,知道到达entry4,进行删除,删除后数据结构如下:

image.png 这个时候继续删除key5,假设hash(key5)的值为3,遍历到数组中第五个位置,e=null,结束循环,数据结构不变,继续删除key6,假设hash(key6)为6,e=null,数据不变。

清除过期数据

回到代码片段2中的注释4处 expungeStaleEntry(),见名知义,清除过期数据,结合上一篇文章我们知道,expungeStaleEntry()不但会在删除数据是触发,还会在插入数据、取数据的时候触发,如下:

代码片段3
private int expungeStaleEntry(int staleSlot) {
    Entry[] tab = table;
    int len = tab.length;

    // 注释1 
    tab[staleSlot].value = null;
    tab[staleSlot] = null;
    size--;

   
    Entry e;
    int i;
    for (i = nextIndex(staleSlot, len);
         (e = tab[i]) != null;
         i = nextIndex(i, len)) {
        ThreadLocal<?> k = e.get();
        //注释2 
        if (k == null) {
            e.value = null;
            tab[i] = null;
            size--;
        } else {
            //注释3 重新计算位置
            int h = k.threadLocalHashCode & (len - 1);
            if (h != i) {
                //注释4
                tab[i] = null;
                //注释5
                while (tab[h] != null)
                    h = nextIndex(h, len);
                tab[h] = e;
            }
        }
    }
    return i;
}

注释1处清理当前位置,代码片段2注释3处只是对key这个WeakRefrence进行了清理,所以这里对value进行了置空,以及对table数组相应的位置进行了置空,按道理代码流转到这里就应该结束了,那接下来的那一坨是干啥的呢?下面结合图分析,假设i=0,当前数据结构如下:

image.png 遍历到entry1处,流程走到注释2处,entry1的key为null,过期数据,执行清理,清理之后数据结构如下:

image.png 继续往下遍历由于entry2不是脏数据,并且经过注释3的计算的存储的位置和entry2实际存储位置相等,因此不做任何调整,继续往下,entry3的key等于null,清除entry3,清理之后数据结构如下: image.png 继续往下遍历,到entry4,这个时候注释3处的h为1,但是i=4, 到注释4处对数组的当前位置置空,注释5处重新查找一个空位置放当前遍历的 entry4,由于此时tab[h]也就是tab[1]为null,所以把当前遍历的数据e移动到数组中的h位置,移动之后,数据结构如下:

image.png 也就是说 expungeStaleEntry() 除了清理当前位置的过期数据,还会对当前位置和第一个空位置之间的数据进行整理,如果是过期数据执行清理,这样做的好处是节省内存,如果不是过期数据那么就把数据移动到尽可能靠近通过hash计算的位置,通过上一篇ThreadLocal 源码解析(一)的插入数据以及读取数据知道,这样做的好处是提高接下来存取数据的查找效率。

扩容

扩容发生在ThreadLocalMap#set(),因为ThreadLocal冲突解决的策略是开放地址法,当数据的富集度到达很高的程度,继续插入数据,要查找空的位置执行的遍历次数就会变多,效率很低,所以要进行扩容,代码如下:

代码片段4
private void set(ThreadLocal<?> key, Object value) {

   
    int sz = ++size;
    //注释1
    if (!cleanSomeSlots(i, sz) && sz >= threshold)
        rehash();
}

看下注释1处的ThreadLocalMap#cleanSomeSlots(),如下:

代码片段5
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;
           // 注释1
            i = expungeStaleEntry(i);
        }
    } 
    //注释2
    while ( (n >>>= 1) != 0);
    return removed;
}

注释1处调用了上一节中的 expungeStaleEntry() 函数,上一节中我们知道 expungeStaleEntry()只会对当前位置以及往下第一个为空的位置之前的过期数据进行清理,而其他位置的过期数据则不管,那就造成一个问题,如果不执行或者只执行expungeStaleEntry()一次,就有可能造成 ThreadLocalMap 中过期数据过多,要插入新数据的时候,过于频繁的触发扩容操作,影响效率以及内存利用率,但是如果每次插入数据都对整个 ThreadLocalMap 进行扫描遍历清除过期数据,那么对于性能不大友好,所以采取了一个平衡,注释2处通过对n不断的左移,将时间复杂度从O(n)变为O(logn),取得性能以及内存利用率之间的平衡。再回到代码片段4处的注释1,如果当前数据的个数sz大于等于 threshold(threshold为数组 table 长度的0.75),触发扩容,走到 ThreadLocalMap#hash(),如下:

代码判断6
private void rehash() {
    //注释1
    expungeStaleEntries();
    注释2
    if (size >= threshold - threshold / 4)
        resize();
}

注释1处全量扫描整个数组,清理过期数据,注释2处如果如果清理之后数据的个数sz大于 threshold 的0.75,执行到 ThreadLocalMap#resize()进行扩容,如下:

代码片段7

private void resize() {
    Entry[] oldTab = table;
    int oldLen = oldTab.length;
    //注释1
    int newLen = oldLen * 2;
    Entry[] newTab = new Entry[newLen];
    int count = 0;

    for (int j = 0; j < oldLen; ++j) {
        Entry e = oldTab[j];
        if (e != null) {
            ThreadLocal<?> k = e.get();
            if (k == null) {
            //注释2
                e.value = null; 
            } else {
            //注释3 
                int h = k.threadLocalHashCode & (newLen - 1);
                while (newTab[h] != null)
                    h = nextIndex(h, newLen);
                newTab[h] = e;
                count++;
            }
        }
    }
// 注释4
    setThreshold(newLen);
    size = count;
    table = newTab;
}

注释1扩容原来数组到两倍,注释2处继续清理过期数据,注释3处重新计算数据的位置,注释4处重置thresholde,数据长度以及table数组。

关于内存泄漏

ThreadLocal不恰当的用法会产生内存泄漏,原因就是Entry中,key为一个弱引用,在内存不足的情况下,gc会被清理,但是Entry的value是强引用,如果value是一个生命周期比较长的对下,就有可能导致内存泄漏,关于如何避免其实很简单,只需要记得在及时调用删除操作就行了

总结

通过两篇结合源码以及图形的文章分析,我们知道 ThreadLocalMap 的核心是一个数组,通过开放地址法解决解决hash冲突。在插入数据、获取数据、删除数据或者扩容的过程中过程中都会对过期数据进行清除,在清理的过程中会对数据进行整理,尽量使得数据存放在合适位置,并且清理分为全量扫描和局部扫描尽量力求在性能以及内存使用率方面达到平衡,这也是与ThreadLocal的使用场景紧密相关的,对于Android应用来说,一个进程可以有多个线程,应该使得每个线程的在内存占用以及性能方面尽量达到最佳平衡,提升用户体验。