由一個Bug來看Java內存模型和垃圾回收

背景

前兩天,項目中發現一個Bug。咱們使用的RocketMQ,在服務啓動後會建立MQ的消費者實例。測試過程當中,發現服務啓動一段時間後,與RocketMQ的鏈接就會斷掉,從而找不到訂閱關係,監聽不到數據。bash

1、Bug的產生

通過回溯代碼,發現訂閱的邏輯是這樣的。將ConsumerStarter類註冊到Spring,並經過PostConstruct註解觸發初始化方法,完成MQ消費者的建立和訂閱。 微信

ConsumerStarter

上面代碼中的Subscriber類是同事寫的一個工具類,封裝了一些鏈接RocketMQ的配置信息,使用的時候都調用這裏。這裏面也不復雜,就是鏈接RocketMQ,完成建立和訂閱。app

Subscriber

一、finalize

上面的代碼看起來平平無奇,但實際上他重寫了finalize方法。而且在裏面執行了consumer.shutdown(),將RocketMQ斷開了,這裏是誘因。工具

finalizeObject中的方法。在GC(垃圾回收器)決定回收一個不被其餘對象引用的對象時調用。子類覆寫 finalize 方法來處置系統資源或是負責清除操做。測試

回到項目中,他這樣的寫法就是在Subscriber類被回收的時候,斷開RokcketMQ的鏈接,於是產生了Bug。最簡單的方式就是把shutdown這句代碼刪掉,但這彷佛不是好的解決方案。this

二、爲什麼被回收?

在Java的內存模型中,有一個虛擬機棧,它是線程私有的。spa

虛擬機棧是線程私有的,每建立一個線程,虛擬機就會爲這個線程建立一個虛擬機棧,虛擬機棧表示Java方法執行的內存模型,每調用一個方法就會爲每一個方法生成一個棧幀(Stack Frame),用來存儲局部變量表、操做數棧、動態連接、方法出口等信息。每一個方法被調用和完成的過程,都對應一個棧幀從虛擬機棧上入棧和出棧的過程。虛擬機棧的生命週期和線程是相同的.net

在上面的ConsumerStarter.init()方法中,Subscriber subscriber = new Subscriber()被定義成了局部變量,在方法執行完畢後,subscriber變量就沒有了引用,而後GC掉。線程

很快,我就有了新的想法,將Subscriber定義成ConsumerStarter類中的成員變量也是能夠的,由於ConsumerStarter是註冊到了Spring中。在Bean的生命週期內,不會被回收。3d

如上代碼,把subscriber變量做用域提到類級別,事實證實這樣也是沒問題的。

還有個更優的方案是,將Subscriber類直接註冊到Spring中,由PostConstruct註解觸發初始化完成對MQ的建立和訂閱;由PreDestroy註解完成資源的釋放。這樣,資源的建立和銷燬跟Bean的生命週期綁定,也是沒問題的。

到目前爲止,這個Bug的緣由和解決方案都有了。但還有個問題,筆者當時也沒想明白。

2、線程與垃圾回收

爲了肯定哪些對象是垃圾,在Java中使用了可達性分析的方法。

它經過經過一系列的GC roots對象做爲起點搜索。從這些節點開始向下搜索,搜索所走過的路徑稱爲引用鏈(Reference Chain),當一個對象到GC Roots沒有任何引用鏈相連時,則證實此對象是不可用的。

在上面的例子中,雖然Subscriber類已經不被引用,要被回收,可是它裏面還有RocketMQConsumer實例還在以線程的方式運行,這種狀況下,Subscriber類也會被回收掉嗎?

那麼,就變成了這樣一個問題: 若是一個對象A已不被引用,符合GC條件;可是它裏面還有一個線程對象a1在活躍,那麼此時這個對象A還會被回收嗎?

爲了驗證這個問題,筆者作了一個測試。

一、測試一

在這裏,仍是以Subscriber類爲例,給它啓動一個線程,看是否還會被GC。簡化代碼以下:

測試流程是:啓動服務 > Subscriber類被建立 > 線程執行10次循環 > 手動調用GC

得出結果以下:

15:01:07.110  [main]  INFO  - Subscriber已啓動...
15:01:07.112  [Thread-9]  INFO  - 執行線程次數:0
15:01:07.357  [main]  INFO  - Initializing ExecutorService 'applicationTaskExecutor'
15:01:07.568  [main]  INFO  - Starting ProtocolHandler ["http-nio-8081"]
15:01:07.609  [main]  INFO  - Tomcat started on port(s): 8081 (http) with context path ''
15:01:07.614  [main]  INFO  - Started BootMybatisApplication in 3.654 seconds
15:01:08.114  [Thread-9]  INFO  - 執行線程次數:1
15:01:09.114  [Thread-9]  INFO  - 執行線程次數:2
15:01:09.302  [http-nio-8081-exec-2]  INFO  - 調用GC。。。
15:01:10.122  [Thread-9]  INFO  - 執行線程次數:3
15:01:11.123  [Thread-9]  INFO  - 執行線程次數:4
15:01:12.123  [Thread-9]  INFO  - 執行線程次數:5
15:01:12.319  [http-nio-8081-exec-3]  INFO  - 調用GC。。。
15:01:13.123  [Thread-9]  INFO  - 執行線程次數:6
15:01:14.124  [Thread-9]  INFO  - 執行線程次數:7
15:01:15.125  [Thread-9]  INFO  - 執行線程次數:8
15:01:15.319  [http-nio-8081-exec-5]  INFO  - 調用GC。。。
15:01:16.125  [Thread-9]  INFO  - 執行線程次數:9
15:01:17.775  [http-nio-8081-exec-7]  INFO  - 調用GC。。。
15:01:17.847  [Finalizer]  INFO  - finalize-------------Subscriber對象被銷燬
複製代碼

從結果上看,若是Subscriber類若是有活躍的線程在運行,它是不會被回收的;等線程運行完以後,再次調用GC,纔會被回收掉。不過,先不要急着下結論,咱們再測試一下別的狀況。

二、測試二

此次,咱們先建立一個線程類Thread1

它的run方法,咱們跟上面保持一致。而後在Subscriber對象中經過線程調用它。

流程和測試1同樣,此次的結果輸出以下:

14:59:20.193  [main]  INFO  - Subscriber已啓動...
14:59:20.194  [Thread-9]  INFO  - 執行次數:0
14:59:20.359  [Finalizer]  INFO  - finalize-------------Subscriber對象被銷燬
14:59:20.444  [main]  INFO  - Initializing ExecutorService 'applicationTaskExecutor'
14:59:20.699  [main]  INFO  - Starting ProtocolHandler ["http-nio-8081"]
14:59:20.745  [main]  INFO  - Tomcat started on port(s): 8081 (http) with context path ''
14:59:20.751  [main]  INFO  - Started BootMybatisApplication in 3.453 seconds
14:59:21.197  [Thread-9]  INFO  - 執行次數:1
14:59:22.198  [Thread-9]  INFO  - 執行次數:2
14:59:23.198  [Thread-9]  INFO  - 執行次數:3
14:59:24.198  [Thread-9]  INFO  - 執行次數:4
14:59:25.198  [Thread-9]  INFO  - 執行次數:5
14:59:26.198  [Thread-9]  INFO  - 執行次數:6
14:59:27.198  [Thread-9]  INFO  - 執行次數:7
14:59:28.199  [Thread-9]  INFO  - 執行次數:8
14:59:29.199  [Thread-9]  INFO  - 執行次數:9
複製代碼

從結果上看,Subscriber建立以後就由於ConsumerStarter.init()方法執行完畢,而被銷燬了,絲毫沒有受線程的影響。

咦,這就有意思了。從邏輯上看,兩個測試都是經過new Thread()開啓了新的線程,爲啥結果卻不同呢?

立即就在微信聯繫了芋道源碼大神,大神果真老道,他一眼指出:你這個應該是內部類的問題。

三、匿名內部類

咱們把目光回到測試1的代碼中。

若是在方法中,這樣建立一個線程,實際上建立了一個匿名內部類。可是,它有什麼特殊之處嗎?居然會阻止對象會正常回收。

咱們知道在內部類編譯成功後,它會產生一個class文件,該class文件與外部類並非同一class文件。在上面的代碼中,編譯後就產生一個class文件叫作:Subscriber$1.class

經過反編譯軟件,咱們能夠獲得這個class文件的內容以下:

看到這裏,就已經很明確了。這個內部類雖然與外部類不是同一個class文件,可是它保留了對外部類的引用,就是這個Subscriber this$0

只要內部類的方法執行不完,就會還保留外部類的引用實例,因此外部類就不會被GC回收。

因此,再回到一開始的問題:

若是一個對象A已不被引用,符合GC條件;可是它裏面還有一個線程對象a1在活躍,那麼此時這個對象A還會被回收嗎?

答案也是確定的,除非線程對象a1還在引用A對象。

3、匿名內部類與垃圾回收

第二部分中,咱們看的是匿名內部類中開啓線程和垃圾回收的關係,咱們再看看與線程無關的狀況。

在乎識到是由於匿名內部類致使垃圾回收的問題時,在網上找到一篇文章。說的也是匿名內部類垃圾回收的問題,原文連接: 匿名內部類相關的gc

裏面的代碼筆者也測試過了,確實也如原文做者所說:

從結果推測,匿名內部類會關聯對象,內部類對象不回收,致使主對象沒法回收;

但實際上,這並非匿名內部類的問題,或者說不徹底是它的問題,而是static修飾符的問題。

一、測試一

使用匿名內部類咱們必需要繼承一個父類或者實現一個接口,因此咱們隨便建立一個接口。

public interface TestInterface {
    void out();
}
複製代碼

而後在Subscriber外部類中使用它。

而後啓動服務,輸出結果以下:

16:37:53.626  [main]  INFO  - Root WebApplicationContext: initialization completed in 1867 ms
16:37:53.710  [main]  INFO  - Subscriber已啓動...
16:37:53.711  [main]  INFO  - 匿名內部類測試...
16:37:53.866  [Finalizer]  INFO  - finalize-------------Subscriber對象被銷燬
16:37:53.950  [main]  INFO  - Initializing ExecutorService 'applicationTaskExecutor'
16:37:54.180  [main]  INFO  - Starting ProtocolHandler ["http-nio-8081"]
16:37:54.224  [main]  INFO  - Tomcat started on port(s): 8081 (http) with context path ''
複製代碼

從結果來看,Subscriber對象被建立,使用完以後,不須要手動調用GC,就被銷燬了。

若是將interface1變量定義成靜態的呢?static TestInterface interface1;

輸出結果以下:

16:43:20.331  [main]  INFO  - Root WebApplicationContext: initialization completed in 1826 ms
16:43:20.404  [main]  INFO  - Subscriber已啓動...
16:43:20.405  [main]  INFO  - 匿名內部類測試...
16:43:20.673  [main]  INFO  - Initializing ExecutorService 'applicationTaskExecutor'
16:43:20.955  [main]  INFO  - Starting ProtocolHandler ["http-nio-8081"]
16:43:21.002  [main]  INFO  - Tomcat started on port(s): 8081 (http) with context path ''
16:43:21.007  [main]  INFO  - Started BootMybatisApplication in 3.327 seconds
16:43:33.095  [http-nio-8081-exec-1]  INFO  - Initializing Servlet 'dispatcherServlet'
16:43:33.102  [http-nio-8081-exec-1]  INFO  - Completed initialization in 7 ms
16:43:33.140  [http-nio-8081-exec-1]  INFO  - 調用GC。。。
16:43:35.763  [http-nio-8081-exec-2]  INFO  - 調用GC。。。
複製代碼

若是把interface1定義成靜態的,再怎麼調用GCSubscriber都不會被回收掉。

怎麼辦呢?能夠在interface1使用完以後,把它從新賦值成空。interface1=null;,這時候,Subscriber類又會像測試1裏面那樣,被正常回收掉。

不過,這又是爲何呢?Java的垃圾回收是依靠GC Roots開始向下搜索的,當一個對象到GC Roots沒有任何引用鏈相連時,則證實此對象是不可用的;那麼反之,就是可用的,不可回收的。

GC Roots包含如下對象:

  • 虛擬機棧中引用的對象
  • 方法區中類靜態屬性引用的對象
  • 方法區中常量引用的對象
  • 本地方法棧中JNI[即通常說的Native]引用的對象

也就是說,若是咱們把匿名內部類interface1定義成靜態的對象,它就會看成GC Root對象存在,此時,這個內部類裏面又保持着外部類Subscriber的引用,因此遲遲不能被回收。

二、測試二

爲何說,這並非匿名內部類的問題,或者說不徹底是它的問題,而是static修飾符的問題,咱們能夠再作一個測試。

關於匿名內部類,咱們先無論它是如何產生的,也不理會又是怎麼用的,咱們只記住它的特性之一:

它會保持外部類的引用。

因此,咱們先摒棄什麼勞什子內部類,就建立一個普通的類,讓它保持調用類的引用。

而後在外部類Subscriber中,建立它的實例,並調用:

最後啓動服務,輸出結果以下:

17:30:02.331  [main]  INFO  - Root WebApplicationContext: initialization completed in 1726 ms
17:30:02.443  [main]  INFO  - Subscriber已啓動...
com.viewscenes.netsupervisor.controller.test.Subscriber@7ea899a9
17:30:02.447  [main]  INFO  - User對象輸出信息....
com.viewscenes.netsupervisor.controller.test.Subscriber@7ea899a9
17:30:02.605  [Finalizer]  INFO  - finalize-------------Subscriber對象被銷燬
17:30:02.606  [Finalizer]  INFO  - finalize-------------User對象被銷燬
17:30:02.676  [main]  INFO  - Initializing ExecutorService 'applicationTaskExecutor'
17:30:02.880  [main]  INFO  - Starting ProtocolHandler ["http-nio-8081"]
複製代碼

這種狀況下,都是會正常被回收的。

但若是這時候再把user對象定義成靜態屬性,無論怎麼GC,仍是不能回收。

由此看來,能夠得出這樣一個結論:

  • 匿名內部類並不會妨礙外部類的正常GC,而是不能將它定義成靜態屬性引用。
  • 靜態匿名內部類,致使外部類不能正常回收的緣由就是:它做爲GC Root對象卻保持着外部類的引用。

4、總結

本文經過一個小Bug,引起了內存模型和垃圾回收的問題。由於基礎理論知識還不過硬,只能經過不一樣的測試,來解釋整個事件的緣由。

另外,雖然Java幫咱們自動回收垃圾,但你們寫代碼的時候注意不要引起內存泄露哦。

相關文章
相關標籤/搜索