ThreadLocal弱引用与内存泄漏分析

40次阅读

共计 3479 个字符,预计需要花费 9 分钟才能阅读完成。

本文对 ThreadLocal 弱引用进行一些解析,以及 ThreadLocal 使用注意事项。

ThreadLocal

首先,简单回顾一下,ThreadLocal 是一个线程本地变量,每个线程维护自己的变量副本,多个线程互相不可见,因此多线程操作该变量不必加锁,适合不同线程使用不同变量值的场景。

其实现原理这里就不做详细阐述,其数据结构是每个线程 Thread 类都有个属性 ThreadLocalMap,用来维护该线程的多个 ThreadLocal 变量,该 Map 是自定义实现的 Entry<K,V>[]数组结构,并非继承自原生 Map 类,Entry 其中 Key 即是 ThreadLocal 变量本身,Value 则是具体该线程中的变量副本值。结构如图:

因此 ThreadLocal 其实只是个符号意义,本身不存储变量,仅仅是用来索引各个线程中的变量副本。

值得注意的是,Entry 的 Key 即 ThreadLocal 对象是采用 弱引用 引入的,如源代码:

static class ThreadLocalMap {

        /**
         * The entries in this hash map extend WeakReference, using
         * its main ref field as the key (which is always a
         * ThreadLocal object).  Note that null keys (i.e. entry.get()
         * == null) mean that the key is no longer referenced, so the
         * entry can be expunged from table.  Such entries are referred to
         * as "stale entries" in the code that follows.
         */
        static class Entry extends WeakReference<ThreadLocal<?>> {
            /** The value associated with this ThreadLocal. */
            Object value;

            Entry(ThreadLocal<?> k, Object v) {super(k);
                value = v;
            }
        }

本文下面重点分析为何使用弱引用,以及可能存在的问题。

首先看下弱引用。

弱引用

java 语言中为对象的引用分为了四个级别,分别为 强引用、软引用、弱引用、虚引用。

其余三种具体可自行查阅相关资料。

弱引用具体指的是 java.lang.ref.WeakReference<T> 类。

对对象进行弱引用不会影响垃圾回收器回收该对象,即如果一个对象只有弱引用存在了,则下次 GC 将会回收掉该对象(不管当前内存空间足够与否)。

再来说说内存泄漏,假如一个短生命周期的对象被一个长生命周期对象长期持有引用,将会导致该短生命周期对象使用完之后得不到释放,从而导致内存泄漏。

因此,弱引用的作用就体现出来了,可以使用弱引用来引用短生命周期对象,这样不会对垃圾回收器回收它造成影响,从而防止内存泄漏。

ThreadLocal 中的弱引用

1. 为什么 ThreadLocalMap 使用弱引用存储 ThreadLocal?

假如使用强引用,当 ThreadLocal 不再使用需要回收时,发现某个线程中 ThreadLocalMap 存在该 ThreadLocal 的强引用,无法回收,造成内存泄漏。

因此,使用弱引用可以防止长期存在的线程(通常使用了线程池)导致 ThreadLocal 无法回收造成内存泄漏。

2. 那通常说的 ThreadLocal 内存泄漏是如何引起的呢?

我们注意到 Entry 对象中,虽然 Key(ThreadLocal)是通过弱引用引入的,但是 value 即变量值本身是通过强引用引入。

这就导致,假如不作任何处理,由于 ThreadLocalMap 和线程的生命周期是一致的,当线程资源长期不释放,即使 ThreadLocal 本身由于弱引用机制已经回收掉了,但 value 还是驻留在线程的 ThreadLocalMap 的 Entry 中。即存在 key 为 null,但 value 却有值的无效 Entry。导致内存泄漏。

但实际上,ThreadLocal 内部已经为我们做了一定的防止内存泄漏的工作。

即如下方法:

/**
         * Expunge a stale entry by rehashing any possibly colliding entries
         * lying between staleSlot and the next null slot.  This also expunges
         * any other stale entries encountered before the trailing null.  See
         * Knuth, Section 6.4
         *
         * @param staleSlot index of slot known to have null key
         * @return the index of the next null slot after staleSlot
         * (all between staleSlot and this slot will have been checked
         * for expunging).
         */
        private int expungeStaleEntry(int staleSlot) {Entry[] tab = table;
            int len = tab.length;

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

            // Rehash until we encounter null
            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;

                        // Unlike Knuth 6.4 Algorithm R, we must scan until
                        // null because multiple entries could have been stale.
                        while (tab[h] != null)
                            h = nextIndex(h, len);
                        tab[h] = e;
                    }
                }
            }
            return i;
        }

上述方法的作用是擦除某个下标的 Entry(置为 null,可以回收),同时检测整个 Entry[]表中对 key 为 null 的 Entry 一并擦除,重新调整索引。

该方法,在每次调用 ThreadLocal 的 get、set、remove 方法时都会执行,即 ThreadLocal 内部已经帮我们做了对 key 为 null 的 Entry 的清理工作。

但是该工作是有触发条件的,需要调用相应方法,假如我们使用完之后不做任何处理是不会触发的。

总结

  • (强制)在代码逻辑中使用完 ThreadLocal,都要调用 remove 方法,及时清理。

目前我们使用多线程都是通过线程池管理的,对于核心线程数之内的线程都是长期驻留池内的。显式调用 remove,一方面是防止内存泄漏,最为重要的是,不及时清除有可能导致严重的业务逻辑问题,产生线上故障(使用了上次未清除的值)。

最佳实践:在 ThreadLocal 使用前后都调用 remove 清理,同时对异常情况也要在 finally 中清理。

  • (非规范)对 ThreadLocal 是否使用全局 static 修饰的讨论。

在某些代码规范中遇到过这样一条要求:“尽量不要使用全局的 ThreadLocal”。关于这点有两种解读。最初我的解读是,因为静态变量的生命周期和类的生命周期是一致的,而类的卸载时机可以说比较苛刻,这会导致静态 ThreadLocal 无法被垃圾回收,容易出现内存泄漏。另一个解读,我咨询了编写该规范的对方解释是,如果流程中改变了变量值,下次复用该流程可能导致获取到非预期的值。

但实际上,这两个解读都是不必要的,首先,静态 ThreadLocal 资源回收的问题,即使 ThreadLocal 本身无法回收,但线程中的 Entry 是可以通过 remove 清理掉的也就不会出现泄漏。第二种解读,多次复用值改变的问题,其实在调用 remove 后也不会出现。

而如果 ThreadLocal 不加 static,则每次其所在类实例化时,都会有重复 ThreadLocal 创建。这样即使线程在访问时不出现错误也有资源浪费。

因此,ThreadLocal 一般加 static 修饰,同时要遵循第一条及时清理

个人博客:www.hellolvs.cn

正文完
 0