一文看懂ThreadLocal内存泄漏

40 阅读7分钟

之前看过ThreadLocal保存值的代码,保存的时候会计算数据在数组中的索引int i = key.threadLocalHashCode & (len-1);,跟数组的长度与运算是为了保证每个Entry都在数组中,避免越界。但是数组的长度有限的,如果有很多个ThreadLocal变量时,就有可能出现哈希冲突,即计算出来的索引相同。比如Entry数组的长度是8,而我们声明了9个ThreadLocal变量,这样同一个线程中同时操作这9个ThreadLocal时就会出现哈希碰撞,现在我们看下Java如何解决ThreadLocal的哈希冲突。

        private void set(ThreadLocal<?> key, Object value) {
            Entry[] tab = table;
            int len = tab.length;
            int i = key.threadLocalHashCode & (len-1);
// 从根据hash值计算得到的索引开始向后移动
            for (Entry e = tab[i];
                 e != null;
                 e = tab[i = nextIndex(i, len)]) {
                ThreadLocal<?> k = e.get();

                if (k == key) {
                    e.value = value;
                    return;
                }
                // ThreadLocal被清理了
                if (k == null) {
                    replaceStaleEntry(key, value, i);
                    return;
                }
            }
// 在下个没有Entry的插槽处加入
            tab[i] = new Entry(key, value);
            int sz = ++size;
            if (!cleanSomeSlots(i, sz) && sz >= threshold)
                rehash();
        }

这个过程很简单,例如ThreadLocal3和ThreadLocal4的hash散列值都是2,set()时会将ThreadLocal4保存在ThreadLocal3的下一个索引即Entry3处。这样就把哈希冲突的问题解决掉了,但是会有个致命问题:ThreadLocal3和ThreadLocal4对线程1和线程2都可见,线程1调用set()设置过ThreadLocal3的值,用完之后将ThreadLocal3=null了,此时线程2设置ThreadLocal4的值时,就会发现根据hash值计算得到的索引处的Entry内置的ThreadLocal为null,即if (k == null),此时Entry对象仍然存在于堆上,但是内置的ThreadLocal被gc成null,而我们正是根据ThreadLocal计算hash值访问Entry的,是不是就出现无法访问到的内存,即内存泄漏,Java通过replaceStaleEntry()解决内存泄漏的。

threadlocal-hash.png 首先沿着需要清理的Entry处向前遍历,找到数组中最靠前的Entry,记录到slotToExpunge,目的是一次性把整个数组清理干净。接下来先解决哈希冲突,沿着待清理的staleSlot处向后遍历,如果发现Entry能够if (k == key)保存set()的值就保存下来,同时将待清理的插槽和存在值的插槽交换位置,目的是前移Entry插槽,这样下次get()获取时可以减少nextIndex遍历次数。向后遍历时找到要清理的Entry时就会用slotToExpunge记录下来,解决掉哈希冲突后就会从slotToExpunge处开始清理。例如,ThreadLocal3、ThreadLocal4、ThreadLocal5发生哈希冲突,ThreadLocal4被gc成null了,调用ThreadLocal5的set()方法时发现ThreadLocal4需要被清理,于是进入replaceStaleEntry()清理Entry,向前遍历发现前面的Entry都是正常,于是向后遍历,找到ThreadLocal5对应的Entry在Entry4处,就会保存值,同时将ThreadLocal5和要清理的ThreadLocal4对应的Entry3交换位置,这样下次get()时就可以减少一次nextIndex遍历。

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

            int slotToExpunge = staleSlot;
            //向前遍历数组,找到最开始要清理的Entry
            for (int i = prevIndex(staleSlot, len);
                 (e = tab[i]) != null;
                 i = prevIndex(i, len))
                if (e.get() == null)
                    slotToExpunge = i;

            for (int i = nextIndex(staleSlot, len);
                 (e = tab[i]) != null;
                 i = nextIndex(i, len)) {
                ThreadLocal<?> k = e.get();
            // 匹配到能够保存值的Entry就保存set()的值,k == key默认按hash值比较
                if (k == key) {
                    e.value = value;
                    //交换位置,将待清理的插槽和存在值的插槽交换位置
                    //目的是前移Entry插槽,这样下次get()获取时可以减少nextIndex遍历次数
                    tab[i] = tab[staleSlot];
                    tab[staleSlot] = e;
                    //如果沿staleSlot向前发现没有需要清理的Entry,但是现在发现了,就及时记录
                    //这样slotToExpunge始终为数组中最靠前的Entry
                    if (slotToExpunge == staleSlot)
                        slotToExpunge = i;
                    cleanSomeSlots(expungeStaleEntry(slotToExpunge), len);
                    return;
                }
                //如果沿staleSlot向前发现没有需要清理的Entry,但是现在发现了,就及时记录
                //这样slotToExpunge始终为数组中最靠前的Entry
                if (k == null && slotToExpunge == staleSlot)
                    slotToExpunge = i;
            }
            // 如果在Entry数组中没发现能更新值的位置
            tab[staleSlot].value = null;
            tab[staleSlot] = new Entry(key, value);
            // 向前或向后遍历中发现存在要清理的Entry
            if (slotToExpunge != staleSlot)
                cleanSomeSlots(expungeStaleEntry(slotToExpunge), len);
        }

再看下如何清理掉过期的Entry,沿staleSlot向后遍历,发现有内置的ThreadLocal对象为null时清理掉,同时找出存在哈希冲突的ThreadLocal,判断是否存在hash冲突if (h != i)的逻辑很简单,不存在时k.threadLocalHashCode & (len - 1)计算出来的值跟数组的索引i相等,发现不相等必然存在哈希冲突,将此处的Entry向前移动。例如ThreadLocal6=null、ThreadLocal7、ThreadLocal8的哈希值都是5,现在先将staleSlot=5处的插槽清理掉,再向后移动,发现ThreadLocal7存在哈希碰撞,保存在Entry6处(本来应该保存在5(101)&15(1111)=5即Entry5处),就将ThreadLocal7移动到ThreadLocal6对应的Entry5处,同时令Entry6=null;下次迭代到ThreadLocal8,发现又存在哈希碰撞,此时移动保存ThreadLocal8的Entry,先打算移动到int h = k.threadLocalHashCode & (len - 1);处,即Entry5处,但是Entry5已经保存ThreadLocal6了,于是向后移动一位while (tab[h] != null) h = nextIndex(h, len);,找到下一个空的Entry6处保存,再将Entry6=null,去掉需要被gc的Entry,同时将存在哈希碰撞的Entry前移。此方法返回到一个为null的Entry索引。

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

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

            //沿着staleSlot处向后清理Entry
            Entry e;
            int i;
            for (i = nextIndex(staleSlot, len);
                 (e = tab[i]) != null;
                 i = nextIndex(i, len)) {
                ThreadLocal<?> k = e.get();
                //发现Entry内置的ThreadLocal弱引用被gc,则手动清理Entry
                if (k == null) {
                    e.value = null;
                    tab[i] = null;
                    size--;
                } else {
                    //发现存在哈希冲突时将Entry迁移
                    int h = k.threadLocalHashCode & (len - 1);
                    if (h != i) {
                        //因为要将i处的Entry前移,所以i处的Entry将置为null,i处的Entry引用保存在e变量
                        tab[i] = null;

                        // 找到发生哈希碰撞处最靠前的地方
                        while (tab[h] != null)
                            h = nextIndex(h, len);
                        tab[h] = e;
                    }
                }
            }
            return i;
        }

刚才说到清理时遇到为null的Entry就会跳出expungeStaleEntry方法,但是Entry数组里还有需要清理的Entry怎么办,需要在cleanSomeSlots处继续处理。这里采用渐进衰减的局部清理策略,发现不存在Entry需要处理就右移一位能够减少很多次无用的迭代。

        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存在但是用来访问Entry的ThreadLocal不存在
                if (e != null && e.get() == null) {
                    //发现Entry需要被清理就奖励一次,这样每次发现Entry要被清理就会一直循环,直到清理完
                    n = len;
                    removed = true;
                    i = expungeStaleEntry(i);
                }
                //每次右移一位
            } while ( (n >>>= 1) != 0);
            return removed;
        }

最后set()时发现容量超出需要扩容了,通过resize()方法实现的,逻辑很简单,新数组默认为原数组的2倍,发现存在if (k == null)时则清理ThreadLocal保存的值,老表中的ThreadLocal存在,跟新表计算索引int h = k.threadLocalHashCode & (newLen - 1);,保存到新表,发现哈希冲突则向后移动。

        private void resize() {
            Entry[] oldTab = table;
            int oldLen = oldTab.length;
            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) {
                        e.value = null; // Help the GC
                    } else {
                        int h = k.threadLocalHashCode & (newLen - 1);
                        while (newTab[h] != null)
                            h = nextIndex(h, newLen);
                        newTab[h] = e;
                        count++;
                    }
                }
            }

            setThreshold(newLen);
            size = count;
            table = newTab;
        }

总结下,ThreadLocal出现哈希碰撞时会将数据保存在数组的下个索引处,并清理过期Entry避免内存泄漏,同时将哈希碰撞处的Entry向前移动,这个过程非常繁琐。所以我们尽量不要定义太多ThreadLocal,不要声明临时ThreadLocal变量,也不要轻易将ThreadLocal置为null。有不对的地方请大神指出,欢迎大家一起讨论交流,共同进步,更多请关注微信公众号 葡萄开源