ThreadLocal--内存泄漏问题及Java的对应处理办法

内存泄漏问题

ThreadLocal--内存泄漏问题及Java的对应处理办法_第1张图片

线程变量存储在ThreadLocalMap中,ThreadLocal只是作为key存在,而ThreadLocalMap中key为ThreadLocal的若引用。

弱引用的特点是,如果这个对象只存在弱引用,那么在下一次垃圾回收的时候必然会被清理掉。

所以如果 ThreadLocal 没有被外部强引用的情况下,在垃圾回收的时候会被清理掉的,这样一来 ThreadLocalMap中使用这个 ThreadLocal 的 key 也会被清理掉。但是,value 是强引用,不会被清理,这样一来就会出现 key 为 null 的 value,即发生内存泄漏。

ThreadLocalMap实现中已经考虑了这种情况,在调用 set()、get()、remove() 方法的时候,会清理掉 key 为 null 的记录。如果说会出现内存泄漏,那只有在出现了 key 为 null 的记录后,没有手动调用 remove() 方法,并且之后也不再调用 get()、set()、remove() 方法的情况下。

对应处理

get()方法

public T get() {
        Thread t = Thread.currentThread();
        ThreadLocalMap map = getMap(t);
        if (map != null) {
            ThreadLocalMap.Entry e = map.getEntry(this);
            if (e != null) {
                @SuppressWarnings("unchecked")
                T result = (T)e.value;
                return result;
            }
        }
        return setInitialValue();
    }

可以看到,get()方法底层就是调用Threadlocal的getEetry()方法

private Entry getEntry(ThreadLocal<?> key) {
            //散列获取index
            int i = key.threadLocalHashCode & (table.length - 1);
            //从数组中取值
            Entry e = table[i];
            //判断是否为空,Entry的key是否与传入的key一致
            if (e != null && e.get() == key)
                return e;
            //处理脏Entry或散列冲突
            else
                return getEntryAfterMiss(key, i, e);
        }

可以看到,如果Entry不为null,且传入的key与Entry中存储的key一致,则直接返回。否则要调用getEntryAfterMiss()进行处理。

private Entry getEntryAfterMiss(ThreadLocal<?> key, int i, Entry e) {
            Entry[] tab = table;
            int len = tab.length;
            while (e != null) {
                ThreadLocal<?> k = e.get();
                //如果Entry的key和传入的key一致,则返回e(说明不是脏entry)
                if (k == key)
                    return e;
                //如果为空,说明是脏key,进行处理
                if (k == null)
                    expungeStaleEntry(i);
                //非空,key不一致,说明是散列冲突,则继续向后循环查找
                else
                    i = nextIndex(i, len);
                e = tab[i];
            }
            return null;
        }

getEntryAfterMiss()的处理可以分为三种情况:

  1. Entry的key和传入的key一致:命中,直接返回
  2. key为空,说明是脏Entry,ThreadLocal失去强引用,被GC
  3. key非空,但是不一致,说明发生了散列冲突,ThreadLocalMap通过线性向后探测再散列解决,此种情况只需要向后遍历即可

1和3情况都很简单,所以我们看第2中情况,jdk的编写者做了怎样处理,也就是expungeStaleEntry()方法。

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

            //清除saleslot位置的脏Entry
            tab[staleSlot].value = null;
            tab[staleSlot] = null;
            //长度减一
            size--;

            //重新散列,直到遇到table[i]==null时
            Entry e;
            int i;
            for (i = nextIndex(staleSlot, len);
                 (e = tab[i]) != null;
                 i = nextIndex(i, len)) {
                ThreadLocal<?> k = e.get();
                //查找过程中再次遇到脏Entry,清除掉
                if (k == null) {
                    e.value = null;
                    tab[i] = null;
                    size--;
                } else {
                    //重新散列该Entry
                    int h = k.threadLocalHashCode & (len - 1);
                    //如果h != i,说明之前该Entry存在散列冲突,现在它前面的脏Entry被清理
                    //该Entry需要向前移动,防止下次get()或set()的时候再次因散列冲突而查找
                    //到null值
                    if (h != i) {
                        //当前位置置空
                        tab[i] = null;

                        //从初次散列的位置开始找,直到找到一个空位置
                        while (tab[h] != null)
                            h = nextIndex(h, len);
                        //赋值
                        tab[h] = e;
                    }
                }
            }
            //这个i,即tab[i]==null的位置
            return i;
        }

首先清除当前位置的脏Entry,然后向后遍历直到table[i]==null。在遍历的过程中如果再次遇到脏Entry就会清理,如果没有遇到就会重新散列当前遇到的Entry。如果重新散列得到的下标h与当前下标i不一致,说明该Entry被放入Entry数组的时候发生了散列冲突(其位置通过再散列被向后偏移了),现在其前面的脏Entry已经被清除,所以当前Entry应该向前移动,补上空位置。否则下次调用set()或get()方法查找该Entry的时候会查找到位于其之前的null值。

该方法会清理当前脏Entry位置,直到tab[i]==null之间的所有脏Entry。保证紧接的下次获取该Entry的操作不会读取脏Entry和null值。

set()方法

public void set(T value) {
        Thread t = Thread.currentThread();
        ThreadLocalMap map = getMap(t);
        if (map != null)
            map.set(this, value);
        else
            createMap(t, value);
    }

与get()方法一样,也是通过调用底层的ThreadLocalMap的set()方法来设置线程变量。

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

            Entry[] tab = table;
            int len = tab.length;
            //散列
            int i = key.threadLocalHashCode & (len-1);

            //tab[i]!=null,即散列位置存在一个Entry(可能是原来的Entry,可能是散列冲突,也可能是脏Entry)
            for (Entry e = tab[i];
                 e != null;
                 e = tab[i = nextIndex(i, len)]) {
                //拿到散列位置对应的key
                ThreadLocal<?> k = e.get();
                //1. 如果key一致,则赋值(说明是赋值为原来的Entry,ThreadLocal强引用还存在)
                if (k == key) {
                    e.value = value;
                    return;
                }
                //2. 如果key为null,则先处理脏数据再赋值(散列位置是脏数据,ThreadLocal强引用不存在,key被GC)
                if (k == null) {
                    replaceStaleEntry(key, value, i);
                    return;
                }
                //3. 上述两种都不是即为散列冲突,向后查找即可
            }

            //4. tab[i]==null,即散列位置不存在Entry,new一个新的放入即可
            tab[i] = new Entry(key, value);
            int sz = ++size;
            //插入后需要检测和清理脏Entry
            if (!cleanSomeSlots(i, sz) && sz >= threshold)
                rehash();
        }

可以分为4种情况:

  1. Entry!=null且key一致:说明获取到了原来的旧值,直接设置value即可
  2. Entry!=null且key==null:说明获取到了脏Entry,ThreadLocal强引用不存在,key被GC,需要清理再赋值
  3. Entry!=null且key!=null,且key不一致:说明发生了散列冲突,向后继续查找总会找到
  4. Entry==null:说明set的Entry是一个新的,则新建一个Entry放入i位置。注意:此时i的位置是方法开始时走了一遍循环后的值:nextIndex(i,len),此位置的前一个位置是脏Entry,所以赋值后需要清理

1和3情况不需要多讲,主要看看对于2和4情况,大佬是如何处理的:

replaceStaleEntry()方法:

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

            int slotToExpunge = staleSlot;
            // 向前环形查找,直到tab[i]==null,即找到散列中脏Entry的起始位置,为下一步向后清理脏Entry做准备
            //slotEoExpung负责在查找中保存脏Entry的位置
            for (int i = prevIndex(staleSlot, len);
                 (e = tab[i]) != null;
                 i = prevIndex(i, len))
                //判断是否为脏Entry
                if (e.get() == null)
                    //记录位置
                    slotToExpunge = i;

            // 向后查找,直到tab[i]==null或找到对应的key
            for (int i = nextIndex(staleSlot, len);
                 (e = tab[i]) != null;
                 i = nextIndex(i, len)) {
                ThreadLocal<?> k = e.get();

                // 向后查找到可覆盖的key
                if (k == key) {
                    //赋值
                    e.value = value;

                    //将当前位置的Entry与staleSlot位置(即第一次散列位置)的Entry做交换
                    //防止下次set()或get()时出现散列冲突读到null值,维护hash表顺序
                    tab[i] = tab[staleSlot];
                    tab[staleSlot] = e;

                    //true: 向前查找没找到脏Entry,向后查找到可覆盖key
                    //false:向前查找找到脏Entry,向后查找到可覆盖key
                    if (slotToExpunge == staleSlot)
                        // 如果slotToExpunge == staleSlot,则说明slotToExpunge到i之间没有脏Entry
                        //所以更新脏Entry位置,为后面的清理做准备
                        slotToExpunge = i;
                    //先调用expungeStaleEntry()清理从上面记录slotToExpunge(最前面的脏Entry位置)
                    //到tab[i]==null位置的脏数据
                    //再调用cleanSomeSlots()向后扫描清理
                    cleanSomeSlots(expungeStaleEntry(slotToExpunge), len);
                    return;
                }

                // key为null,说明是脏Entry,如果向前查找没找到脏Entry,则更新脏Entry位置为当前位置
                if (k == null && slotToExpunge == staleSlot)
                    slotToExpunge = i;
            }

            //向后查找没找到可覆盖的key,说明为新Entry,新建一个放入staleSlot位置
            tab[staleSlot].value = null;
            tab[staleSlot] = new Entry(key, value);

            //true: 如果slotToExpunge在初始位置,说明只有初始位置一个脏Entry(已经被新Entry覆盖)
            //false:如果slotToExpunge不在初始位置,说明其后面存在脏Entry,继续清理
            if (slotToExpunge != staleSlot)
                cleanSomeSlots(expungeStaleEntry(slotToExpunge), len);
        }

首先明确两点:

  • slotToExpunge和slotTopunge初始位置一致
  • slotToExpunge始终记录最前面的脏Entry的位置

此方法也可以分为4中情况:

  1. 向前查找到脏Entry,向后查找到可覆盖的Entry
            向前搜索脏Entry                  向后搜索
         <--------------------         ----------------->
                            (脏Entry)
       脏Entry               起始位置                  可覆盖Entry
          |                    |                         |
    ------|--------------------|-------------------------|----------- Entry数组
    slotToExpunge           staleSlot   <-------->    key一致 i
          |                                交换
          |                                 
          |------------------------------------------>
                cleanSomeSlot()清理脏Entry

先向前搜索脏Entry,找到后通过slotToExpunge下标,直到tab[i]==null。这一步是为了获取到最前面的脏Entry位置,方便后面从头进行清理。

然后向后查找可以覆盖的key,直接到tab[i]==null。找到后直接赋值,然后交换当前Entry和初始staleSlot位置的Entry。因为初始位置的脏Entry在后面会被清理,先将当前Entry换过去,防止下次获取Entry时读到null值。

因为向前遍历时搜索到了脏Entry,slotToExpunge已经被改变与staleSlot并不相等。最后直接从slotToExpunge位置开始清理即可。

expungeStaleEntry()只能清理一次散列冲突范围内的脏Entry,所以最后会调用cleanSomeSlots()开始一次大范围扫描清理。

  1. 向前查找到脏Entry,向后没找到可覆盖的Entry
            向前搜索脏Entry                  向后搜索(未找到)
         <--------------------         ----------------->
                            (脏Entry)
       脏Entry               起始位置             tab[i]==null
          |                    |                      |
    ------|--------------------|----------------------|-------------- Entry数组
    slotToExpunge           staleSlot
          |                    |
          |               插入新Entry
          |------------------------------------------>
                cleanSomeSlot()清理脏Entry

向前查找脏Entry,然后记录。向后查找可覆盖的Entry,但是并没有找到。在向后遍历的过程中,每当找到脏Entry(k == null),就会比较 slotToExpunge == staleSlot,因为前面找到了脏Entry,所以slotToExpunge位置已经改变,不需要变动。

因为向后没有找到可覆盖的Entry,所以为新Entry,直接new一个放入初始的位置(staleSlot)。然后从slotToExpunge位置开始清理。

  1. 向前搜索没有找到脏Entry,向后查找到可覆盖的Entry
            向前搜索脏Entry(没找到)                  向后搜索
         <--------------------         ----------------->
                            (脏Entry)
    tab[i]==null            起始位置      脏Entry       可覆盖Entry
          |                    |            |             |
    ------|--------------------|------------|-------------|----------- Entry数组
                           staleSlot   slotToExpunge    key一致 i
                               |  <---------------------> |
                                           交换
                                            |
                                            |-------------------------------->
                                                  cleanSomeSlot()清理脏Entry   

向前搜索脏Entry,但并未找到,所以此时slotToExpunge==staleSlot。向后查找可覆盖的Entry,最终找到。此处有两种情况:

3.1 向后查找的过程中,发现了脏Entry(k == null):此时会将当前位置i赋值为slotToExpunge,因为向前搜索并未发现脏Entry,当前位置即为最前面的脏Entry位置。
在这里插入图片描述
继续寻找发现可覆盖的Entry,则进行赋值,交换当前Entry的位置到staleSlot位置。然后进行判断:
ThreadLocal--内存泄漏问题及Java的对应处理办法_第2张图片
因为在向后查找的过程中发现了脏Entry,slotToExpunge已经更改,所以为false。最后从slotToExpunge开始清理。

3.2 向后查找的过程中没有发现脏Entry就找到可覆盖的Entry了:则直接赋值,交换当前Entry的位置到staleSlot位置。然后进行判断:
ThreadLocal--内存泄漏问题及Java的对应处理办法_第3张图片
因为在向后查找的过程中没有发现脏Entry,slotToExpunge==staleSlot,所以为true。此时当前位置为最前面的脏Entry位置,所以把i赋值给slotToExpunge。最后从slotToExpunge开始清理。

  1. 向前搜索没有找到脏Entry,向后查找没找到可覆盖的Entry
            向前搜索脏Entry(未找到)                  向后搜索(未找到)
         <--------------------         -------------------------->
                            (脏Entry)
    tab[i]==null            起始位置      脏Entry       tab[i]==null
          |                    |            |             |
    ------|--------------------|------------|-------------|----------- Entry数组
                           staleSlot   slotToExpunge
                               |            |
                            新插入Entry      |        
                                            |-------------------------------->
                                                  cleanSomeSlot()清理脏Entry

向前查找未找到脏Entry,此时slotToExpunge==staleSlot。向后查找可覆盖Entry过程中发现脏Entry,此时判断:
在这里插入图片描述
当前位置即为最前面脏Entry位置,所以i赋值给slotToExpunge。找到最后没找到,则新建一个Entry放入初始的staleSlot位置。
此时判断:
在这里插入图片描述
如果前面未发现脏Entry,则slotToExpunge还在初始位置,说明只有初始位置一个脏Entry(已经被新Entry覆盖),直接返回即可。

如果前面发现了脏Entry,则slotToExpunge位置已经改变,从slotToExpunge位置开始清理。

综上,该方法就是在定位最前面的脏Entry位置,然后通过cleanSomeSlots()方法向后清理。

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];
                //判断脏Entry
                if (e != null && e.get() == null) {
                    //遇到脏Entry重新赋值n
                    n = len;
                    removed = true;
                    //清理i到tab[i]==null
                    i = expungeStaleEntry(i);
                }
                //n控制循环次数,如果一个脏Entry都没遇到,则循环log2(n)+1 次
                //遇到脏Entry,则会不断扩大向后扫描的范围,直到扫描不到脏Entry
            } while ( (n >>>= 1) != 0);
            return removed;
        }

首先我们要清楚expungeStaleEntry(int staleSlot)方法清除的范围是多大:

假设a,b,c为三个散列后的hashcode,均为0,此时a因为失去强引用被GC,成为脏Entry。因为ThreadLocalMap采用线性探测再散列解决散列冲突,所以最后结果如下:

hashcode  (a)    b     c
        ---0-----1-----2-----3-----4-----5----- Entry[]
 key      null               空

此时调用expungeStaleEntry(0)返回3,Entry[]变为:

hashcode   b     c
        ---0-----1-----2-----3-----4-----5----- Entry[]
 key                  空     空

清理的范围实际为[0,3),仅能保证下一次对hashcode为0的获取读不到脏Entry。

cleanSomeSlots()的第二个参数n就是弥补的措施,n用来控制循环的次数log2(n),如果在清理的过程中遇到脏Entry,则n被重置为数组长度len。即每遇到一次脏Entry,循环多执行log2(n)次。

通过这样不断的推进,达到清理大部分脏Entry的目的。

示例:默认Entry[]长度为16,因为是do-while循环,默认会执行4+1=5次。

 hashcode  (a)    b     c                            d     e
        ---0-----1-----2-----3-----4-----5-----6-----7-----8-----9-----10-----11-----12- Entry[]
 key      null              空     空    空    null              空
           |---------------> |---> | --> | --> | --------------> | -------> ........
              log2(16)==4      3      2     1   读到脏key重置循环:4

remove()

public void remove() {
         ThreadLocalMap m = getMap(Thread.currentThread());
         if (m != null)
             m.remove(this);
     }

调用底层的remove():

private void remove(ThreadLocal<?> key) {
            Entry[] tab = table;
            int len = tab.length;
            //散列
            int i = key.threadLocalHashCode & (len-1);
            for (Entry e = tab[i];
                 e != null;
                 e = tab[i = nextIndex(i, len)]) {
                //key一致,命中,直接清理,然后调用expungeStaleEntry()保证下次对hashcode为i
                //的Entry获取操作不再读到脏Entry
                if (e.get() == key) {
                    e.clear();
                    expungeStaleEntry(i);
                    return;
                }
                //没读到即为散列冲突,向后读取直到e==null结束
            }
        }

总结一下:整体来看,jdk的编写者认为,存在脏Entry的附近可能存在其他脏Entry,所以采用这种扫描的方式进行清理。

每次get()、set()、remove()操作后,都保证了下次对同一hashcode的读取不会再读到脏Entry。

至于为何不采用遍历数组的方式进行清理,可能是因为执行效率的考量吧。

你可能感兴趣的:(Java)