從區域性變數說起,關於一個莫得名堂的參照和一個坑!

2022-10-17 15:01:31

你好呀,我是歪歪。

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

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

請問,上面程式碼中,位於 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 這三個單詞並沒有連續的出現在一起過:

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

直到我找到了這個地方:

https://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技術】,歡迎大家關注。