底层解剖ThreadLocal及其引发的内存泄漏问题

首先我们先明确一点,这里我们谈论的是比如 线程池中的核心线程 的情况,而不是普通的run完就销毁的线程。后面会继续说明为什么。

关于ThreadLocal和ThreadLocalMap

假设线程run()这样:

public void run() {
    ThreadLocal<String> threadLocal = new ThreadLocal<>();
    threadLocal.set("abc");
}

关系是:
threadLocal 变量只是你线程中的一个局部变量,在线程的栈帧中,它指向了一个堆上的 ThreadLocal 实例。
1、ThreadLocal是一个Java工具类,当我们

new ThreadLocal<>()

就只是new了一个这个工具类,其他的什么都没有发生。并不是每个线程都有一个它,不要混淆了。
每个线程都有一个的是ThreadLocalMap,它是Thread类的成员,只是这个map里面存的键值对,其中key的类型恰好就是ThreadLocal

2、当我们执行

threadLocal.set("abc");

①ThreadLocal 会获取当前线程(Thread.currentThread());
②拿到该线程中的 ThreadLocalMap;
③然后往当前线程的ThreadLocalMap里,新增一个键值对(也可能是修改),key就是刚刚new的这个threadLocal对象,value就是设置的“abc”。

补充一句,这个ThreadLocalMap是懒加载的,并不是Thread出生就有一个,是第一次执行ThreadLocal.set()的时候才会出生。

关于引用关系与内存泄漏

首先,内存泄漏,并不是顾名思义的说这个线程想独有的内存给别的线程偷偷抢走了,而是,内存不能被回收,就是内存泄露。

为了避免混淆,我们把上面的代码改个名字

public void run() {
    ThreadLocal<String> ikun = new ThreadLocal<>();
    threadLocal.set("abc");
}

当我们new了一个ThreadLocal对象ikun之后,实际保存是这样的:ikun是一个引用,指向实际的ThreadLocal对象,ikun保存在这个线程的里,而实际的ThreadLocal对象数据保存在里,并且ikun是强引用这个对象。而ThreadLocalMap也在堆里,而它里面的新创建的键值对,key是一个引用,引用了ThreadLocal对象,并且key是弱引用这个对象,而value就是"abc"不管它,不过要注意value是对"abc"的强引用

我们把这段话剔出来骨头就是:
栈里的 ikun 变量是对堆中那个 ThreadLocal 实例的强引用
ThreadLocalMap 的键值对中的 key 是对同一个 ThreadLocal 实例的弱引用

两个引用都指向同一个堆上的 ThreadLocal 实例,只是引用类型不同:栈变量是强引用,key 是弱引用。
也就是:

引用来源 引用类型 指向 作用
栈上的 ikun 强引用 堆中的 ThreadLocal 实例 保持实例存活,确保它不被 GC
ThreadLocalMap.Entry 弱引用 (WeakReference) 同一个 ThreadLocal 实例 允许 GC 回收无用 ThreadLocal 实例,防止内存泄漏

ikun要强引用(其实就是普通引用)很合理,因为ikun要用ThreadLocal,要是弱引用,还没用完就直接给GC回收了,那就抽象了。换句话说,ikun就是一个普通的变量引用,就和 Object o = new Object(); 的o没区别,这个o当然也是对Object对象的强引用。

但是为什么ThreadLocalMap的键值对的key要对ThreadLocal用弱引用呢?
——实际上就是为了防止或者说缓解内存泄漏而设计的。

因为设计成弱引用就是这样的:
当线程的run()运行结束之后,该方法中的局部变量(包括 ikun)全部出栈,ikun就走了,这个强引用就消失了;

这个时候,堆上的 ThreadLocal 实例只有 线程内部 ThreadLocalMap 键值对的Key的 弱引用 指向它;

JVM GC 发现该 ThreadLocal 实例只有弱引用,没有强引用,就会将其回收;

回收后,ThreadLocalMap 键值对中的 key 就会变成 null(弱引用被清理后),线程后续调用 ThreadLocal 的 get 方法时,会检测到 key 为 null 的键值对,进而触发清理,释放对应的 value(强引用),避免内存泄漏。

想想要是key对ThreadLocal是强引用,那么ikun出栈之后,ThreadLocal就一直不能被回收,这内存泄漏就很严重了。

因此,这样设计可以避免线程长时间持有已失效的 ThreadLocal 实例,导致其关联的值无法被回收;
即使用户忘记调用threadLocal.remove(),也能通过弱引用 + 后续清理机制来防止内存泄漏。

不过这里有个问题,线程都run完了,谁来调用ThreadLocal 的get方法以触发清理?
这个问题就是开头说明的,其实这个说法是针对线程池中的核心线程的。

如果只是普通线程,比如说web项目中的一次请求这样的普通线程,那么当Thread 对象终止后,JVM 的垃圾回收器会回收线程对象和它持有的所有成员变量,包括线程的 ThreadLocalMap,因为线程结束意味着该线程对象不再被 JVM 持有,也没有任何活动线程引用它;
因此,普通线程执行完 run() 后,ThreadLocalMap 及其中的所有键值对也会随着线程对象一起被清理,不会造成内存泄漏。

但是如果是线程池中的线程,这一次run()完了之后,线程并不会直接被销毁,尤其是核心线程,不会被销毁,一直等着下一个任务呢,所以相当于给了他二次上线的机会,再去调用ThreadLocal的 get 方法以触发清理。

所以可以说threadlocal的内存泄露只是暂时的,因为普通线程销毁了就全回收了,而线程池的线程在下一次被调用的时候运行set、get方法的时候就会触发清理了。
但是如果这个核心线程一直没有机会二进宫,就一直不会被清理。

疑问

threadlocalmap的key本身就是对threadlocal的弱引用,弱引用不会直接被回收掉吗?

如果有这样的问题,说明你搞混淆了,key是弱引用,引用的是ThreadLocal,要回收是ThreadLocal,而不是key。
ThreadLocal身上还有ikun的强引用,所以ikun出栈前ThreadLocal不会被回收。

补充

这里提一嘴,GC回收判断对象是否是垃圾的 引用计数法:当一个对象身上背负的引用数到0的时候,GC才能清理它,弱引用不计在内。
当然,他还有可达性分析法:从 GC Roots 开始向下搜索,搜索走过的路径称为引用链,当一个对象和 GC Roots 之间没有任何引用链相连时,则证明此对象是不可用的垃圾。

你可能感兴趣的:(jvm,java,ThreadLocal,ThreadLocalMap,内存泄漏)