ThreadLocal的内存泄漏问题
发布日期:2021-06-29 03:44:52 浏览次数:2 分类:技术文章

本文共 10050 字,大约阅读时间需要 33 分钟。

文章目录


ThreadLocal的内存泄漏,是一个会经常被问的一个问题,甚至是你在去使用ThreadLocal,你都无法去感知其ThreadLocal潜在的内存泄漏。

深入分析ThreadLocal的内存泄漏问题之前,我们首先要明白,什么是内存泄漏

百度百科给出的内存泄漏的定义是:

内存泄漏(Memory

Leak)是指程序中已动态分配的堆内存由于某种原因程序未释放或无法释放,造成系统内存的浪费,导致程序运行速度减慢甚至系统崩溃等严重后果。

实际上:ThreadLocal得益于Josh Bloch and Doug Lea 大佬们对于ThreadLocal代码的优化改进,已经很大程度上优化了关于ThreadLocal内存泄漏的问题,并配合以ThreadLocal的正确使用姿势,是能够解决ThreadLocal的内存泄漏问题(换句话说,即使大佬们对于代码的精良优化,如果你不正确的使用ThreadLocal,也是会出现ThreadLocal的内存泄漏问题的)

1,ThreadLocal为什么会出现内存泄漏的问题

我觉得在分析这个问题之前,我们首先要分析这么一个问题:为什么ThreadLocal中的ThreadLocalMap使用弱引用(WeakReference)去引用对应的ThreadLocal对象

1,首先,ThreadLocal的作用是为每一个使用该变量【实际上的变量就是ThreadLocal的泛型类型】的线程提供独立的变量副本,即每一个线程都可以独立的修改自己线程的变量副本,线程之间互不影响。

2,ThreadLocal为了促使每一个线程维护的不止一个变量的副本信息,从而使用Thread对象上保持一个成员变量:threadLocals,

ThreadLocal.ThreadLocalMap threadLocals = null;

你会看到该成员变量的类型是ThreadLocal.ThreadLocalMap 类型,该ThreadLocalMap 正是ThreadLocal的内部类,并且他是一个经过定制化的哈希表映射类型

/**     * ThreadLocalMap是一种定制的哈希映射,     * 仅适用于维护线程局部值。 在Thread Local类之外没有导出任何操作。     * 类是包私有的,允许在类Thread中声明字段。 为了帮助处理非常大和长寿命的用法,     * 哈希表条目使用键的弱引用。[WeakReference] 但是,由于不使用引用队列,只有当表开始耗尽空间时,才保证删除陈旧的条目。     *     * WeakReference:弱引用也是用来描述那些非必须对象,但是它的强度比软引用更弱一些,     *               被弱引用关联的对象只能生存到下一次垃圾收集发生为止。当垃圾收集器开始工作,无论当前内存是否足够,都会回收掉只被弱引用关联的对象。在JDK 1.2版之后提供了WeakReference类来实现弱引用。     * ThreadLocalMap is a customized hash map suitable only for     * maintaining thread local values. No operations are exported     * outside of the ThreadLocal class. The class is package private to     * allow declaration of fields in class Thread.  To help deal with     * very large and long-lived usages, the hash table entries use     * WeakReferences for keys. However, since reference queues are not     * used, stale entries are guaranteed to be removed only when     * the table starts running out of space.     */    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
> { /** The value associated with this ThreadLocal. */ Object value; Entry(ThreadLocal
k, Object v) { super(k); value = v; } }}.....

和HashMap一样,他的内部是通过数组的形式存储,通过对应的key值(也就是ThreadLocal对象)的哈希去定位对应的数组索引位置。对于每一个线程需要维护多个副本的实现,线程Thread对象的成员变量的ThreadLocalMap的数组存储实体是Entry。即每一个数组存储的Entry都关联着对应的ThreadLocal对象,value的值就是thread存储的变量副本的值,这里我们可以看到Entry存储的ThreadLocal对象就是以弱引用(WeakReference)进行存储

ThreadLocal的get()方法,通过返回当前线程持有的成员变量ThreadLocalMap对象,并通过当前ThreadLocal为key即可返回其副本的值:

public T get() {        // 获取当前线程得示例对象        Thread t = Thread.currentThread();        // 2. 获取当前线程的threadLocalMap        ThreadLocalMap map = getMap(t);        if (map != null) {            // 获取map中以ThreadLocal为key得示例对象            ThreadLocalMap.Entry e = map.getEntry(this);            if (e != null) {                @SuppressWarnings("unchecked")                        // //4. 当前entitiy不为null的话,就返回相应的值value                T result = (T)e.value;                return result;            }        }        //5. 若map为null或者entry为null的话通过该方法初始化,并返回该方法返回的value        return setInitialValue();    }

如果说使用强引用,即ThreadLocalMap中Entry引用ThreadLocal使用强引用,如果在业务代码中将当前threadLocal设置为threadLocal = null,该方法的操作从业务代码的逻辑中来说是需要垃圾回收创建的ThreadLocal对象,并且同时能过GC掉关联该ThreadLocal对应的变量副本Entry。 然而实际并不会清除掉当前线程对应关联threadlocal对应的Entry,因为Entry强关联对应的threadlocal。GC可达性分析中发现ThreadLocal任然存在对应的强关联,自然不会进行GC垃圾回收对应的ThreadLocal对象。

这是一个很严重的问题,从代码开发来说,我本身设置threadLocal=null,就是为了触发GC去垃圾回收这个创建的ThreadLocal对象,但是正因为你的内部JDK的代码设计,内部强关联了这个ThreadLocal,致使GC无法垃圾回收创建的ThreadLocal对象。

如果说使用弱引用:我们都知道弱引用在GC可达性分析中没有对应的强引用引用该ThreadLocal的时候,在下一次发生GC的时候,都是会清除掉对应的弱引用对象,这会来带一个问题,清除掉了弱引用的threalocal对象之后,线程对象持有的当前threadlocal的副本数据Entry是不会进行GC垃圾回收的, 原因很简单, GC垃圾清楚threadLocal之后,当前线程对应的ThreadLocalMap对应的关联ThreadLocal的Entry的referent会变成null而已。 并没有任何的机制去在threadLocal =null 之后去触发设置当前线程持有的ThreadLocal副本的值也就是对应的Entry为null,自然GC可达性分析之后不会去垃圾回收。

这个问题,也正是 Josh Bloch and Doug Lea 大佬们对于ThreadLocal的代码优化之处

2,如何清理已经垃圾回收掉的ThreadLocal的关联Entry?

ThreadLocal的代码设计很优秀,在set(ThreadLocal<?> key, Object value),get() 方法中,其内部已经是通过高性能得遍历优化来清除掉ThreadLocalMap中Entry得k为null得情况。(k为null得情况实际上就是另一个变量副本ThreadLocal被GC回收)

* @return true if any stale entries have been removed.         * i:表示:插入entry的位置i,很显然在上述情况2(table[i]==null)中,entry刚插入后该位置i很显然不是脏entry;         * n的作用:         * 如果是在set方法插入新的entry后调用,n为当前已经插入的entry个数size;         * 如果是在replaceSateleEntry方法中调用n为哈希表的长度len         */        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) {                    // 脏entry                    n = len;                    removed = true;                    i = expungeStaleEntry(i);                }                // n >>>= 1            } while ( (n >>>= 1) != 0);  // log2n 次数            // cleanSomeSlot不知道是我理解的有问题还是作者表达的问题,log2(n)应该不是范围,而是查找的次数,源码的意思应该是执行log2(n)次查找,如果未找到则结束查找退出。            // 一旦找到就将n更新为hash数组的容量,继续查找,直到连续log2(len)次查找均为找到脏的Entry才会退出,应该是基于效率和时间的一个折中策略。            return removed;        }        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;            //2.往后环形继续查找,直到遇到table[i]==null时结束            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 { // 处理rehash的情况 int h = k.threadLocalHashCode & (len - 1); // rehash发现对应索引位置和原位置不同 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得k为null时,设置Entry得value也为null,并且将ThreadLocalMap对应得槽位设置为null,

关于ThreadLocal的脏槽位(staleSlot)清除的算法可以这位大佬的分析:

3,如何优雅的使用ThreadLocal

每次使用完ThreadLocal,都调用它的remove()方法,清除数据。

ThreadLocal的remove()方法中,当k为null的脏entry的时候,同样会调用

expungeStaleEntry 进行清理

/**         * Remove the entry for key.         */        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)]) { if (e.get() == key) { e.clear(); expungeStaleEntry(i); return; } } }

4,ThreadLocal保持线程执行之间副本值得传递性??

正确得使用ThreadLocal得关键是确保每一次线程使用完ThreadLocal之后都要调用remove()方法。

但是,有这么一个问题,如果我们想要保持同一个线程重复调用任务得时候,保持同一线程的ThreadLocal叠加性。这时该如何操作呢?

例如:假设我们想在一个固定线程数为3的线程池中执行一个10次的任务(每一个任务的执行周期不同):现在我们想要去统计这三个核心线程每一个最终执行了多少个任务。

public static void main(String[] args) {        ExecutorService executorService = Executors.newFixedThreadPool(3);        ThreadLocalDemoForPool threadLocalDemoForPool = new ThreadLocalDemoForPool();        for (int i = 0;i < 10 ;i++) {            Task task = new Task(threadLocalDemoForPool);            executorService.execute(task);        }}private static class Task implements Runnable{        private ThreadLocalDemoForPool threadLocalDemoForPool;        public Task(ThreadLocalDemoForPool threadLocalDemoForPool) {            this.threadLocalDemoForPool = threadLocalDemoForPool;        }        @SneakyThrows        @Override        public void run() {            Random random = new Random();            Thread.sleep(random.nextInt(1000));            long threadId = Thread.currentThread().getId();            int before = threadLocalDemoForPool.get();            int after = threadLocalDemoForPool.getNextNum();            System.out.println("当前线程执行次数:ThreadId:"+ threadId +" before:"+ before + " ,after"+after);        }}

输出结果:

当前线程执行次数:ThreadId:12 before:0 ,after1当前线程执行次数:ThreadId:14 before:0 ,after1当前线程执行次数:ThreadId:12 before:1 ,after2当前线程执行次数:ThreadId:13 before:0 ,after1当前线程执行次数:ThreadId:13 before:1 ,after2当前线程执行次数:ThreadId:12 before:2 ,after3当前线程执行次数:ThreadId:14 before:1 ,after2当前线程执行次数:ThreadId:13 before:2 ,after3当前线程执行次数:ThreadId:14 before:2 ,after3当前线程执行次数:ThreadId:12 before:3 ,after4

即:三个核心线程中线程ID12执行了4次,线程ID13执行了三次,线程ID14执行了三次

上面的代码实际上我们并没有在每一次线程执行完毕之后调用ThreadLocal的remove()方法,因为为了达到每一个线程统计计算次数的目的。

事实上,统计线程池执行任务次数的这个需求使用上诉的代码的实现是一个十分伪代码并且不妥的实现,无论是main函数最后没有对线程池进行关闭操作会导致内存泄漏,还是代码的复杂度,甚至使用Map进行统计都比上面代码来的简单。

实际上,如果你使用ThreadLocal想保持同一线程执行之间变量副本数据的传递性,这个思路是一个错误的设计思路(一定会有更好更简单的方法来实现线程执行之间变量副本的传递性)。

5,ThreadLocalMap和HashMap的不同之处

上面的分析中,我们知道ThreadLocal中的ThreadLocalMap私有类是为了每一个线程存储变量副本的定制化的哈希映射(API文档的描述)

ThreadLocalMap is a customized hash map suitable only for maintaining thread local values

实际上,同样是属于哈希映射散列表类型的HashMap和ThreadLocalMap有很大的不同,

HashMap的内存模型是数组+链表+红黑树的形式,而ThreadLocalMap的内存模型是数组

HashMap为了解决Hash冲突会使用链表的形式处理,并且链表长度达到8的时候会转成红黑树,这一哈希冲突的策略叫做: 分离链表法(separate chaining)

ThreadLocalMap会通过另一种策略: 开放定址法(open addressing) 来解决Hash冲突,即 当关键字散列到的数组单元已经被另外一个关键字占用的时候,就会尝试在数组中寻找其他的单元,直到找到一个空的单元进行存储。

分离链表发和开放定址法可以参考这位大佬的讲解:


另一篇关于ThreadLocal的分析:

转载地址:https://blog.csdn.net/zcswl7961/article/details/113848100 如侵犯您的版权,请留言回复原文章的地址,我们会给您删除此文章,给您带来不便请您谅解!

上一篇:字节跳动面试经验
下一篇:ReentrantLock的lock()和lockInterruptibly()方法的区别

发表评论

最新留言

不错!
[***.144.177.141]2024年04月04日 13时36分38秒