JVM的自愈能力

在IT行業,碰到問題的第一個反應通常是——“你重啓過沒”——而這樣做可能會適得其反,本文要講述的就是這樣的一個場景。

接下來要介紹的這個應用,它不僅不需要重啓,而且毫不誇張地說,它能夠自我治癒:剛開始運行的時候它可能會碰到些挫折,但會漸入佳境。爲了能實際地展示出它的自愈能力,我們儘可能簡單地重現了這一場景,這個靈感還得歸功於五年前heinz Kabutz發表的一篇老文章:

package eu.plumbr.test;

public class HealMe {
  private static final int SIZE = (int) (Runtime.getRuntime().maxMemory() * 0.6);

  public static void main(String[] args) throws Exception {
    for (int i = 0; i < 1000; i++) {
      allocateMemory(i);
    }
  }

  private static void allocateMemory(int i) {
    try {
      {
        byte[] bytes = new byte[SIZE];
        System.out.println(bytes.length);
      }

      byte[] moreBytes = new byte[SIZE];
      System.out.println(moreBytes.length);

      System.out.println("I allocated memory successfully " + i);

    } catch (OutOfMemoryError e) {
      System.out.println("I failed to allocate memory " + i);
    }
  }
}

上述代碼會循環地分配兩塊內存。每次分配的內存都是堆中總內存的60%。由於在同一個方法內會不停地進行這個內存分配,因此你可能會認爲這段代碼會不斷地拋出 java.lang.OutOfMemoryError: Java heap space異常,永遠無法正常地執行完allocateMemory方法。

我們先來對源代碼進行下靜態分析,看看這種猜測是否恰當:

  1. 乍看一下這段程序的話,這確實是無法成功執行的,因爲要分配的內存已經超出了JVM的限制。
  2. 但再仔細分析下的話我們會發現第一次分配是在一個塊作用域內完成的,也就是說這個塊中定義的變量僅對塊內可見。這意味着這些內存在這個代碼塊執行完成後便可以回收掉了。這段代碼一開始應該是可以成功執行的,只是當它再去嘗試分配moreBytes的時候纔會掛掉。
  3. 如果再查看下編譯後的class文件的話,你會看到如下的字節碼:
private static void allocateMemory(int);
    Code:
       0: getstatic     #3                  // Field SIZE:I
       3: newarray       byte
       5: astore_1      
       6: getstatic     #4                  // Field java/lang/System.out:Ljava/io/PrintStream;
       9: aload_1       
      10: arraylength   
      11: invokevirtual #5                  // Method java/io/PrintStream.println:(I)V
      14: getstatic     #3                  // Field SIZE:I
      17: newarray       byte
      19: astore_1      
      20: getstatic     #4                  // Field java/lang/System.out:Ljava/io/PrintStream;
      23: aload_1       
      24: arraylength   
      25: invokevirtual #5                  // Method java/io/PrintStream.println:(I)V
---- cut for brevity ----

從中能夠看出,第一個數組是在位置3~5處完成分配的,並存儲到了序號爲1的本地變量中。隨後在位置17處,正要分配另一個數組。不過由於第一個數組仍被本地變量所引用着,因此第二次分配總會拋出OOM的異常而失敗。字節碼解釋器不會允許GC去回收第一個數組,因爲它仍然存在着一個強引用。

從靜態代碼分析中可看出,由於底層的兩個約束,上述的代碼是無法成功執行的,而在第一種情況下則是能夠運行的。這三點分析裏面哪個纔是正確的呢?我們來實際運行下看看結果吧。結果表明,這些結論都是正確的。首先,應用程序的確無法分配內存。但是,經過一段時間之後(在我的Mac OS X上使用Java 8大概是出現在第255次迭代中),內存分配開始能夠成功執行了:

 java -Xmx2g eu.plumbr.test.HealMe
1145359564
I failed to allocate memory 0
1145359564
I failed to allocate memory 1

 cut for brevity ...

I failed to allocate memory 254
1145359564
I failed to allocate memory 255
1145359564
1145359564
I allocated memory successfully 256
1145359564
1145359564
I allocated memory successfully 257
1145359564
1145359564
Self-healing code is a reality! Skynet is near...

爲了搞清楚究竟發生了什麼,我們得思考一下,在程序運行期間發生了什麼變化?顯然,Just-In-Time編譯開始介入了。如果你還記得的話,JIT編譯是JVM的一個內建機制,它可以優化熱點代碼。JIT會監控運行的代碼,如果發現了一個熱點,它會將你的字節碼轉化成本地代碼,同時會執行一些額外的優化,譬如方法內聯以及無用代碼擦除。

我們打開下面的命令行參數重啓下程序,看看是否觸發了JIT編譯。

-XX:+UnlockDiagnosticVMOptions -XX:+PrintAssembly -XX:+LogCompilation

這會生成一個日誌文件,在我這裏是一個hotspot_pid38139.log文件,38139是Java進程的PID。在該文件中可以找到這麼一行:

<task_queued compile_id='94' method='HealMe allocateMemory (I)V' bytes='83' count='256' iicount='256' level='3' stamp='112.305' comment='tiered' hot_count='256'/>

這說明,在運行了256次allocateMemory()方法2之後,C1編譯器決定將這個方法進行3級編譯。看下這裏可以瞭解下分層編譯的各個級別以及不同的閾值。在前面的256次迭代中這段程序都是在解釋模式下運行的,這裏的字節碼解釋器就是一個簡單堆棧機器,它無法提前預知某個變量後續是否會被用到,在這裏對應的是變量bytes。但是JIT會一次性查看整個方法,因此它能推斷出後面不會再用到bytes變量,可以對它進行GC。所以纔會觸發垃圾回收,因此我們的程序才能奇蹟般地自愈。我只是希望本文的讀者都不要在生產環境碰到調試這類問題的情況。不過如果你想讓某人抓狂的話,倒是可以試試在生產環境中加下類似的代碼。

原創文章轉載請註明出處:JVM的自愈能力

英文原文鏈接

發佈了96 篇原創文章 · 獲贊 14 · 訪問量 75萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章