跳过正文
  1. 博客/
  2. 后端/
  3. 框架/

深入剖析ThreadLocal的内存泄漏问题与弱引用的作用

·5 分钟· ·
后端 框架 Java
目录
84 - 这篇文章属于一个选集。

背景
#

在之前的探讨中,我们已经了解了如何使用ThreadLocal。接下来,我们将深入探究为什么在实际使用中ThreadLocal无法及时释放内存,必须等到线程结束后才能释放,以及ThreadLocal中的弱引用到底起到了什么作用。

ThreadLocal底层实现原理
#

ThreadLocal能够让每个线程获取到自己独有的值,其实现原理其实相当简单。每个Thread线程都有一个ThreadLocal.ThreadLocalMap threadLocals变量。从数据类型上我们可以看出,这是一个Map结构。

为什么需要使用Map呢?如果我们只有一个ThreadLocal变量,那么只需要让线程持有一个ThreadLocal的对象引用即可。但实际应用中,我们通常会创建多个ThreadLocal变量:

ThreadLocal<?> threadLocalA = new ThreadLocal<>();  
ThreadLocal<?> threadLocalB = new ThreadLocal<>();  

为了快速获取线程持有的不同ThreadLocal变量,就需要使用Map结构。这个ThreadLocalMap与普通的HashMap有所不同,我们将在后续文章中详细介绍它的独特实现。本文重点讨论ThreadLocalMap中弱引用的使用方式。

在ThreadLocalMap中,key就是ThreadLocal对象本身,value则是与这个ThreadLocal关联的值。ThreadLocal使用了一个特殊的Entry来存储这些值:

static class Entry extends WeakReference<ThreadLocal<?>> {    
    /** The value associated with this ThreadLocal. */    
    Object value;    
    
    Entry(ThreadLocal<?> k, Object v) {    
        super(k);    
        value = v;    
    }    
}  

这段代码的核心在于Entry继承了WeakReference类,并在初始化时将key和value都存储进去。这里的WeakReference就是我们要重点讨论的弱引用类。

弱引用的作用
#

Java中有多种引用类型,我们最常用的是强引用。例如:

Object obj = new Object();  

这里的obj就是一个强引用。只有当obj被赋值为null且没有被其他任何引用时,new Object()占用的内存才会被释放。

而弱引用则不同,它告诉垃圾回收器:虽然我引用了这个对象,但如果这个对象没有被其他强引用指向,你就可以回收它。我们可以通过一个简单的例子来验证这一点:

public class EntryWeakRefTest {    
    static class Entry extends WeakReference<ThreadLocal<?>> {    
        Object value;    
    
        Entry(ThreadLocal<?> k, Object v) {    
            super(k);    
            value = v;    
        }    
    
        @Override    
        protected void finalize() throws Throwable {    
            super.finalize();    
            System.out.println("Entry finalize");    
        }    
    }    
    
    public static void main(String[] args) {    
        // 1. 创建ThreadLocal的强引用    
        ThreadLocal<?> threadLocal = new ThreadLocal<>();    
        Object value = new Object();    
    
        // 2. 创建Entry实例(弱引用关联ThreadLocal)    
        Entry entry = new Entry(threadLocal, value);    
    
        // 3. 检查Entry的key和value是否存活    
        System.out.println("强引用存在时,Entry的Key存活? " + (entry.get() != null));    
        System.out.println("Value存活? " + (entry.value != null));    
    
        // 4. 移除ThreadLocal的强引用    
        threadLocal = null;    
        value = null;    
    
        // 5. 触发GC(弱引用此时应被回收)    
        System.gc();    
        try { Thread.sleep(500); } catch (InterruptedException e) {}    
    
        // 6. 再次检查Entry的key和value          
        System.out.println("强引用移除后,Entry的Key存活? " + (entry.get() != null));    
        System.out.println("Value存活? " + (entry.value != null));    
    
        entry = null;    
    
        // 7. 触发GC,entry也被回收,调用finalize  
        System.out.println("开始gc");    
        System.gc();    
        try { Thread.sleep(500); } catch (InterruptedException e) {}    
        System.out.println("结束");    
    }    
}  

程序输出结果为:

强引用存在时,Entry的Key存活? true  
Value存活? true  
强引用移除后,Entry的Key存活? false  
Value存活? true  
开始gc  
Entry finalize  
结束  

从结果可以看出,当我们将threadLocal设置为null时,entry.get()获取到的key就变为null了。这说明当threadLocal被设置为null或结束生命周期时,弱引用指向的对象会被回收。然而,即使我们将value也设置为null,entry.value仍然能获取到值。

这是因为在Entry中,value是一个强引用。只要entry对象存在,value引用的对象就不会被回收。这就是为什么很多资料都建议在使用完ThreadLocal后一定要调用remove方法,否则可能导致内存泄漏。

ThreadLocal如何解决弱引用中的强引用问题
#

既然Entry强引用了value,那么弱引用还有什么作用呢?从上面的测试代码可以看出,弱引用能够告诉我们:当引用值为null时,说明ThreadLocal变量已经被回收了。因此,当线程在调用get、remove或set方法时,ThreadLocalMap会检查哪些Entry的key为null,并将这些Entry清除。

核心代码如下:

private int expungeStaleEntry(int staleSlot) {    
    Entry[] tab = table;    
    int len = tab.length;    
    
    // 清除staleSlot处的entry  
    tab[staleSlot].value = null;    
    tab[staleSlot] = null;    
    size--;    
    
    // 继续检查后续的entry  
    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 {    
            // 重新计算hash位置  
            int h = k.threadLocalHashCode & (len - 1);    
            if (h != i) {    
                tab[i] = null;    
                while (tab[h] != null)    
                    h = nextIndex(h, len);    
                tab[h] = e;    
            }    
        }    
    }    
    return i;    
}  

关键部分在于:

if (k == null) {    
    e.value = null;    
    tab[i] = null;    
    size--;   
}  

这段代码会在发现key为null时,将对应的Entry清除,从而释放value占用的内存。

总结
#

然而,在实际应用中,我们常常会这样声明ThreadLocal变量:

private static final ThreadLocal<byte[]> threadLocal = new ThreadLocal<>();    

这种情况下,ThreadLocal变量是静态的,生命周期与类相同。除非类被卸载,否则这个引用将一直存在。这意味着Entry的弱引用永远不会为null,ThreadLocalMap也就不会自动清除这些Entry,导致内存无法释放。

因此,要让ThreadLocal中的弱引用机制发挥作用,必须确保ThreadLocal对象的生命周期与函数调用周期一致。否则,就必须显式调用remove方法来释放内存。

网上关于ThreadLocal内存泄漏的说法是正确的,但往往没有深入解释其原因。只有理解了ThreadLocalMap的内部实现机制,我们才能真正明白这种设计的原因,并合理使用ThreadLocal。

84 - 这篇文章属于一个选集。

相关文章

深入解析ThreadLocalMap的开放地址法实现
·4 分钟
后端 框架 Java
背景 # 在前面的博客中,我们介绍了ThreadLocal的实现原理,其中最核心的部分就是ThreadLocalMap这个数据结构。我们都知道HashMap是使用红黑树或者链表来解决哈希冲突的,那么ThreadLocalMap底层又是如何处理冲突的呢?
ThreadLocal 真的会导致内存泄漏吗?深入剖析使用场景与最佳实践
·3 分钟
后端 框架 Java
背景 # 在一次代码评审中,同事指出我使用 ThreadLocal 可能会导致内存泄漏,这让我大吃一惊——ThreadLocal 这么常用的工具类怎么会引发内存泄漏呢?于是我开始深入研究这个问题。
大话DDD
·11 分钟
后端 框架 Java
背景 # 什么是DDD,DDD全名 Domain Driven Design,是一种架构设计方法,和我们普通的设计模式有什么区别呢,我们知道设计模式有单例、工厂这些,这些东西只和代码有关,他是一种手法,可以看作是一个小手段,就是类似孔己己的茴香豆7种写法一样
大话Java精度问题
·7 分钟
后端 框架 Java
背景 # 事情的起因是,正当我悠闲的品尝一杯Java Caffe的时候,突然飞书一个加急信息铺面而来,“小张啊,你快看下,线上有个用户用优惠券少付一分钱“
Mockito资料整理
·5 分钟
后端 框架 Java 单元测试
背景 # 网上Mockito 资料我看了一下很多都不够清晰,我总结一下我在使用 Mockito 常用的方法
设计模式探索:从原则到实践
·15 分钟
框架 后端 Java
背景 # 在公司推进DDD中,我发现即使代码按照DDD进行分层,但是底层代码还是阅读性比较差,只不过被分到不同的子服务中。怎么让代码更加整洁规范呢?我觉得可以采用设计模式,所以我花了点时间重新学习了所有的设计模式。