JVM 實戰 OutOfMemoryError 異常
在《Java虛擬機規范》的規定里,除了程序計數器外,虛擬機內存的其他幾個運行時區域都有發生OutOfMemoryError(下文稱OOM)異常的可能。(本文主要是基于 jdk1.8 展開探討)
Java 堆溢出
Java堆用于儲存對象實例,我們只要不斷地創建對象,并且保證GC Roots到對象之間有可達路徑來避免垃圾回收機制清除這些對象,那么隨著對象數量的增加,總容量觸及最大堆的容量限制后就會產生內存溢出異常。
模擬代碼
下面是簡單的模擬堆內存溢出的代碼:
- /**
- * VM Args:-Xms10m -Xmx10m -XX:+HeapDumpOnOutOfMemoryError
- * @author zhengsh
- * @date 2021-8-13
- */
- public class HeapOOM {
- public static void main(String[] args) {
- List<byte[]> list = new ArrayList<>();
- while (true) {
- list.add(new byte[2048]);
- }
- }
- }
返回結果信息如下所示:
- Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
- at cn.zhengsh.jvm.oom.HeapOOM.main(HeapOOM.java:16)
問題分析
我們需要定位是內存泄漏(Memory Leak)還是,內存溢出(Memory Overflow)
- 內存泄漏
- 內存溢出
內存泄漏
我們可以通過 jdk 自帶的 jvisualvm 工具來加載堆快照文件進行分析。如果是內存泄漏,可進一步通過工具查看泄漏對象到GC Roots的引用鏈,找到泄漏對象是通過怎樣的引用路徑、與哪些GC Roots相關聯,才導致垃圾收集器無法回收它們,根據泄漏對象的類型信息 以及它到GC Roots引用鏈的信息,一般可以比較準確地定位到這些對象創建的位置,進而找出產生內存泄漏的代碼的具體位置。
內存溢出
如果不是內存泄漏,換句話說就是內存中的對象確實都是必須存活的,那就應當檢查Java虛擬機的堆參數(-Xmx與-Xms)設置,與機器的內存對比,看看是否還有向上調整的空間。再從代碼上檢查是否存在某些對象生命周期過長、持有狀態時間過長、存儲結構設計不合理等情況,盡量減少程序運 行期的內存消耗。
虛擬機棧和本地方法棧溢出
HotSpot虛擬機中并不區分虛擬機棧和本地方法棧,因此對于HotSpot來說,-Xoss參數(設置 本地方法棧大小)雖然存在,但實際上是沒有任何效果的,棧容量只能由-Xss參數來設定。關于虛擬機棧和本地方法棧,在《Java虛擬機規范》中描述了兩種異常:
- 如果線程請求的棧深度大于虛擬機所允許的最大深度,將拋出StackOverflowError異常。
- 如果虛擬機的棧內存允許動態擴展,當擴展棧容量無法申請到足夠的內存時,將拋出 OutOfMemoryError異常。
《Java虛擬機規范》明確允許Java虛擬機實現自行選擇是否支持棧的動態擴展,而HotSpot虛擬機 的選擇是不支持擴展,所以除非在創建線程申請內存時就因無法獲得足夠內存而出現 OutOfMemoryError異常,否則在線程運行時是不會因為擴展而導致內存溢出的,只會因為棧容量無法 容納新的棧幀而導致StackOverflowError異常。
虛擬機棧內存溢出
StackOverflowError
示例代碼:
- /**
- * VM Args:-Xss128k
- *
- * @author zhengsh
- * @date 2021-08-13
- */
- 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;
- }
- }
- }
返回異常信息
- Exception in thread "main" java.lang.StackOverflowError
- stack length:992
- at cn.zhengsh.jvm.oom.JavaVMStackSOF.stackLeak(JavaVMStackSOF.java:13)
- at cn.zhengsh.jvm.oom.JavaVMStackSOF.stackLeak(JavaVMStackSOF.java:14)
- //.... 省略更多
OutOfMemoryError
- package cn.zhengsh.jvm.oom;
- /**
- * @author zhengsh
- * @date 2021-08-13
- */
- public class JavaVMStackSOF2 {
- private static int stackLength = 0;
- public static void test() {
- long unused1, unused2, unused3, unused4, unused5, unused6, unused7, unused8, unused9, unused10, unused11,
- unused12, unused13, unused14, unused15, unused16, unused17, unused18, unused19, unused20, unused21,
- unused22, unused23, unused24, unused25, unused26, unused27, unused28, unused29, unused30, unused31,
- unused32, unused33, unused34, unused35, unused36, unused37, unused38, unused39, unused40, unused41,
- unused42, unused43, unused44, unused45, unused46, unused47, unused48, unused49, unused50, unused51,
- unused52, unused53, unused54, unused55, unused56, unused57, unused58, unused59, unused60, unused61,
- unused62, unused63, unused64, unused65, unused66, unused67, unused68, unused69, unused70, unused71,
- unused72, unused73, unused74, unused75, unused76, unused77, unused78, unused79, unused80, unused81,
- unused82, unused83, unused84, unused85, unused86, unused87, unused88, unused89, unused90, unused91,
- unused92, unused93, unused94, unused95, unused96, unused97, unused98, unused99, unused100;
- stackLength++;
- test();
- unused1 = unused2 = unused3 = unused4 = unused5 = unused6 = unused7 = unused8 = unused9 = unused10 = unused11 =
- unused12 = unused13 = unused14 = unused15 = unused16 = unused17 = unused18 = unused19 = unused20 =
- unused21 = unused22 = unused23 = unused24 = unused25 = unused26 = unused27 = unused28 = unused29 =
- unused30 = unused31 = unused32 = unused33 = unused34 = unused35 = unused36 = unused37 = unused38 =
- unused39 = unused40 = unused41 = unused42 = unused43 =
- unused44 = unused45 = unused46 = unused47 = unused48 = unused49 = unused50 = unused51 =
- unused52 = unused53 = unused54 = unused55 = unused56 = unused57 = unused58 = unused59 =
- unused60 = unused61 = unused62 = unused63 = unused64 = unused65 = unused66 =
- unused67 = unused68 = unused69 = unused70 = unused71 = unused72 = unused73 =
- unused74 = unused75 = unused76 = unused77 = unused78 = unused79 = unused80 =
- unused81 = unused82 = unused83 = unused84 = unused85 = unused86 =
- unused87 = unused88 = unused89 = unused90 = unused91 = unused92 =
- unused93 = unused94 = unused95 =
- unused96 = unused97 = unused98 = unused99 = unused100 = 0;
- }
- public static void main(String[] args) {
- try {
- test();
- } catch (Error e) {
- System.out.println("stack length:" + stackLength);
- throw e;
- }
- }
- }
輸出結果:
- stack length:6986
- Exception in thread "main" java.lang.StackOverflowError
- at cn.zhengsh.jvm.oom.JavaVMStackSOF2.test(JavaVMStackSOF2.java:22)
- at cn.zhengsh.jvm.oom.JavaVMStackSOF2.test(JavaVMStackSOF2.java:22)
總結
無論是由于棧幀太大還是虛擬機棧容量太小,當新的棧幀內存無法分配的時候, HotSpot虛擬機拋出的都是StackOverflowError異常。可是如果在允許動態擴展棧容量大小的虛擬機上,相同代碼則會導致不一樣的情況。
創建線程導致內存溢出
注意:下面的這個實驗可能導致操作系統卡死,建議大家在虛擬機中執行
- /**
- * VM Args:-Xss512k
- *
- * @author zhengsh
- * @date 2021-08-13
- */
- 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();
- }
- }
方法區和運行時常量池溢出
由于運行時常量池是方法區的一部分,所以這兩個區域的溢出測試可以放到一起進行。HotSpot從JDK 7 開始逐步“去永久代”的計劃,并在JDK 8中完全使用元空間來代替永久代。
方法區內存溢出
方法區的主要職責是用于存放類型的相關信息,如類 名、訪問修飾符、常量池、字段描述、方法描述等。對于這部分區域的測試,基本的思路是運行時產 生大量的類去填滿方法區,直到溢出為止。雖然直接使用Java SE API也可以動態產生類(如反射時的 GeneratedConstructorAccessor和動態代理等),但在本次實驗中借助了CGLib直接操作字節碼運行時生成了大量的動態類。
- /**
- * VM Args:-XX:MetaspaceSize=21m -XX:MaxMetaspaceSize=21m
- *
- * @author zhengsh
- * @date 2021-08-13
- */
- 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() {
- @Override
- 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: Metaspace
- Caused by: java.lang.OutOfMemoryError: Metaspace
常量池案例
String::intern()是一個本地方法,它的作用是如果字符串常量池中已經包含一個等于此String對象的 字符串,則返回代表池中這個字符串的String對象的引用;否則,會將此String對象包含的字符串添加 到常量池中,并且返回此String對象的引用。在JDK 6或更早之前的HotSpot虛擬機中,常量池都是分配在永久代中,我們可以通過-XX:PermSize和-XX:MaxPermSize限制永久代的大小,即可間接限制其中常量池的容量。
- /**
- * @author zhengsh
- * @date 2021-08-13
- */
- public class RuntimeConstantPoolOOM2 {
- 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 6中運行,會得到兩個false,而在JDK 7中運行,會得到一個true和一個false。產生差異的原因是,在JDK 6中,intern()方法會把首次遇到的字符串實例復制到永久代的字符串常量池中存儲,返回的也是永久代里面這個字符串實例的引用,而由StringBuilder創建的字符串對象實例在 Java堆上,所以必然不可能是同一個引用,結果將返回 false。而JDK 7(以及部分其他虛擬機,例如JRockit)的intern()方法實現就不需要再拷貝字符串的實例到永久代了,既然字符串常量池已經移到Java堆中,那只需要在常量池里記錄一下首次出現的實例引用即可,因此intern()返回的引用和由StringBuilder創建的那個字符串實例就是同一個。而對str2比較返 回false,這是因為“java”這個字符串在執行String-Builder.toString()之前就已經出現過了,字符串常量 池中已經有它的引用,不符合intern()方法要求“首次遇到”的原則,“計算機軟件”這個字符串則是首次出現的,因此結果返回true。
本機直接內存溢出
直接內存(Direct Memory)的容量大小可通過 -XX:MaxDirectMemorySize參數來指定,默認與Java堆最大值(由-Xmx指定)一致,代碼越過了DirectByteBuffer類直接通過反射獲取Unsafe實例進行內存分配(Unsafe類的getUnsafe()方法指定只有引導類加載器才會返回實例,體現了設計者希望只有虛擬機標準類庫里面的類才能使用Unsafe的功能,在JDK 10時才將Unsafe 的部分功能通過VarHandle開放給外部使用),因為雖然 DirectByteBuffer分配內存也會拋出內存溢出異常,但它拋出異常時并沒有真正向操作系統申請分配內存,而是通過計算得知內存無法分配就會在代碼里手動拋出溢出異常,真正申請分配內存的方法是 Unsafe::allocateMemory()。
- /**
- * VM Args:-Xmx20M -XX:MaxDirectMemorySize=10M
- *
- * @author zhengsh
- * @date 2021-08-13
- */
- 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
- Exception in thread "main" java.lang.OutOfMemoryError
- at java.base/jdk.internal.misc.Unsafe.allocateMemory(Unsafe.java:616)
- at jdk.unsupported/sun.misc.Unsafe.allocateMemory(Unsafe.java:462)
- at cn.zhengsh.jvm.oom.DirectMemoryOOM.main(DirectMemoryOOM.java:21)
參考資料
《深入理解 JVM 虛擬機-第三版》 周志明
https://docs.oracle.com/javase/specs/jls/se8/html/index.html