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方法。
我們先來對源代碼進行下靜態分析,看看這種猜測是否恰當:
- 乍看一下這段程序的話,這確實是無法成功執行的,因為要分配的內存已經超出了JVM的限制。
- 但再仔細分析下的話我們會發現第一次分配是在一個塊作用域內完成的,也就是說這個塊中定義的變量僅對塊內可見。這意味著這些內存在這個代碼塊執行完成后便可以回收掉了。這段代碼一開始應該是可以成功執行的,只是當它再去嘗試分配moreBytes的時候才會掛掉。
- 如果再查看下編譯后的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的自愈能力