7

从局部变量说起,关于一个莫得名堂的引用和一个坑! - why技术

 1 year ago
source link: https://www.cnblogs.com/thisiswhy/p/16798811.html
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

从局部变量说起,关于一个莫得名堂的引用和一个坑!

你好呀,我是歪歪。

今天带大家盘一个有点意思的基础知识啊。

有多基础呢,先给你上个代码:

20221011210201.png

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

20221015235613.png

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

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

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

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

2c1114cbeeed52b976d07d034aa8e0b8.gif

这是一个基础知识点,没骗你吧?

那么我现在换个写法:

20221011211259.png

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

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

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

20221011215955.png

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

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

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

20221015200338.png

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

然后我要引出的问题就出来了:这也是个局部变量,它为什么就不可以被回收呢?

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

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

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

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

20221011222025.png

必须做到和看到 KFC 的时候,立马就想到 v 我 50 一样自然。

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

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

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

20221011223042.png

老八股文了,不过多说。

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

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

20221011231854.png

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

live thread(GC Root) -> executorService

一个 live thread 对应到代码,一个调用了 start 方法的 Thread,这个 Thread 里面是一个实现了 Runnable 接口的对象。

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

java.util.concurrent.ThreadPoolExecutor.Worker

20221014133332.png

那么我们可以把上面的链路更加具化一点:

Worker(live thread) -> ThreadPoolExecutor(executorService)

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

有的同学立马就站起来抢答了:hi,就这?我以为多狠呢?这个我熟悉啊,不就是它吗?

20221012215133.png

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

我只是微微一笑:是的,然后呢?

抢答的同学立马就回答到:然后就证明 ThreadPoolExecutor 类是持有 workers 的引用啊?

我继续追问一句:没毛病,然后呢?

同学喃喃自语的说:然后不就结束了吗?

是的,结束了,今天的面试到这结束了,回去等通知吧。

我的问题是:找 Worker 类到 ThreadPoolExecutor 类的引用关系。

你这弄反了啊。

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

不好意思,这个真没有:

20221014133524.png

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

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

皮之不存,毛将焉附,奇怪啊,奇怪...

20221015201032.png

看着这个同学陷入了一种自我怀疑的状态,我直接就是发动一个“不容多想”的技能:坐下!听我讲!

接下来,先忘记线程池,我给大家搞个简单的 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 文件:

20221014132342.png

它们分别是这样的:

20221013225850.png

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

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

20221014134133.png

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

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

Worker(live thread) -> ThreadPoolExecutor(executorService)

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

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

20221014135640.png

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

20221014135944.png

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

20221014140220.png

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

20221014143805.png

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

20221011215955.png

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

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

道理,就这么一个道理。

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

20221016000455.png

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

垃圾回收线程一看:嚯~好家伙,过来吧,您呢。

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

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

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

20221014151840.png

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

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

20221014152200.png

或者改成这样:

20221014152243.png

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

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

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

20221014154158.png

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

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

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

那么文本的第一个延伸点就出来了。

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

20221014162332.png
1665734308312.png

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

20221015234132.png

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

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

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

20221015205031.png

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

20221014165319.png

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

20221014172433.png

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

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

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

20221014173306.png

怎么断开这个“没得名堂”的引用呢?

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

20221014173606.png

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

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

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

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

再延伸一下

关于“静态内部类”这个叫法,我记得我从第一次接触到的时候就是这样叫它的,或者说大家都是这样叫的。

然后我写文章的时候,一直在 JLS 里面找 “Static Inner Class” 这样的关键词,但是确实是没找到。

在 Inner Class 这一部分,Static Inner Class 这三个单词并没有连续的出现在一起过:

https://docs.oracle.com/javase/specs/jls/se8/html/jls-8.html#jls-8.1.3

20221015212700.png

直到我找到了这个地方:

https://docs.oracle.com/javase/tutorial/java/javaOO/nested.html

20221015213114.png

在 Java 官方教程里面,关于内部类这部分,有这样一个小贴士:

20221015213047.png

嵌套类分为两类:非静态和静态。非静态的嵌套类被称为内部类(inner classes)。被声明为静态的嵌套类被称为静态嵌套类(static nested classes)。

看到这句话的时候,我一下就反应过来了。大家习以为常的 Static Inner Class,其实是没有这样的叫法的。

nested,嵌套。

我觉得这里就有一个翻译问题了。

首先,在一个类里面定义另外一个类这种操作,在官方文档这边叫做嵌套类。

没有加 static 的嵌套类被称为内部类,从使用上来说,要实例化内部类,必须首先实例化外部类。

代码得这样写:

//先搞出内部类
OuterClass outerObject = new OuterClass();
//才能搞出内部类
OuterClass.InnerClass innerObject = outerObject.new InnerClass();

所以这个 Inner 就很传神,打个比分,它就像是我的肾,是我身体的一部分,它 Inner 我。

加了 static 的嵌套类被称为静态嵌套类,和 Inner 完全就不沾边。

这个 nested 也就很传神,它的意思就是我本来是可以独立存在的,不用依附于某个类,我依附你也只是借个壳而已,我嵌套一下。

打个比分,它就像是我的手机,它随时都在我的身上,但是它并不 Inner 我,它也可以独立于我存在。

所以,一个 Inner ,一个 nested。一个肾,一个手机,它能一样吗?

当然了,如果你非得用肾去换一个手机...

20221015215956.png

这种翻译问题,也让我想起了在知乎看到的一个类似的问题:

为什么很多编程语言要把 0 设置为第一个元素下标索引,而不是直观的 1 ?

下面有一个言简意赅、醍醐灌顶的回答:

20221015220234.png

还可以延伸一下

接下来,让我们把目光放到《Java并发编程实战》这本书上来。

这里面也有一段和本文相关的代码,初看这段代码,让无数人摸不着头脑。

书上说下这段代码是有问题的,会导致 this 引用逸出。

我第一次看到的时候,整个人都是懵的,看了好几遍都没看懂:

20221014174658.png

然后就跳过了...

直到很久之后,我才明白作者想要表达的意思。

现在我就带你盘一盘这个代码,把它盘明白。

我先把书上的代码补全,全部代码是这样的:

public class ThisEscape {
    public ThisEscape(EventSource source) {
        source.registerListener(new EventListener() {
            public void onEvent(Event e) {
                doSomething(e);
            }
        });
    }

    void doSomething(Event e) {
    }


    interface EventSource {
        void registerListener(EventListener e);
    }

    interface EventListener {
        void onEvent(Event e);
    }

    interface Event {
    }
}

代码要是你一眼看不明白,没关系,主要是关注 EventListener 这个玩意,你看它其实是一个接口对不对。

好,我给你变个型,变个你更加眼熟一点的写法:

20221014194419.png

Runnable 和 EventListener 都是接口,所以这样的写法和书中的示例代码没有本质上的区别。

但是让人看起来就眼熟了一点。

然后其实这个 EventSource 接口也并不影响我最后要给你演示的东西,所以我把它也干掉,代码就可以简化到这个样子:

public class ThisEscape {

    public ThisEscape() {
        new Runnable() {
            @Override
            public void run() {
                doSomething();
            }
        };
    }

    void doSomething() {
    }
}

在 ThisEscape 类的无参构造里面,有一个 Runnable 接口的实现,这种写法叫做匿名内部类。

看到内部类,再看到书中提到的 this 逸出,再想起前面刚刚才说的非静态内部类持有外部类的引用你是不是想起了什么?

验证一下你的想法,我通过 javac 编译这个类,然后查看它的 class 文件如下:

20221014200529.png

我们果然看到了 this 关键字,所以 “this 逸出”中的 this 指的就是书中 ThisEscape 这个类。

逸出,它带来了什么问题呢?

来看看这个代码:

20221014202227.png

由于 ThisEscape 对象在构造方法还未执行完成时,就通过匿名内部类“逸”了出去,这样外部在使用的时候,比如 doSomething 方法就拿到可能是一个还未完全完成初始化的对象,就会导致问题。

我觉得书中的这个案例,读者只要是抓住了“内部类”和“this是谁”这两个关键点,就会比较容易吸收。

针对“this逸出”的问题,书中也给出了对应的解决方案:

20221015220837.png

做个导读,就不细说了,有兴趣自己去翻一翻。

最后,文章首发在公众号【why技术】,欢迎大家关注。


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK