线程数突增!领导说再这么写就GC掉我!

科技   2024-11-17 09:20   四川  

你好呀,我是苏三。

前两天在掘金社区冲浪的时候,看到首页推了一篇文章:

https://juejin.cn/post/7197424371991855159

我看了一下,文章写的还是挺好的,案例经典,思路清晰,行文流畅。

你可以看一下,文章写的基本没啥毛病,有些小瑕疵在评论区指出了,问题也不大。

但是歪师傅觉得这里面有一个非常关键的点没有说到,导致这个文章还差点意思,所以歪师傅想要沿着这个话题再补充一下。

主要是看到文章在社区里面有点热度,引发了大家的讨论。而对于这个问题歪师傅之前又刚好浅浅的研究过一番,所以准备蹭一蹭热度。

开蹭

先上个代码:

请问,上面代码中,位于 method 方法中的 object 对象,在方法执行完成之后,是否可以被垃圾回收?

这还思考个啥呀,这必须可以呀,因为这是一个局部变量,它的作用域在于方法之间。

JVM 在执行方法时,会给方法创建栈帧,然后入栈,方法执行完毕之后出栈。

一旦方法栈帧出栈,栈帧里的局部变量,也就相当于不存在了,因为没有任何一个变量指向 Java 堆内存。

换句话说:它完犊子了,它不可达了。

那么我现在换个写法:

你说在 method 方法执行完成之后,executorService 对象是否可以被垃圾回收呢?

别想复杂了,这个东西和刚刚的 Object 一样,同样是个局部变量,肯定可以被回收的。

但是接下来我就要开始搞事情了:

我让线程池执行一个任务,相当于激活线程池,但是这个线程池还是一个局部变量。

那么问题就来了:在上面的示例代码中,executorService 对象是否可以被垃圾回收呢?

这个时候你就需要扣着脑壳想一下了...

别扣了,先说结论:不可以被回收。

由于不可用被回收,这个方法被调用的次数越多,那么创建的线程就越多。

这就是前面提到的文章中的现象:

问题也就随之引出来了:

这也是个局部变量,它为什么就不可以被回收呢?

这是文章中给出的结论,整体看来没有什么大问题。但是少了非常关键的一环,如果这一环没有捋顺,那么整个结论都是可以被推翻的,我给你扯掰扯。

为什么

你知道线程池里面有活跃线程,所以从直觉上讲应该是不会被回收的。

但是证据呢,你得拿出完整的证据链来才行啊。

好,我问你,一个对象被判定为垃圾,可以进行回收的依据是什么?

这个时候你脑海里面必须马上蹦出来“可达性分析算法”这七个字,刷的一下就要想起这样的图片:

必须做到我告诉你明天是星期四,你立马就想到 KFC,然后脱口而出"v 我 50" 一样自然。

这个算法的基本思路就是通过一系列称为“GC Roots”的根对象作为起始节点集,从这些节点开始,根据引用关系向下搜索,搜索过程所走过的路径称为“引用链”(Reference Chain),如果某个对象到 GC Roots 间没有任何引用链相连,或者用图论的话来说就是从 GC Roots 到这个对象不可达时,则证明此对象是不可能再被使用的。

所以如果要推理 executorService 是不会被回收的,那么就得推理出 GC Root 到 executorService 对象是可达的。

那么哪些对象是可以作为 GC Root 呢?

老八股文了,不过多说。

只看本文关心的部分:live thread,是可以作为 GC Root 的。

这一点在文章中也有相关说明:

所以,由于我在线程池里面运行了一个线程,即使它把任务运行完成了,它也只是 wait 在这里,还是一个 live 线程:

因此,我们只要能找到这样的一个链路就可以证明 executorService 这个局部变量不会被回收:

live thread(GC Root) -> executorService

一个 live thread 对应到代码,就是一个调用了 start 方法的 Thread,并且未处于终止状态。这个 Thread 里面是一个实现了 Runnable 接口的对象。

这个实现了 Runnable 接口的对象对应到线程池里面的代码就是这个玩意:

java.util.concurrent.ThreadPoolExecutor.Worker

再看看文章的结论部分,写的是 Worker 全部被 GC 之后,那么对应的线程池也就会被 GC 了:

这个结论没有任何毛病,但是文章少了一步论证过程。

即证明:

Worker(live thread) -> ThreadPoolExecutor(executorService)

也就是找 Worker 类到 ThreadPoolExecutor 类的引用关系。

歪师傅就来“狗尾续貂”一把,帮忙把这一步衔接上。

看到这个问题,有的同学立马就站起来抢答了这个我熟悉啊,不就是它吗?

你看,ThreadPoolExecutor 类里面有个叫做 workers 的成员变量。

就证明 ThreadPoolExecutor 类是持有 workers 的引用啊?

没毛病,但是,请注意我的问题是:

找 Worker 类到 ThreadPoolExecutor 类的引用关系。

有的同学立马又要说了:这个问题,直接看 Worker 类不就行了,看看里面有没有一个 ThreadPoolExecutor 对象的成员变量。

不好意思,这个真没有:

咋回事?难道是可以被回收的?

但是如果 ThreadPoolExecutor 对象被回收了,Worker 类还存在,那岂不是很奇怪,线程池没了,线程还在?

开始证明

接下来,先忘记线程池,我给大家搞个简单的 Demo,让我们回归本源,分析起来就简单一点了:

public class Outer {

    private int num = 0;

    public int getNum() {
        return num;
    }

    public void setNum(int num) {
        this.num = num;
    }

    //内部类
    class Inner {
        private void callOuterMethod() {
            setNum(18);
        }
    }
}

Inner 类是 Outer 类的一个内部类,所以它可以直接访问 Outer 类的变量和方法。

这个写法大家应该没啥异议,日常的开发中有时也会写内部类,我们稍微深入的想一下:为什么 Inner 类可以直接用父类的东西呢?

因为非静态内部类持有外部类的引用。

这句话很重要,后面全部都是围绕这句话展开的。

接下来我来证明一下这个点。

怎么证明呢?

很简单,javac 编译一波,答案都藏在 Class 里面。

可以看到, Outer.java 反编译之后出来了两个 Class 文件:

它们分别是这样的:

在 Outer&Inner.class 文件中,我们可以看到 Outer 在构造函数里面被传递了进来,这就是为什么我们说:为非静态内部类持有外部类的引用。

好的,理论知识有了,也验证完成了,现在我们再回过头去看看线程池:

Worker 类是 ThreadPoolExecutor 类的内部类,所以它持有 ThreadPoolExecutor 类的引用。

因此这个链路是成立的,executorService 对象不会被回收。

Worker(live thread) -> ThreadPoolExecutor(executorService)

你要不信的话,我再给你看一个东西。

我的 IDEA 里面有一个叫做 Profile 的插件,程序运行起来之后,在这里面可以对内存进行分析:

我根据 Class 排序,很容易就能找到内存中存活的 ThreadPoolExecutor 对象:

点进去一看,这不就是我定义的核心线程数、最大线程数都是 3,且只激活了一个线程的线程池吗:

从 GC Root 也能直接找到我们需要验证的链路:

所以,我们回到最开始的问题:

在上面的示例代码中,executorService 对象是否可以被垃圾回收呢?

答案是不可以,因为线程池里面有活跃线程,活跃线程是 GC Root。这个活跃线程,其实就是 Woker 对象,它是 ThreadPoolExecutor 类的一个内部类,持有外部类 ThreadPoolExecutor 的引用。所以,executorService 对象是“可达”,它不可以被回收。

道理,就这么一个道理。

然后,问题又来了:应该怎么做才能让这个局部线程池回收呢?

调用 shutdown 方法,干掉 live 线程,也就是干掉 GC Root,整个的就是个不可达。

这样,整体上就和前面提到的文章中的总结部分呼应起来了。

延伸一下

再看看我前面说的那个结论:

非静态内部类持有外部类的引用。

强调了一个“非静态”,如果是静态内部类呢?

把 Inner 标记为 static 之后, Outer 类的 setNum 方法直接就不让你用了。

如果要使用的话,得把 Inner 的代码改成这样:

或者改成这样:

也就是必须显示的持有一个外部内对象,来,大胆的猜一下为什么?

难道是静态内部类不持有外部类的引用,它们两个之间压根就是没有任何关系的?

答案我们还是可以从 class 文件中找到:

当我们给 inner 类加上 static 之后,它就不在持有外部内的引用了。

此时我们又可以得到一个结论了:

静态内部类不持有外部类的引用。

那么延伸点就出来了。

也就是《Effective Java(第三版)》中的第 24 条:

比如,还是线程池的源码,里面的拒绝策略也是内部类,它就是 static 修饰的:

为什么不和 woker 类一样,弄成非静态呢?

这个就是告诉我:当我们在使用内部类的时候,尽量要使用静态内部类,免得莫名其妙的持有一个外部类的引用,又不用上。

其实用不上也不是什么大问题。

真正可怕的是:内存泄露。

比如网上的这个测试案例:

Inner 类不是静态内部类,所以它持有外部类的引用。但是,在 Inner 类里面根本就不需要使用到外部类的变量或者方法,比如这里的 data。

你想象一下,如果 data 变量是个很大的值,那么在构建内部类的时候,由于引用存在,不就不小心额外占用了一部分本来应该被释放的内存吗。

所以这个测试用例跑起来之后,很快就发生了 OOM:

怎么断开这个“莫名其妙”的引用呢?

方案在前面说了,用静态内部类:

只是在 Inner 类上加上 static 关键字,不需要其他任何变动,问题就得到了解决。

但是这个 static 也不是无脑直接加的,在这里可以加的原因是因为 Inner 类完全没有用到 Outer 类的任何变量和属性。

所以,再次重申《Effective Java(第三版)》中的第 24 条:静态内部类优于非静态内部类。

你看,他用的是“优于”,意思是优先考虑,而不是强行怼。


最后欢迎加入苏三的星球,你将获得:商城系统实战、秒杀系统实战、

代码生成工具、系统设计、性能优化、技术选型、高频面试题、底层原理、

Spring源码解读、工作经验分享、痛点问题等多个优质专栏。

还有1V1答疑、修改简历、职业规划、送书活动、技术交流。

目前星球已经更新了4400+篇优质内容,还在持续爆肝中..星球已经被官方推荐了3次,收到了小伙伴们的一致好评。戳我加入学习,已有1400+小伙伴加入学习

我的技术专栏《程序员最常见的100个问题》,目前已经更新了80篇干货文章,里面收录了很多踩坑经历,对你的职业生涯或许有些帮助,最近收到的好评挺多的。

这个专栏总结了我10年工作中,遇到过的100个非常有代表性的技术问题,非常有参考和学习价值。

Java、Spring、分布式、高并发、数据库、海量数据、线上问题什么都有。

每篇文章从发现问题、分析问题、解决问题和问题总结等多个维度,深入浅出,分享了很多技术细节,定位和排查问题思路,解决问题技巧,以及实际工作经验。

你能从中学到很多有用知识,帮你少走很多弯路。

扫描下方二维码即可订阅:


原价199,现价只需23,即将涨价。




    苏三说技术
    作者曾浪迹几家大厂,掘金优秀创作者,CSDN万粉博主,免费刷题网站:www.susan.net.cn
     最新文章