從局部變量說起,關於一個莫得名堂的引用和一個坑!

  • 2022 年 10 月 17 日
  • 筆記

你好呀,我是歪歪。

今天帶大家盤一個有點意思的基礎知識啊。

有多基礎呢,先給你上個代碼:

請問,上面代碼中,位於 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(live thread) -> ThreadPoolExecutor(executorService)

也就是找 Worker 類到 ThreadPoolExecutor 類的引用關係。

有的同學立馬就站起來搶答了:hi,就這?我以為多狠呢?這個我熟悉啊,不就是它嗎?

你看,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 條:靜態內部類優於非靜態內部類。

你看,他用的是「優於」,意思是優先考慮,而不是強行懟。

再延伸一下

關於「靜態內部類」這個叫法,我記得我從第一次接觸到的時候就是這樣叫它的,或者說大家都是這樣叫的。

然後我寫文章的時候,一直在 JLS 裏面找 「Static Inner Class」 這樣的關鍵詞,但是確實是沒找到。

在 Inner Class 這一部分,Static Inner Class 這三個單詞並沒有連續的出現在一起過:

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

直到我找到了這個地方:

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

在 Java 官方教程裏面,關於內部類這部分,有這樣一個小貼士:

嵌套類分為兩類:非靜態和靜態。非靜態的嵌套類被稱為內部類(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。一個腎,一個手機,它能一樣嗎?

當然了,如果你非得用腎去換一個手機…

這種翻譯問題,也讓我想起了在知乎看到的一個類似的問題:

為什麼很多編程語言要把 0 設置為第一個元素下標索引,而不是直觀的 1 ?

下面有一個言簡意賅、醍醐灌頂的回答:

還可以延伸一下

接下來,讓我們把目光放到《Java並發編程實戰》這本書上來。

這裏面也有一段和本文相關的代碼,初看這段代碼,讓無數人摸不着頭腦。

書上說下這段代碼是有問題的,會導致 this 引用逸出。

我第一次看到的時候,整個人都是懵的,看了好幾遍都沒看懂:

然後就跳過了…

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

現在我就帶你盤一盤這個代碼,把它盤明白。

我先把書上的代碼補全,全部代碼是這樣的:

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 這個玩意,你看它其實是一個接口對不對。

好,我給你變個型,變個你更加眼熟一點的寫法:

Runnable 和 EventListener 都是接口,所以這樣的寫法和書中的示例代碼沒有本質上的區別。

但是讓人看起來就眼熟了一點。

然後其實這個 EventSource 接口也並不影響我最後要給你演示的東西,所以我把它也幹掉,代碼就可以簡化到這個樣子:

public class ThisEscape {

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

    void doSomething() {
    }
}

在 ThisEscape 類的無參構造裏面,有一個 Runnable 接口的實現,這種寫法叫做匿名內部類。

看到內部類,再看到書中提到的 this 逸出,再想起前面剛剛才說的非靜態內部類持有外部類的引用你是不是想起了什麼?

驗證一下你的想法,我通過 javac 編譯這個類,然後查看它的 class 文件如下:

我們果然看到了 this 關鍵字,所以 「this 逸出」中的 this 指的就是書中 ThisEscape 這個類。

逸出,它帶來了什麼問題呢?

來看看這個代碼:

由於 ThisEscape 對象在構造方法還未執行完成時,就通過匿名內部類「逸」了出去,這樣外部在使用的時候,比如 doSomething 方法就拿到可能是一個還未完全完成初始化的對象,就會導致問題。

我覺得書中的這個案例,讀者只要是抓住了「內部類」和「this是誰」這兩個關鍵點,就會比較容易吸收。

針對「this逸出」的問題,書中也給出了對應的解決方案:

做個導讀,就不細說了,有興趣自己去翻一翻。

最後,文章首發在公眾號【why技術】,歡迎大家關注。