ThreadLocal如何解决内存泄漏问题

什么是内存泄漏?

不再用到的内存,没有及时释放,就叫做内存泄漏。

对于持续运行的服务进程,必须及时释放内存,否则内存占用率越来越高,轻则影响系统性能,重则导致进程崩溃。

ThreadLocal是怎么造成内存泄露的呢?

如果发生了下面的情况:

  • 如果ThreadLocal是null了,也就是要被GC回收了,
  • 但是此时我们的ThreadLocalMap(thread 的内部属性)生命周期和Thread的一样,它不会回收,这时候就出现了一个现象。

总之,就是ThreadLocalMap的key没了,但是value还在,这就造成了内存泄漏。

我们细致的分析一下。

ThreadLocal 有两个引用链

ThreadLocalMap中的Key就是ThreadLocal对象,ThreadLocal 有两个引用链:

  • 一个引用链是栈内存中ThreadLocal引用
  • 一个引用链是ThreadLocalMap中的Key对它的引用图片

而对于Value(实际保存的值)来说,它的引用链只有一条,就是从Thread对象引用过来的,如下图:图片

上述过程分析后,就会出现如下的两种情况:

情况1: key的泄漏

情况2: value的泄漏

情况1:key的泄漏

栈上的ThreadLocal Ref引用不再使用了,即当前方法结束处理后,这个对象引用就不再使用了,

那么,ThreadLocal对象因为还有一条引用链存在,如果是强引用的话,这里就会导致ThreadLocal对象无法被回收,可能导致OOM。图片

情况1 的解决方案,使用弱引用解决 。

情况2: value的泄漏

情况2.假设我们使用了线程池,如果Thread对象一直被占用使用中(如在线程池中被重复使用),但是此时我们的ThreadLocalMap(thread 的内部属性)生命周期和Thread的一样,它不会回收,这时候就出现了一个现象。

这就意味着,Value这条引用链就一直存在,那么就会导致ThreadLocalMap无法被JVM回收,可能导致OOM,如上图。

情况2 ,比较严重。还得另想办法。

情况1的解决方案:使用弱引用,解决key的内存泄露

从如下ThreadLocal中内部类Entry代码可知:

Entry类的父类是弱引用WeakReference,ThreadLocal的引用k通过 WeakReference 构造方法传递给了 父类WeakReference的构造方法,

从而,ThreadLocalMap中的Key是ThreadLocal的弱引用,通过弱引用来解决内存泄露问题。

具体的代码如下

static class ThreadLocalMap {
        static class Entry extends WeakReference<ThreadLocal<?>> {
            Object value;
            Entry(ThreadLocal<?> k, Object v) {
                super(k); // key为弱引用
                value = v;
            }
        }
}

栈内存中的ThreadLocal Ref引用不再使用了,即当当前方法结束处理后,这个key对象引用就不再使用了,

那么,如果这里 不用弱引用而是强引用的话,这里ThreadLocal对象因为还有一条引用链存在,所以就会导致他无法被回收,可能导致OOM。图片

回顾Java中4种引用类型

  1. 强引用(Strong Reference)

    • 这是最常见的引用类型。一个对象具有强引用,垃圾收集器就不会回收它,即使系统内存空间不足。
    • 示例:Object obj = new Object(); 在这里,obj就是new Object()的一个强引用。
  2. 软引用(Soft Reference)

    • 用来描述一些可能还有用但并非必需的对象。在系统将要发生内存溢出异常前,将会把这些对象列进回收范围之中进行第二次回收。如果这次回收还没有足够的内存,才会抛出内存溢出异常。
    • 在Java中,软引用是用来实现内存敏感的高速缓存。
    • 示例:使用java.lang.ref.SoftReference类可以创建软引用。
  3. 弱引用(Weak Reference)

    • 这里讨论ThreadLocalMap中Entry类的重点。
    • 弱引用也是用来描述非必需对象的,它的强度比软引用更弱一些,被弱引用关联的对象只能生存到下一次垃圾收集发生之前。当垃圾收集器工作时,无论当前内存是否足够,都会回收被弱引用关联的对象。
    • 在Java中,弱引用是用来描述那些非关键的数据,在Java里用java.lang.ref.WeakReference类来表示。
    • 示例:使用java.lang.ref.WeakReference类可以创建弱引用。
  4. 虚引用(Phantom Reference)

    • 一个虚引用关联着的对象,在任何时候都可能被垃圾收集器回收,它不能单独用来获取被引用的对象。虚引用必须和引用队列(ReferenceQueue)联合使用。主要用来跟踪对象被垃圾回收的活动。
    • 虚引用对于一般的应用程序来说意义不大,主要使用在能比较精确控制Java垃圾收集器的高级场景中。
    • 示例:使用java.lang.ref.PhantomReference类可以创建虚引用。

弱引用也是用来描述非必需对象的,它的强度比软引用更弱一些,被弱引用关联的对象只能生存到下一次垃圾收集发生之前。当垃圾收集器工作时,无论当前内存是否足够,都会回收被弱引用关联的对象。

至此,key的泄漏问题,JDK已经帮我们顺利解决。 

更复杂的是: 如何解决value内存泄露问题? 

情况2的解决方案:清理策略解决value内存泄露图片

为了解决value内存泄露问题,Java 的 ThreadLocal 实现了两大清理方式:

  • 探测式清理(Proactive Cleanup)
  • 启发式清理(Heuristic Cleanup) 。

源码:value的 探测式清理 :

当线程调用 ThreadLocal的 get()set() 或 remove()方法时,会探测式的去触发对 ThreadLocalMap 的清理。

此时,ThreadLocalMap 会检查所有键(ThreadLocal 实例),并移除那些已经被垃圾回收的key键及其对应的value 值。

这种清理是主动的,因为它是在每次操作 ThreadLocal 时进行的。

探测式清理(Proactive Cleanup)如何实现的呢?

从当前节点开始遍历数组,将key等于null的entry置为null,key不等于null则rehash重新分配位置,若重新分配上的位置有元素则往后顺延。

注意:这里把清理的开销放到了get、set操作上,如果get的时候无用Entry(Entry的Key为null)特别多,那这次get相对而言就比较慢了。

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) {
                    // 将k=null的entry置为null
                    e.value = null;
                    tab[i] = null;
                    size--;
                } else {
                    // k不为null,则rehash从新分配配置
                    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;
        }

源码:value的启发式清理:

在 ThreadLocalMap 的 set() 方法中,有一个阈值(默认为 ThreadLocalMap.Entry 数组长度的 1/4)。

当 ThreadLocalMap 中的 Entry 对象被删除(通过键的弱引用被垃圾回收)并且剩余的 Entry 数量大于这个阈值时,会触发一次启发式清理操作。

这种清理是启发式的,因为它不是每次操作都进行,而是基于一定的条件和概率。

启发式清理(Heuristic Cleanup)如何实现?

从当前节点开始,进行do-while循环检查清理过期key,结束条件是连续n次未发现过期key就跳出循环,n是经过位运算计算得出的,可以简单理解为数组长度的2的多少次幂次。

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);
            return removed;
        }

业务主动清理:手动清除解决内存泄露

要知道,ThreadLocal的一个常见问题是内存泄露。

这通常发生在使用线程池的场景中,因为线程池中的线程通常是长期存在的,它们的ThreadLocal变量也不会自动清理,这可能导致内存泄漏。

前面讲了,JDK已经用尽全力去解决了,JDK 用了三个办法,来解决内存泄漏。

尽管有弱引用以及这些清理机制,但最佳实践业务主动清理,

业务上解决这个问题的一个方法是,每当使用完ThreadLocal变量后,显式地调用remove()方法来清除它:

如何业务主动清理?在使用完 ThreadLocal 后显式调用 remove()方法,以确保不再需要的值能够被及时回收,key和value 都同时清理,一锅端。

这样可以避免潜在的内存泄漏问题,并减少垃圾回收的压力。

讲到这里,尼恩团队给大家,用一个大的图总结一下 ThreadLocal的内存泄露与解决方案,具体如下:

图片

除非注明,否则均为哦豁原创文章,转载必须以链接形式标明本文链接
guest

0 评论
最多投票
最新 最旧
内联反馈
查看所有评论