JVM讀書筆記之OOM

木瓜芒果發表於2018-09-11

  在Java虛擬機器規範的描述中,除了程式計數器外,虛擬機器記憶體的其他幾個執行時區域都有發生OutOfMemoryError(OOM)異常的可能,本文總結了若干例項來驗證異常及發生的場景。

  下文程式碼的開頭都註釋了執行時所需要設定的虛擬機器啟動引數(註釋中VM Args後面跟著的引數),如果使用控制檯命令來執行程式,那直接跟在Java命令之後書寫就可以。如果使用Eclipse IDE,則可以參考下圖在Debug/Run頁籤中設定。本文的程式碼都是基於Sun公司的HotSpot虛擬機器執行的,對於不同公司的不同版本的虛擬機器,引數和程式執行的結果可能會有所差異。

1.Java堆溢位

  Java堆用於儲存物件例項,只要不斷建立物件,並且保證GC Roots到物件之間有可達路徑來避免垃圾回收機制清除這些物件,那麼在物件資料量到達最大堆的容量限制後就會產生記憶體溢位異常。如下程式碼清單中,限制Java堆的大小為20MB,不可擴充套件(將堆的最小值-Xms引數與最大值-Xmx引數設定為一樣即可避免堆自動擴充套件),通過引數-XX:+HeapDumpOnOutOfMemoryError可以讓虛擬機器在出現記憶體溢位異常時Dump出當前記憶體堆轉儲快照以便事後分析。

//程式碼清單1-1,Java堆記憶體溢位異常測試
/**
* 虛擬機器引數: -Xms20m -Xmx20m -XX:+HeapDumpOnOutOfMemoryError
*/

public
class HeapOOM{ static class OOMObject{} List<OOMObject> list = new ArrayList<OOMObject>(); while(true) { list.add(new OOMObject()); } }
執行結果:
java.lang.OutOfMemoryError: Java heap space Dumping heap to java_pid10544.hprof ... Heap dump file created [
28023233 bytes in 0.083 secs]

  Java堆記憶體的OOM異常是實際應用中常見的記憶體溢位異常情況。當出現Java堆記憶體溢位時,異常堆疊資訊“java.lang.OutOfMemoryError”會跟著進一步提示"Java heap space"。

  要解決這個區域的異常,一般的手段是先通過記憶體映像分析工具(如Eclipse Memory Analyzer)對Dump出來的堆轉儲快照進行分析,重點是確認記憶體中的物件是否是必要的,也就是要先分清楚到底是出現了記憶體洩露(Memory Leak)還是記憶體溢位(Memory Overflow)。

  如果是記憶體洩漏,可進一步通過工具檢視洩漏物件到GC Roots的引用鏈。於是就能找到洩漏物件是通過怎樣的路徑與GC Roots相關聯並導致垃圾收集器無法自動回收它們的。掌握了洩漏物件的型別資訊及GC Roots引用鏈資訊,就可以比較準確地定位出洩漏程式碼的位置。

  如果不存在洩漏,換句話說,就是記憶體中的物件確實都還必須存活著,那就應當檢查虛擬機器的堆引數(-Xmx與-Xms),與機器實體記憶體對比看是否還可以調大,從程式碼上檢查是否存在某些物件生命週期過長、持有狀態時間過長的情況,嘗試減少程式執行期的記憶體消耗。

  以上是處理Java堆記憶體問題的簡單思路,處理這些問題需要實戰經驗的積累。

2.虛擬機器棧和本地方法棧溢位

  關於虛擬機器棧和本地方法棧,在Java虛擬機器規範中描述了兩種異常:

  • 如果執行緒請求的棧深度大於虛擬機器所允許的最大深度,將丟擲StackOverflowError異常。
  • 如果虛擬機器在擴充套件棧時無法申請到足夠的記憶體空間,則丟擲OutOfMemoryError異常。

  測試程式碼如下:

/**
* 程式碼清單2-1,虛擬機器引數: -Xss128k
*/
public
class QuickTest { private int stackLength = 1; public static void main(String[] args) throws Throwable{ QuickTest qu = new QuickTest(); try { qu.stackLeak(); }catch(Throwable e) { System.out.println("stack length:" + qu.stackLength); throw e; } } public void stackLeak() { stackLength++; stackLeak(); } }
執行結果:
Exception in thread "main" stack length:992
java.lang.StackOverflowError
    at sort.QuickTest.stackLeak(QuickTest.java:78)
    at sort.QuickTest.stackLeak(QuickTest.java:79)

  如上實驗是在單執行緒下測試,表明當記憶體無法分配的時候,虛擬機器丟擲的都是StackOverflowError異常。如果測試時不限於單執行緒,通過不斷地建立執行緒的方式倒是可以產生記憶體溢位異常,如下程式碼所示。但是這樣產生的記憶體溢位異常就和棧空間是否足夠大並不存在任何聯絡,或者說,為每個執行緒的棧分配的記憶體越大,反而越容易產生記憶體溢位異常。

  其實原因不難理解,作業系統分配給每個程式的記憶體是有限制的,譬如32位的Windows限制為2GB。虛擬機器提供了引數來控制Java堆和方法區的這兩部分記憶體的最大值。剩餘的記憶體為2GB(作業系統限制)減去Xmx(最大堆容量),再減去MaxPermSize(最大方法區容量),程式計數器消耗記憶體很小,可以忽略掉。如果虛擬機器程式本身耗費的記憶體不計算在內,剩下的記憶體就由虛擬機器棧和本地方法棧"瓜分"了。每個執行緒分配到的棧容量越大,可以建立的執行緒數量自然就越少,建立執行緒時就越容易把剩下的記憶體耗盡。

  這一點在開發多執行緒的應用時特別注意,出現StackOverflowError異常時有錯誤堆疊可以閱讀,相對來說,比較容易找到問題所在。而且,如果使用虛擬機器預設引數,棧深度在大多數情況下(因為每個方法壓入棧的幀大小並不是一樣的,所以只能說在大多數情況下)達到1000-2000完全沒有問題,對於正常的方法呼叫(包括遞迴),這個深度應該完全夠用了。但是,如果是建立過多執行緒導致的記憶體溢位,在不能減少執行緒數或者更換64為虛擬機器的情況下,就只能通過減少最大堆和減少棧容量來換取更多的執行緒。如果沒有這方面的處理經驗,這種通過“減少記憶體”的手段來解決記憶體溢位的方式會比較難以想到。

3.執行時常量池溢位

  如果要向執行時常量池中新增內容,最簡單的做法就是使用 string.intern( )這個Native方法。該方法的作用是:如果池中已經包含一個等於此 String 物件的字串,則返回代表池中這個字串的 string 物件;否廁,將此 string 物件包含的字串新增到常最池中,並且返回此 string 物件的引用。由於常量池分配在方法區內,我們可以通過-XX:PermSize和-XX:MaxPermSize限制方法區的大小,從而間接限制其中常量池的容最,如程式碼清單3-1所示。

/**
* 程式碼清單3-1,VM Args: -XX:PermSize=10M -XX:MaxPermSize=10M
*/
public
static void main(String[] args){ List<String> list = new ArrayList<String>(); int i = 0; while(true) { System.out.println("hello"); list.add(String.valueOf(i++).intern()); } }

  對於如上的程式碼,書中的結果是:

Exception in thread "main" java.lang.OutOfMemoryError:PerGen space
at java.lang.String.intern(Native Method)
at org.fenixsoft.oom.RuntimeConstantPoolOOM.main(RuntimeConstantPoolOOM.java:18)

  解釋是:從執行結果中可以看到,執行時常量池溢位,在OutOfMemoryError後面跟隨的提示資訊是"PermGen space",說明執行時常量池屬於方法區(HotSpot虛擬機器中的永久代)的一部分。

  可是我自己同樣的程式碼在自己電腦上實驗的結果卻是---》死迴圈O__O",這是什麼情況,想一想,問題應該是出在環境上,我的JDK是1.8,作者當時的環境可能是低版本的JDK(1.6),而java6中,JVM字串常量值使用了固定大小的記憶體區域(PermGen),java7和8字串常量池在堆記憶體中,問題應是出在這裡,通過調整虛擬機器引數-Xms10m -Xmx10m,然後報OOM了,顯示是堆記憶體溢位了,說明分析正確,結果如下。

Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
    at java.io.PrintStream.write(PrintStream.java:530)
    at java.io.PrintStream.print(PrintStream.java:669)
    at java.io.PrintStream.println(PrintStream.java:806)
    at testPackage.Test.main(Test.java:89)

 4.方法區溢位

  方法區用於存放 Class 的相關資訊,如類名、訪問修飾符、常量池、欄位描述、方法描述等。對於這個區域的測試,基本的思路是執行時產生大量的類去填滿方法區,直到溢位。雖然直接使用 Java SE API 也可以動態產生類(如反射時的 GeneratedconstructorAccessor 和動態代理等),但操作起來比較麻煩。在程式碼清單4-1中,藉助 CGLib 。直接操作位元組碼執行時,生成了大量的動態類。

/**
* 程式碼清單4-1,VM Args:-XX:PermSize=10M -XX:MaxPermSize=10M
*/
public
class Test{ public static void main(String[] args) { while(true) { System.out.println("hello"); Enhancer en = new Enhancer(); en.setSuperclass(OOMObject.class); en.setUseCache(false); en.setCallback(new MethodInterceptor(){ public Object intercept(Object obj,Method method,Object[] args,MethodProxy proxy)throws Throwable{ return proxy.invokeSuper(obj,args); } }); en.create(); } } static class OOMObject{}; }

  方法區溢位也是一種常見的記憶體溢位異常,一個類如果要被垃圾收集器回收掉,判定條件是非常苛刻的。在經常動態生成大量 class 的應用中,需要特別注意類的回收狀況。這類場景除了上面提到的程式使用了 GCLib 位元組碼增強外,常見的還有:大量 JSP 或動態產生 JSP 檔案的應用( JSP 第一次執行時需要編譯為 Java 類)、基於 OSGi 的應用(即使是同一個類檔案,被不同的載入器載入也會視為不同的類)等。

5.總結

  總結到這裡,明白了在一些基本場景中什麼樣的程式碼和操作可能導致記憶體溢位異常,雖然Java有垃圾回收機制,但記憶體溢位異常離我們其實並不遙遠,本文也只是學習了一些導致記憶體溢位異常的常見操作,並未對出涉及到Java的垃圾收集機制,後續文章將總結Java為了避免記憶體溢位異常的出現做了哪些努力。

 

相關文章