ThreadLocal的内存泄露到底怎么回事

Sal Lv1

如果你的简历上写到了ThreadLocal,那么你面试的时候大概率会被面试官拷打到这个知识点,而且无非就是问你ThreadLocal是怎么实现的,ThreadLocal的内存泄漏是怎么来的?为什么Entry中对Key的引用是所引用,而value就不是弱引用呢?网上有很多说法,说加个static就可以了,说大多数时候都不会有这个问题,可是真的是这样吗,都知道加static可以解决一部分问题,可是有几个能说清楚为什么呢。

ThreadLocal底层是怎么实现的

要想搞清楚ThreadLocal那么多的相关知识,我们就要先搞清楚一个前提:

ThreadLocal是怎么实现的?

首先我们都知道,ThreadLocal提供了一套线程隔离的存储与获取对象的方法,但它到底是怎么实现的呢?

alt text

首先我们来看看这张图片,每一个Thread都有一个唯一对应的ThreadLocalMap,而这个ThreadLocalMap你可以把他看作一种特殊的HashMap,因为它也是Map结构,存储的是Entry数组,相比HashMap比较不同的是,所有的Key都是ThreadLocal(这里也就是说同一个线程可以声明多个ThreadLocal变量,所以同一线程下,ThreadLocal不是唯一的,但是ThreadLocalMap绝对是唯一的)。

我们再来看看对象之间的引用关系。与其说每个ThreadLocalMap存储了Entry,不如说ThreadLocalMap存储了一个Entry数组,这个数组存储的每个Entry实际上是指针,也就是说这些Entry实际上是引用,真正的Entry,对应的key-value还是存储在堆上的。

alt text

内存泄漏是什么?

我们先来讲讲内存泄漏是什么吧。

用C++举个例子:

alt text

C++的内存空间的创建和释放都是要我们手动管理的。
假如说你new了一个对象,用指针指向了它。
你还没有通过delete释放它,你就讲原本用于存储它内存地址的指针指向了另一个地址。
那么你就再也拿不到你原来new的那个对象了,你再也无法对这块空间进行操作了。
但是这个对象依旧会保存在内存中,你无法释放它,它永远会占用一块内存空间。
这就是我们说的内存泄露问题,简单来讲,就是有一块内存空间我们无法进行控制和利用了。

放到Java来看,如果有一个对象,你明明不需要他了,可是从你的代码层面,你无法去释放它,而垃圾回收器还以为这个对象不能被回收,于是这个对象就会一直浪费着这一块的空间。

久而久之 浪费的空间越来越多,要么面临Full GC,要么面临OOM。

ThreadLocal内存泄露?设为static就没事了?

线程的正常生命周期下会存在内存泄漏问题吗?

我们讲一下一条正常的Thread从创建到销毁的生命周期下,ThreadLocal是否有内存泄漏问题。

新建一条线程,然后我们new了一个ThreadLocal,当第一次调用ThreadLocal的set方法时,会去查询该条线程是否有对应的ThreadLocalMap了,如果没有的话,就会在set之前先创造ThreadLocalMap。接着把该ThreadLocal存入到ThreadLocalMap中,将set的value存为对应的值。

1
另外这里想提一嘴,ThreadLocalMap解决冲突的方式是开放定址法,而HashMap解决冲突是链式定址法。

我们忽略到中途的调用细节,我们来说,到最后这条线程被释放了,会发生什么。

alt text
alt text

如上两张图,由于Thread被销毁了,那么堆上的ThreadLocalMap也就失去了强引用,而失去强引用的对象在下一次GC到来时都会被回收的。

于是ThreadLocalMap就被回收了。

alt text

同理,Entry失去了ThreadLocalMap中Entry数组中对其的引用,Entry也被回收。
alt text

接下来 ThreadLocal与被存储的对应的value也被回收。

所以大家是可以看到的 只要Thread被销毁,根本就不存在ThreadLocal的内存泄漏问题。

线程池场景下的内存泄漏

刚刚说到的场景,是单挑线程被正常创建与释放的。

那线程池下是怎样的呢?要知道SpringBoot的默认容器可就是tomcat,而tomcat底层是基于线程池实现的,每一条请求都对应一条线程。

一条请求过来之后,如果说我们用的是局部变量new了一个ThreadLocal,那么会发生什么呢?
alt text
你会发现,由于Thread不会被销毁,那么ThreadLocalMap也就不会销毁,我们又没有对ThreadLocal做释放。

逻辑上讲,上一条请求的ThreadLocal的值对我们来说是没有用的。

而我们也拿不到上一条请求的地址,而ThreadLocalMap对该entry的应用依旧存在,也就是说,这个我们用不到也拿不到的entry,依旧会存在于我们的内存空间中。
alt text

Entry对key的弱引用

这里的Entry对key,也就是ThreadLocal的引用,实际上是弱引用。被弱引用的对象如果没有其他的引用,那么下一次GC必定会被回收,也就是说,实际上ThreadLocal这个对象,我们是不存在内存泄漏问题的。

真正存在内存泄露的,实际上是ThreadLocal中对应存的Value。entry对value可不是弱引用。

那么有的人就会问了,为什么不把value也设为弱引用?

这是个很好的问题,我们先来补充点前置知识吧。

强引用:我们能看到的显式的声明基本都是强应用,比如:

List list = new ArrayList<>();

这里的list就是对堆上的arraylist有一个强引用。

弱引用需要我们继承weakReference。而被弱引用的对象,如果没有其他的强引用或软引用(有兴趣可以自行了解)。

下一次GC必然会被回收。

那么我们再来看原来的场景:

alt text

这条请求结束了,那么实际上ThreadLocal的强引用也就不存在了,只剩下了一个弱引用,ThreadLocal也就被回收了。

为什么不把value设为弱引用呢?接着看这张图,你会发现,我们没有对value设置一个显式的强引用,也就是说,我们直接将他存入了ThreadLocal中,如果value也是弱引用,而外部没有强引用,下一次GC,那么ThreadLocal中的value就会被回收。可能我们代码的业务链还没走完,value就被回收了。

如何解决ThreadLocal的内存泄漏问题?

首先来说最完美的解决方案:

调用ThreadLocal的remove方法。

alt text

先调用ThreadLocal中的remove方法,然后对ThreadLocal进行置空,这是最好的解决方法。

这样一点都不用担心内存泄露的问题,实际开发中建议将ThreadLocal的生命周期管理在Interceptor中处理,或者用AOP去处理。

将ThreadLocal封装成某个类的静态成员变量

将ThreadLocal设为静态变量,这样能保证,不会被销毁的那条线程,所对应的ThreadLocal是唯一的。

苍穹外卖里就是这么写的:

alt text

由于ThreadLocal是唯一的,当当前请求结束下一条请求进入时,调用了ThreadLocal的set方法,原来的存储对象就会被覆盖:

alt text

但是依旧有一定的缺陷,比如如果长时期没有请求,里面永远要存着那个对象,假如存的是一个超级大的对象呢?及其占空间,对吧。所以最好还是remove一下。

  • 标题: ThreadLocal的内存泄露到底怎么回事
  • 作者: Sal
  • 创建于 : 2025-07-23 21:04:34
  • 更新于 : 2025-07-25 22:19:15
  • 链接: https://redefine.ohevan.com/posts/58566/
  • 版权声明: 本文章采用 CC BY-NC-SA 4.0 进行许可。
评论