Java的Finalizer引發的記憶體溢位

語言: CN / TW / HK

小知識,大挑戰!本文正在參與“程式設計師必備小知識”創作活動。

本文介紹的是Java裡一個內建的概念,Finalizer。你可能對它對數家珍,但也可能從未聽聞過,這得看你有沒有花時間完整地看過一遍java.lang.Object類了。在java.lang.Object裡面就有一個finalize()的方法。這個方法的實現是空的,不過一旦實現了這個方法,就會觸發JVM的內部行為,威力和危險並存。

如果JVM發現某個類實現了finalize()方法的話,那麼見證奇蹟的時刻到了。我們先來建立一個實現了這個非凡的finalize()方法的類,然後看下這種情況下JVM的處理會有什麼不同。我們先從一個簡單的示例程式開始:

``` import java.util.concurrent.atomic.AtomicInteger;

class Finalizable { static AtomicInteger aliveCount = new AtomicInteger(0);

  Finalizable() {
        aliveCount.incrementAndGet();
 }

 @Override
 protected void finalize() throws Throwable {
              Finalizable.aliveCount.decrementAndGet();
 }

  public static void main(String args[]) {
        for (int i = 0;; i++) {
              Finalizable f = new Finalizable();
              if ((i % 100_000) == 0) {
                    System.out.format("After creating %d objects, %d are still alive.%n", new Object[] {i, Finalizable.aliveCount.get() });
                }
      }
 }

} ```

這個程式使用了一個無限迴圈來建立物件。它同時還用了一個靜態變數aliveCount來跟蹤一共建立了多少個例項。每建立了一個新物件,計數器會加1,一旦GC完成後呼叫了finalize()方法,計數器會跟著減1。

你覺得這小段程式碼的輸出結果會是怎樣的呢?由於新建立的物件很快就沒人引用了,它們馬上就可以被GC回收掉。因此你可能會認為這段程式可以不停的執行下去,:

After creating 345,000,000 objects, 0 are still alive. After creating 345,100,000 objects, 0 are still alive. After creating 345,200,000 objects, 0 are still alive. After creating 345,300,000 objects, 0 are still alive.

顯然結果並非如此。現實的結果完全不同,在我的Mac OS X的JDK 1.7.0_51上,程式大概在建立了120萬個物件後就丟擲java.lang.OutOfMemoryError: GC overhead limitt exceeded異常退出了。

After creating 900,000 objects, 791,361 are still alive. After creating 1,000,000 objects, 875,624 are still alive. After creating 1,100,000 objects, 959,024 are still alive. After creating 1,200,000 objects, 1,040,909 are still alive. Exception in thread "main" java.lang.OutOfMemoryError: GC overhead limit exceeded at java.lang.ref.Finalizer.register(Finalizer.java:90) at java.lang.Object.(Object.java:37) at eu.plumbr.demo.Finalizable.(Finalizable.java:8) at eu.plumbr.demo.Finalizable.main(Finalizable.java:19)

垃圾回收的行為

想弄清楚到底發生了什麼,你得看下這段程式在執行時的狀況如何。我們來開啟-XX:+PrintGCDetails選項再執行一次看看: [GC [PSYoungGen: 16896K->2544K(19456K)] 16896K->16832K(62976K), 0.0857640 secs] [Times: user=0.22 sys=0.02, real=0.09 secs]\ [GC [PSYoungGen: 19440K->2560K(19456K)] 33728K->31392K(62976K), 0.0489700 secs] [Times: user=0.14 sys=0.01, real=0.05 secs]\ [GC-- [PSYoungGen: 19456K->19456K(19456K)] 48288K->62976K(62976K), 0.0601190 secs] [Times: user=0.16 sys=0.01, real=0.06 secs]\ [Full GC [PSYoungGen: 16896K->14845K(19456K)] [ParOldGen: 43182K->43363K(43520K)] 60078K->58209K(62976K) [PSPermGen: 2567K->2567K(21504K)], 0.4954480 secs] [Times: user=1.76 sys=0.01, real=0.50 secs]\ [Full GC [PSYoungGen: 16896K->16820K(19456K)] [ParOldGen: 43361K->43361K(43520K)] 60257K->60181K(62976K) [PSPermGen: 2567K->2567K(21504K)], 0.1379550 secs] [Times: user=0.47 sys=0.01, real=0.14 secs]\ --- cut for brevity---\ [Full GC [PSYoungGen: 16896K->16893K(19456K)] [ParOldGen: 43351K->43351K(43520K)] 60247K->60244K(62976K) [PSPermGen: 2567K->2567K(21504K)], 0.1231240 secs] [Times: user=0.45 sys=0.00, real=0.13 secs]\ [Full GCException in thread "main" java.lang.OutOfMemoryError: GC overhead limit exceeded\ [PSYoungGen: 16896K->16866K(19456K)] [ParOldGen: 43351K->43351K(43520K)] 60247K->60218K(62976K) [PSPermGen: 2591K->2591K(21504K)], 0.1301790 secs] [Times: user=0.44 sys=0.00, real=0.13 secs]\ at eu.plumbr.demo.Finalizable.main(Finalizable.java:19) 從日誌中可以看到,少數幾次的Eden區的新生代GC過後,JVM開始採用更昂貴的Full GC來清理老生代和持久代的空間。為什麼會這樣?既然已經沒有人引用這些物件了,為什麼它們沒有在新生代中被回收掉?程式碼這麼寫有什麼問題嗎?

要弄清楚GC這個行為的原因,我們先來對程式碼做一個小的改動,將finalize()方法的實現先去掉。現在JVM發現這個類沒有實現finalize()方法了,於是它切換回了”正常”的模式。再看一眼GC的日誌,你只能看到一些廉價的新生代GC在不停的執行。

Java堆分割槽

因為修改後的這段程式中,的確沒有人引用到了新生代的這些剛建立的物件。因此Eden區很快就被清空掉了,整個程式可以一直的執行下去。

另一方面,在早先的那個例子中情況則有些不同。這些物件並非沒人引用 ,JVM會為每一個Finalizable物件建立一個看門狗(watchdog)。這是Finalizer類的一個例項。而所有的這些看門狗又會為Finalizer類所引用。由於存在這麼一個引用鏈,因此整個的這些物件都是存活的。

那現在Eden區已經滿了,而所有物件又都存在引用,GC沒轍了只能把它們全拷貝到Suvivor區。更糟糕的是,一旦連Survivor區也滿了,只能存到老生代裡面了。你應該還記得,Eden區使用的是一種”拋棄一切”的清理策略,而老生代的GC則完全不同,它採用的是一種開銷更大的方式。

Finalizer佇列

只有在GC完成後,JVM才會意識到除了Finalizer物件已經沒有人引用到我們建立的這些例項了,因此它才會把指向這些物件的Finalizer物件標記成可處理的。GC內部會把這些Finalizer物件放到java.lang.ref.Finalizer.ReferenceQueue這個特殊的佇列裡面。

完成了這些麻煩事之後,我們的應用程式才能繼續往下走。這裡有個執行緒你一定會很感興趣——Finalizer守護執行緒。通過使用jstack進行thread dump可以看到這個執行緒的資訊。

``` My Precious:~ demo$ jps 1703 Jps 1702 Finalizable My Precious:~ demo$ jstack 1702

--- cut for brevity --- "Finalizer" daemon prio=5 tid=0x00007fe33b029000 nid=0x3103 runnable [0x0000000111fd4000] java.lang.Thread.State: RUNNABLE at java.lang.ref.Finalizer.invokeFinalizeMethod(Native Method) at java.lang.ref.Finalizer.runFinalizer(Finalizer.java:101) at java.lang.ref.Finalizer.access$100(Finalizer.java:32) at java.lang.ref.Finalizer$FinalizerThread.run(Finalizer.java:190) --- cut for brevity — ```

從上面可以看到有一個Finalizer守護執行緒正在執行。Finalizer執行緒是個單一職責的執行緒。這個執行緒會不停的迴圈等待java.lang.ref.Finalizer.ReferenceQueue中的新增物件。一旦Finalizer執行緒發現佇列中出現了新的物件,它會彈出該物件,呼叫它的finalize()方法,將該引用從Finalizer類中移除,因此下次GC再執行的時候,這個Finalizer例項以及它引用的那個物件就可以回垃圾回收掉了。

現在我們有兩個執行緒都在不停地迴圈。我們的主執行緒在忙著建立新物件。這些物件都有各自的看門狗也就是Finalizer,而這個Finalizer物件會被新增到一個java.lang.ref.Finalizer.ReferenceQueue中。Finalizer執行緒會負責處理這個佇列,它將所有的物件彈出,然後呼叫它們的finalize()方法。

很多時候你可能磁不到記憶體溢位這種情況。finalize()方法的呼叫會比你建立新物件要早得多。因此大多數時候,Finalizer執行緒能夠趕在下次GC帶來更多的Finalizer物件前清空這個佇列。但我們這個例子當中,顯然不是這樣。

為什麼會出現溢位?因為Finalizer執行緒和主執行緒相比它的優先順序要低。這意味著分配給它的CPU時間更少,因此它的處理速度沒法趕上新物件建立的速度。這就是問題的根源——物件建立的速度要比Finalizer執行緒呼叫finalize()結束它們的速度要快,這導致最後堆中所有可用的空間都被耗盡了。結果就是——我們親愛的小夥伴java.lang.OutOfMemoryError會以不同的身份出現在你面前。

如果你仍然不相信我的話,dump一下堆記憶體,看下它裡面有什麼。比如說,你可以使用-XX:+HeapDumpOnOutOfMemoryError引數啟動我們這個小程式,在我的Eclipse中的MAT Dominator Tree中我看到的是下面這張圖:

看到了吧,我這個64M的堆全給Finalizer物件給佔滿了。

結論

回顧一下,Finalizable物件的生命週期和普通物件的行為是完全不同的,列舉如下:

  • JVM建立Finalizable物件
  • JVM建立 java.lang.ref.Finalizer例項,指向剛建立的物件。
  • java.lang.ref.Finalizer類持有新建立的java.lang.ref.Finalizer的例項。這使得下一次新生代GC無法回收這些物件。
  • 新生代GC無法清空Eden區,因此會將這些物件移到Survivor區或者老生代。
  • 垃圾回收器發現這些物件實現了finalize()方法。因為會把它們新增到java.lang.ref.Finalizer.ReferenceQueue佇列中。
  • Finalizer執行緒會處理這個佇列,將裡面的物件逐個彈出,並呼叫它們的finalize()方法。
  • finalize()方法呼叫完後,Finalizer執行緒會將引用從Finalizer類中去掉,因此在下一輪GC中,這些物件就可以被回收了。
  • Finalizer執行緒會和我們的主執行緒進行競爭,不過由於它的優先順序較低,獲取到的CPU時間較少,因此它永遠也趕不上主執行緒的步伐。
  • 程式消耗了所有的可用資源,最後丟擲OutOfMemoryError異常。