[深入理解Java虛擬機器]第二章 實戰 :OutOfMemoryError異常

Coding-lover發表於2015-10-04

在Java虛擬機器規範的描述中,除了程式計數器外,虛擬機器記憶體的其他幾個執行時區域都有發生OutOfMemoryError(下文稱00M )異常的可能,本節將通過若干例項來驗證異常發生的場景(程式碼清單2-3〜程式碼清單2-9的幾段簡單程式碼),並且會初步介紹幾個與記憶體相關的最基本的虛擬機器引數。

本節內容的目的有兩個:第一 ,通過程式碼驗證Java虛擬機器規範中描述的各個執行時區域儲存的內容 ;第二 ,希望讀者在工作中遇到實際的記憶體溢位異常時 ,能根據異 常的資訊快速判斷是哪個區域的記憶體溢位,知道什麼樣的程式碼可能會導致這些區域記憶體溢位,以及出現這些異常後該如何處理。

下文程式碼的開頭都註釋了執行時所需要設定的虛擬機器啟動引數(註釋中“VM Args”後面跟著的引數),這些引數對實驗的結果有直接影響,讀者除錯程式碼的時候千萬不要忽略。如果讀者使用控制檯命令來執行程式,那直接跟在Java命令之後書寫就可以。如果讀者使用 EclipselDE,則可以參考圖2-4在Debug/Run頁籤中的設定。

下文的程式碼都是基於Sun公司的HotSpot虛擬機器執行的,對於不同公司的不同版本的虛擬機器 ,引數和程式執行的結果可能會有所差別。

Java堆溢位

Java堆用於儲存物件例項,只要不斷地建立物件,並且保證GC Roots到物件之間有可達路徑來避免垃圾回收機制清除這些物件,那麼在物件數量到達最大堆的容量限制後就會產生記憶體溢位異常。

程式碼清單2-3中程式碼限制Java堆的大小為20MB,不可擴充套件(將堆的最小值-Xms引數與最大值-Xmx引數設定為一樣即可避免堆自動擴充套件),通過引數-XX:+HeapDumpOnOutOfMemoryError可以讓虛擬機器在出現記憶體溢位異常時Dump出當前的記憶體堆轉儲快照以便事後進行分析。

程式碼清單2-3 Java堆記憶體溢位異常測試

/**
 * VM Args:-Xms20m -Xmx20m -XX:+HeapDumpOnOutOfMemoryError
 * @author zzm
 */
public class HeapOOM {

    static class OOMObject {
    }

    public static void main(String[] args) {
        List<OOMObject> list = new ArrayList<OOMObject>();

        while (true) {
            list.add(new OOMObject());
        }
    }
}

執行結果:


java.lang.OutOfMemoryError :Java heap space
Dumping heap to java_pid3404.hprof.
Heap dump file created[22045981 bytes in 0.663 secs]

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

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

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

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

以上是處理Java堆記憶體問題的簡單思路,處理這些問題所需要的知識、工具與經驗是後面3章的主題。

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

由於在HotSpot虛擬機器中並不區分虛擬機器棧和本地方法棧,因此 ,對於HotSpot來 說 ,雖然-Xoss引數 (設定本地方法棧大小)存在 ,但實際上是無效的,棧容量只由-Xss引數設定。 關於虛擬機器棧和本地方法棧,在Java虛擬機器規範中描述了兩種異常:

如果執行緒請求的棧深度大於虛擬機器所允許的最大深度,將丟擲StackOverflowError異常。

如果虛擬機器在擴充套件棧時無法申請到足夠的記憶體空間,則丟擲OutOMemoryError異常。

這裡把異常分成兩種情況,看似更加嚴謹,但卻存在著一些互相重疊的地方:當棧空間無法繼續分配時,到底是記憶體太小,還是已使用的棧空間太大,其本質上只是對同一件事情的兩種描述而已。

在筆者的實驗中,將實驗範圍限制於單執行緒中的操作,嘗試了下面兩種方法均無法讓虛擬機器產生OutOfMemoryError異常 ,嘗試的結果都是獲得StackOverflowError異 常 ,測試程式碼如程式碼清單2-4所示。

使用-Xss引數減少棧記憶體容量。結果 :丟擲StackOverflowError異常 ,異常出現時輸出的堆疊深度相應縮小。

定義了大量的本地變數,增大此方法幀中本地變數表的長度。結果 :丟擲 StackOverflowError異常時輸出的堆疊深度相應縮小。

程式碼清單2 - 4 虛擬機器找和本地方法棧OOM測試(僅作為第1點測試程式)

/**
 * VM Args:-Xss128k
 * @author zzm
 */
public class JavaVMStackSOF {

    private int stackLength = 1;

    public void stackLeak() {
        stackLength++;
        stackLeak();
    }

    public static void main(String[] args) throws Throwable {
        JavaVMStackSOF oom = new JavaVMStackSOF();
        try {
            oom.stackLeak();
        } catch (Throwable e) {
            System.out.println("stack length:" + oom.stackLength);
            throw e;
        }
    }
}

執行結果:

stack length :2402
Exception in thread"main"java.lang.StackOverflowError
at org.fenixsoft.oom.VMStackSOF.leak (WIStackSOF.java :20 ) at org.fenixsoft.oom.VMStackSOF.leak (WIStackSOF.java :21 ) at org.fenixsoft.oom.VMStackSOF.leak (WIStackSOF.iava :21 ) 
.....後續異常堆疊資訊省略

實驗結果表明:在單個執行緒下,無論是由於棧幀(一個方法中包含的本地變數數)太大還是虛擬機器棧容量(-Xss引數減少每個執行緒棧記憶體容量)太小,當記憶體無法分配的時候,虛擬機器丟擲的都是StackOverflowError異常。

如果測試時不限於單執行緒,通過不斷地建立執行緒的方式倒是可以產生記憶體溢位異常,如程式碼清單2-5所示。但是這樣產生的記憶體溢位異常與棧空間是否足夠大並不存在任何聯絡,或者準確地說,在這種情況下,為每個執行緒的棧分配的記憶體越大,反而越容易產生記憶體溢位異常。

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

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

程式碼清單2 - 5 建立執行緒導致記憶體溢位異常

/**
 * VM Args:-Xss2M (這時候不妨設大些)
 * @author zzm
 */
public class JavaVMStackOOM {

       private void dontStop() {
              while (true) {
              }
       }

       public void stackLeakByThread() {
              while (true) {
                     Thread thread = new Thread(new Runnable() {
                            @Override
                            public void run() {
                                   dontStop();
                            }
                     });
                     thread.start();
              }
       }

       public static void main(String[] args) throws Throwable {
              JavaVMStackOOM oom = new JavaVMStackOOM();
              oom.stackLeakByThread();
       }
}

注意 特別提示一下 ,如果讀者要嘗試執行上面這段程式碼,記得要先儲存當前的工作。 由於在Windows平臺的虛擬機器中,Java的執行緒是對映到作業系統的核心執行緒上的 ,因此上述程式碼執行時有較大的風險,可能會導致作業系統假死。

執行結果:

Exception in thread"main"java.lang.OutOfMemoryError :unable to create new native thread

方法區和執行時常量池溢位

由於執行時常量池是方法區的一部分,因此這兩個區域的溢位測試就放在一起進行。前面提到JDK 1.7開始逐步“去永久代”的事情,在此就以測試程式碼觀察一下這件事對程式的實際影響。

String.intern() 是一個Native方法,它的作用是:如果字串常量池中已經包含一個等於此String物件的字串,則返回代表池中這個字串的String對 象 ;否則 ,將此String物件包含的字串新增到常量池中,並且返回此String物件的引用。在JDK 1.6及之前的版本中,由於常量池分配在永久代內,我們可以通過-XX : PermSize和-XX : MaxPermSize限制方法區大小 ,從而間接限制其中常量池的容量,如程式碼清單2-6所示。

/**
 * VM Args:-XX:PermSize=10M -XX:MaxPermSize=10M
 * @author zzm
 */
public class RuntimeConstantPoolOOM {

    public static void main(String[] args) {
        // 使用List保持著常量池引用,避免Full GC回收常量池行為
        List<String> list = new ArrayList<String>();
        // 10MB的PermSize在integer範圍內足夠產生OOM了
        int i = 0; 
        while (true) {
            list.add(String.valueOf(i++).intern());
        }
    }
}

執行結果:

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

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

而使用JDK 1.7執行這段程式就不會得到相同的結果,while迴圈將一直進行下去。關於這個字串常量池的實現問題,還可以引申出一個更有意思的影響,如程式碼清單2-7所示。

程式碼清單2-7 String, intern ( ) 返回引用的測試

public class RuntimeConstantPoolOOM {

    public static void main(String[] args) {
        public static void main(String[] args) {
        String str1 = new StringBuilder("中國").append("釣魚島").toString();
        System.out.println(str1.intern() == str1);

        String str2 = new StringBuilder("ja").append("va").toString();
        System.out.println(str2.intern() == str2);
    }   }
}

這段程式碼在JDK 1.6中執行,會得到兩個false,而在JDK 1.7中執行,會得到一個true和一 個false。產生差異的原因是:在JDK 1.6中 , intern ( ) 方法會把首次遇到的字串例項複製到永久代中,返回的也是永久代中這個字串例項的引用,而由StringBuilder建立的字串例項在Java堆上,所以必然不是同一個引用,將返回false。而JDK 1.7 (以及部分其他虛擬機器 ,例如JRockit) 的intern ( ) 實現不會再複製例項,只是在常量池中記錄首次出現的例項引用,因此intern( ) 返回的引用和由StringBuilder()建立的那個字串例項是同一個。對str2比較返回false是因為“java”這個字串在執行StringBuilder.toString ( ) 之前已經出現過,字串 常量池中已經有它的引用了,不符合“ 首次出現” 的原則 ,而“計算機軟體”這個字串則是首次出現的,因此返回true。

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

值得特別注意的是,我們在這個例子中模擬的場景並非純粹是一個實驗,這樣的應用經常會出現在實際應用中:當前的很多主流框架,如Spring、Hibernate ,在對類進行增強時, 都會使用到CGLib這類位元組碼技術,增強的類越多,就需要越大的方法區來保證動態生成的 Class可以載入入記憶體。另外,JVM上的動態語言(例如Groovy等 )通常都會持續建立類來實現語言的動態性,隨著這類語言的流行,也越來越容易遇到與程式碼清單2-8相似的溢位場景。

程式碼清單2 - 8 藉助CGLib使方法區出現記憶體溢位異常

/**
 * VM Args: -XX:PermSize=10M -XX:MaxPermSize=10M
 * @author zzm
 */
public class JavaMethodAreaOOM {

    public static void main(String[] args) {
        while (true) {
            Enhancer enhancer = new Enhancer();
            enhancer.setSuperclass(OOMObject.class);
            enhancer.setUseCache(false);
            enhancer.setCallback(new MethodInterceptor() {
                public Object intercept(Object obj, Method method, Object[] args, MethodProxy proxy) throws Throwable {
                    return proxy.invokeSuper(obj, args);
                }
            });
            enhancer.create();
        }
    }

    static class OOMObject {

    }
}

執行結果:

Caused by :java.lang.OutOfMemoryError :PermGen space
at java.lang.ClassLoader.defineClassl (Native Method)
at java.lang.ClassLoader.defineClassCond (ClassLoader. java :632 ) at java.lang.ClassLoader.defineClass (ClassLoader.java :616 )
— 8 more

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

本機直接記憶體溢位

DirectMemory容量可通過-XX : MaxDirectMemorySize指定,如果不指定,則預設與Java堆最大值(-Xmx指定)一樣 ,程式碼清單2-9越過了DirectByteBuffer類 ,直接通過反射獲取Unsafe例項進行記憶體分配(Unsafe類的getUnsafe ( ) 方法限制了只有引導類載入器才會返回例項,也就是設計者希望只有rt.jar中的類才能使用Unsafe的功能)。因為,雖然使用 DirectByteBuffer分配記憶體也會丟擲記憶體溢由異常,但它丟擲異常時並沒有真正向作業系統申請分配記憶體,而是通過計算得知記憶體無法分配,於是手動丟擲異常,真正申請分配記憶體的方法unsafe.allocateMemory ( ) 。

程式碼清單2 - 9 使用unsafe分配本機記憶體

/**
 * VM Args:-Xmx20M -XX:MaxDirectMemorySize=10M
 * @author zzm
 */
public class DirectMemoryOOM {

    private static final int _1MB = 1024 * 1024;

    public static void main(String[] args) throws Exception {
        Field unsafeField = Unsafe.class.getDeclaredFields()[0];
        unsafeField.setAccessible(true);
        Unsafe unsafe = (Unsafe) unsafeField.get(null);
        while (true) {
            unsafe.allocateMemory(_1MB);
        }
    }
}

執行結果:


Exception in thread"main"java.lang.OutOfMemoryError at sun.misc.Unsafe .allocateMemory (Native Method ) at org. fenixsoft. oom.DMOOM.main (DMOOM.java :20 )

由DirectMemory導致的記憶體溢位,一個明顯的特徵是在Heap Dump檔案中不會看見明顯的異常,如果讀者發現OOM之後Dump檔案很小,而程式中又直接或間接使用了NIO,那就可以考慮檢查一下是不是這方面的原因。

相關文章