查看原文
其他

内存泄露的原因找到了,罪魁祸首居然是 Java TheadLocal

脚本之家 2022-04-23

The following article is from 爱笑的架构师 Author 雷架

 关注“脚本之家”,与百万开发者在一起

作者 | 雷架

来源 | 爱笑的架构师(ID:DancingOnYourCode)


ThreadLocal使用不规范,师傅两行泪


组内来了一个实习生,看这小伙子春光满面、精神抖擞、头发微少,我心头一喜:绝对是个潜力股。于是我找经理申请亲自来带他,为了帮助小伙子快速成长,我给他分了一个需求,这不需求刚上线几天就出网上问题了,后台监控服务发现内存一直在缓慢上升,初步怀疑是内存泄露。
把实习生的PR都找出来仔细 review,果然发现问题了。由于公司内部代码是保密的,这里简单写一个 demo 还原场景(忽略代码风格问题)。
public class ThreadPoolDemo {private static final ThreadPoolExecutor poolExecutor = new ThreadPoolExecutor(5, 5, 1, TimeUnit.MINUTES, new LinkedBlockingQueue<>());public static void main(String[] args) throws InterruptedException {for (int i = 0; i < 100; ++i) { poolExecutor.execute(new Runnable() {@Overridepublic void run() { ThreadLocal<BigObject> threadLocal = new ThreadLocal<>(); threadLocal.set(new BigObject());// 其他业务代码 } }); Thread.sleep(1000); } }static class BigObject {// 100Mprivate byte[] bytes = new byte[100 * 1024 * 1024]; }}
代码分析:
  • 创建一个核心线程数和最大线程数都为10的线程池,保证线程池里一直会有10个线程在运行。
  • 使用for循环向线程池中提交了100个任务。
  • 定义了一个 ThreadLoca 类型的变量,Value 类型是大对象。
  • 每个任务会向 threadLocal 变量里塞一个大对象,然后执行其它业务逻辑。
  • 由于没有调用线程池的 shutdown 方法,线程池里的线程还是会在运行。
乍一看这代码好像没有什么问题,那为什么会导致服务 GC 后内存还高居不下呢?
代码中给 threadLocal 赋值了一个大的对象,但是执行完业务逻辑后没有调用remove 方法,最后导致线程池中10个线程的 threadLocals 变量中包含的大对象没有被释放掉,出现了内存泄露。
大家说说这样的实习生还能留不?

ThreadLocal 的 value 值存在哪里?


实习生说他以为线程任务结束了 threadLocal 赋值的对象会被 JVM 垃圾回收,很疑惑为什么会出现内存泄露。作为师傅我肯定要给他把原理讲透呀。
ThreadLocal 类提供 set/get 方法存储和获取 value 值,但实际上 ThreadLocal 类并不存储value值,真正存储是靠ThreadLocalMap这个类,ThreadLocalMap 是 ThreadLocal 的一个静态内部类,它的 key 是ThreadLocal 实例对象,value 是任意 Object 对象。
ThreadLocalMap 类的定义:
static class ThreadLocalMap {// 定义一个table数组,存储多个threadLocal对象及其value值 private Entry[] table; ThreadLocalMap(ThreadLocal<?> firstKey, Object firstValue) { table = new Entry[INITIAL_CAPACITY]; int i = firstKey.threadLocalHashCode & (INITIAL_CAPACITY - 1); table[i] = new Entry(firstKey, firstValue); size = 1; setThreshold(INITIAL_CAPACITY); }// 定义一个Entry类,key是一个弱引用的ThreadLocal对象// value是任意对象static class Entry extends WeakReference<ThreadLocal<?>> {/** The value associated with this ThreadLocal. */Object value; Entry(ThreadLocal<?> k, Object v) {super(k); value = v; } }// 省略其他}
进一步分析 ThreadLocal 类的代码,看 set 和 get 方法如何与 ThreadLocalMap 静态内部类关联上。

1、ThreadLocal 类 set 方法

public class ThreadLocal<T> {public void set(T value) { Thread t = Thread.currentThread(); ThreadLocalMap map = getMap(t);if (map != null)map.set(this, value);else createMap(t, value); }ThreadLocalMap getMap(Thread t) {return t.threadLocals; }void createMap(Thread t, T firstValue) { t.threadLocals = new ThreadLocalMap(this, firstValue); }// 省略其他方法}
set 的逻辑比较简单,就是获取当前线程的 ThreadLocalMap,然后往 map 里添加 KV,K 是当前 ThreadLocal 实例,V 是我们传入的 value。这里需要注意一下,map 的获取是需要从 Thread 类对象里面取,看一下 Thread 类的定义。
public class Thread implements Runnable { ThreadLocal.ThreadLocalMap threadLocals = null;//省略其他}
Thread 类维护了一个 ThreadLocalMap 的变量引用。

2、ThreadLocal 类 get 方法

get获取当前线程的对应的私有变量,是之前 set 或者通过 initialValue 的值,代码如下:
class ThreadLocal<T> {public T get() { Thread t = Thread.currentThread(); ThreadLocalMap map = getMap(t);if (map != null) { ThreadLocalMap.Entry e = map.getEntry(this);if (e != null)return (T)e.value; }return setInitialValue(); }}
代码逻辑分析:
  • 获取当前线程的 ThreadLocalMap 实例;
  • 如果不为空,以当前 ThreadLocal 实例为 key 获取 value;
  • 如果 ThreadLocalMap 为空或者根据当前 ThreadLocal 实例获取的 value 为空,则执行 setInitialValue();

ThreadLocal 相关类的关系总结


看了上面的分析是不是对 Thread,ThreadLocal,ThreadLocalMap,Entry这几个类之间的关系有点晕了,没关系我专门画了一个 UML 类图来总结(忽略UML 标准语法)。

ThreadLocal相关类的关系
  • 每个线程是一个 Thread 实例,其内部维护一个 threadLocals 的实例成员,其类型是 ThreadLocal.ThreadLocalMap。
  • 通过实例化 ThreadLocal 实例,我们可以对当前运行的线程设置一些线程私有的变量,通过调用 ThreadLocal 的 set 和 get 方法存取。
  • ThreadLocal 本身并不是一个容器,我们存取的 value 实际上存储在ThreadLocalMap 中,ThreadLocal 只是作为 TheadLocalMap 的 key。
  • 每个线程实例都对应一个 TheadLocalMap 实例,我们可以在同一个线程里实例化很多个 ThreadLocal 来存储很多种类型的值,这些 ThreadLocal 实例分别作为 key,对应各自的 value,最终存储在 Entry table 数组中。
  • 当调用 ThreadLocal 的 set/get 进行赋值/取值操作时,首先获取当前线程的 ThreadLocalMap 实例,然后就像操作一个普通的 map一样,进行put 和 get。

ThreadLocal 内存模型原理


经过上面的分析我们对 ThreadLocal 相关的类设计已经非常清楚了,下面通过一张图更加深入理解一下 ThreadLocal 的内存存储。
ThreadLocal 内存模型
图中左边是栈,右边是堆。线程的一些局部变量和引用使用的内存属于Stack(栈)区,而普通的对象是存储在 Heap(堆)区。
  • 线程运行时,我们定义的 TheadLocal 对象被初始化,存储在 Heap,同时线程运行的栈区保存了指向该实例的引用,也就是图中的ThreadLocalRef。
  • 当 ThreadLocal 的 set/get 被调用时,虚拟机会根据当前线程的引用也就是 CurrentThreadRef 找到其对应在堆区的实例,然后查看其对用的TheadLocalMap 实例是否被创建,如果没有,则创建并初始化。
  • Map 实例化之后,也就拿到了该 ThreadLocalMap 的句柄,那么就可以将当前 ThreadLocal 对象作为 key,进行存取操作。
  • 图中的虚线,表示 key 对应 ThreadLocal 实例的引用是个弱引用。

强引用弱引用的概念


ThreadLocalMap 的 key 是一个弱引用类型,源代码如下:
static class ThreadLocalMap {// 定义一个Entry类,key是一个弱引用的ThreadLocal对象// value是任意对象static class Entry extends WeakReference<ThreadLocal<?>> {/** The value associated with this ThreadLocal. */Object value; Entry(ThreadLocal<?> k, Object v) {super(k); value = v; } }// 省略其他}
下面解释一下常见的几种引用概念。

1、强引用

一直活着:类似“Object obj=new Object()”这类的引用,只要强引用还存在,垃圾收集器永远不会回收掉被引用的对象实例。

2、弱引用

回收就会死亡:被弱引用关联的对象实例只能生存到下一次垃圾收集发生之前。当垃圾收集器工作时,无论当前内存是否足够,都会回收掉只被弱引用关联的对象实例。在 JDK 1.2之后,提供了 WeakReference 类来实现弱引用。

3、软引用

有一次活的机会:软引用关联着的对象,在系统将要发生内存溢出异常之前,将会把这些对象实例列进回收范围之中进行第二次回收。如果这次回收还没有足够的内存,才会抛出内存溢出异常。在JDK 1.2之后,提供了 SoftReference 类来实现软引用。

4、虚引用

也称为幽灵引用或者幻影引用,它是最弱的一种引用关系。一个对象实例是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。为一个对象设置虚引用关联的唯一目的就是能在这个对象实例被收集器回收时收到一个系统通知。在 JDK 1.2之后,提供了 PhantomReference 类来实现虚引用。

内存泄露是不是弱引用的锅?


从表面上看内存泄漏的根源在于使用了弱引用,但是另一个问题也同样值得思考:为什么 ThreadLocalMap 使用弱引用而不是强引用?
翻看官网文档的说法:
To help deal with very large and long-lived usages, the hash table entries use WeakReferences for keys. 
为了处理非常大和长期的用途,哈希表条目使用weakreference作为键。
分两种情况讨论:
(1)key 使用强引用
引用 ThreadLocal 的对象被回收了,但是 ThreadLocalMap 还持有ThreadLocal 的强引用,如果没有手动删除,ThreadLocal 不会被回收,导致Entry 内存泄漏。
(2)key 使用弱引
引用 ThreadLocal 的对象被回收了,由于 ThreadLocalMap 持有ThreadLocal 的弱引用,即使没有手动删除,ThreadLocal 也会被回收。value在下一次 ThreadLocalMap 调用 set、get、remove 的时候会被清除。
比较两种情况,我们可以发现:由于 ThreadLocalMap的生命周期跟 Thread一样长,如果都没有手动删除对应 key,都会导致内存泄漏,但是使用弱引用可以多一层保障:弱引用ThreadLocal被清理后 key 为 null,对应的 value 在下一次 ThreadLocalMap 调用 set、get、remove 的时候可能会被清除。
因此,ThreadLocal 内存泄漏的根源是:由于 ThreadLocalMap 的生命周期跟 Thread 一样长,如果没有手动删除对应 key 就会导致内存泄漏,而不是因为弱引用。

ThreadLocal 最佳实践


通过前面几小节我们分析了 ThreadLocal 的类设计以及内存模型,同时也重点分析了发生内存泄露的条件和特定场景。最后结合项目中的经验给出建议使用ThreadLocal 的场景:
  • 当需要存储线程私有变量的时候。
  • 当需要实现线程安全的变量时。
  • 当需要减少线程资源竞争的时候。
综合上面的分析,我们可以理解 ThreadLocal 内存泄漏的前因后果,那么怎么避免内存泄漏呢?
答案就是:每次使用完 ThreadLocal,建议调用它的 remove() 方法,清除数据。另外需要强调的是并不是所有使用 ThreadLocal 的地方,都要在最后 remove(),因为他们的生命周期可能是需要和项目的生存周期一样长的,所以要进行恰当的选择,以免出现业务逻辑错误!
  推荐阅读:
计算机优秀书籍每周销售排行榜
你也许连删库跑路都不会
手机没网了,却还能支付?

每日打卡赢积分兑换书籍入口

👇🏻👇🏻👇🏻


您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存