教你寫 Bug,常見的 OOM 異常分析

在《Java虛擬機規(guī)范》的規(guī)定里,除了程序計數(shù)器外,虛擬機內(nèi)存的其他幾個運行時區(qū)域都有發(fā)生 OutOfMemoryError 異常的可能。
本篇主要包括如下 OOM 的介紹和示例:
- java.lang.StackOverflowError
- java.lang.OutOfMemoryError: Java heap space
- java.lang.OutOfMemoryError: GC overhead limit exceeded
- java.lang.OutOfMemoryError-->Metaspace
- java.lang.OutOfMemoryError: Direct buffer memory
- java.lang.OutOfMemoryError: unable to create new native thread
- java.lang.OutOfMemoryError:Metaspace
- java.lang.OutOfMemoryError: Requested array size exceeds VM limit
- java.lang.OutOfMemoryError: Out of swap space
- java.lang.OutOfMemoryError:Kill process or sacrifice child
我們常說的 OOM 異常,其實是 Error

一. StackOverflowError
1.1 寫個 bug
public?class?StackOverflowErrorDemo?{
????public?static?void?main(String[]?args)?{
????????javaKeeper();
????}
????private?static?void?javaKeeper()?{
????????javaKeeper();
????}
}
上一篇詳細的介紹過JVM 運行時數(shù)據(jù)區(qū),JVM 虛擬機棧是有深度的,在執(zhí)行方法的時候會伴隨著入棧和出棧,上邊的方法可以看到,main 方法執(zhí)行后不停的遞歸,遲早把棧撐爆了
Exception?in?thread?"main"?java.lang.StackOverflowError
?at?oom.StackOverflowErrorDemo.javaKeeper(StackOverflowErrorDemo.java:15)1.2 原因分析
- 無限遞歸循環(huán)調(diào)用(最常見原因),要時刻注意代碼中是否有了循環(huán)調(diào)用方法而無法退出的情況
- 執(zhí)行了大量方法,導(dǎo)致線程棧空間耗盡
- 方法內(nèi)聲明了海量的局部變量
- native 代碼有棧上分配的邏輯,并且要求的內(nèi)存還不小,比如 java.net.SocketInputStream.read0 會在棧上要求分配一個 64KB 的緩存(64位 Linux)
1.3 解決方案
- 修復(fù)引發(fā)無限遞歸調(diào)用的異常代碼, 通過程序拋出的異常堆棧,找出不斷重復(fù)的代碼行,按圖索驥,修復(fù)無限遞歸 Bug
- 排查是否存在類之間的循環(huán)依賴(當兩個對象相互引用,在調(diào)用toString方法時也會產(chǎn)生這個異常)
- 通過 JVM 啟動參數(shù)
-Xss增加線程棧內(nèi)存空間, 某些正常使用場景需要執(zhí)行大量方法或包含大量局部變量,這時可以適當?shù)靥岣呔€程棧空間限制
二. Java heap space
Java 堆用于存儲對象實例,我們只要不斷的創(chuàng)建對象,并且保證 GC Roots 到對象之間有可達路徑來避免 GC 清除這些對象,那隨著對象數(shù)量的增加,總?cè)萘坑|及堆的最大容量限制后就會產(chǎn)生內(nèi)存溢出異常。
Java 堆內(nèi)存的 OOM 異常是實際應(yīng)用中最常見的內(nèi)存溢出異常。
2.1 寫個 bug
/**
?* JVM參數(shù):-Xmx12m
?*/
public?class?JavaHeapSpaceDemo?{
????static?final?int?SIZE?=?2?*?1024?*?1024;
????public?static?void?main(String[]?a)?{
????????int[]?i?=?new?int[SIZE];
????}
}
代碼試圖分配容量為 2M 的 int 數(shù)組,如果指定啟動參數(shù) -Xmx12m,分配內(nèi)存就不夠用,就類似于將 XXXL 號的對象,往 S 號的 Java heap space 里面塞。
Exception?in?thread?"main"?java.lang.OutOfMemoryError:?Java?heap?space
?at?oom.JavaHeapSpaceDemo.main(JavaHeapSpaceDemo.java:13)
2.2 原因分析
- 請求創(chuàng)建一個超大對象,通常是一個大數(shù)組
- 超出預(yù)期的訪問量/數(shù)據(jù)量,通常是上游系統(tǒng)請求流量飆升,常見于各類促銷/秒殺活動,可以結(jié)合業(yè)務(wù)流量指標排查是否有尖狀峰值
- 過度使用終結(jié)器(Finalizer),該對象沒有立即被 GC
- 內(nèi)存泄漏(Memory Leak),大量對象引用沒有釋放,JVM 無法對其自動回收,常見于使用了 File 等資源沒有回收
2.3 解決方案
針對大部分情況,通常只需要通過 -Xmx 參數(shù)調(diào)高 JVM 堆內(nèi)存空間即可。如果仍然沒有解決,可以參考以下情況做進一步處理:
- 如果是超大對象,可以檢查其合理性,比如是否一次性查詢了數(shù)據(jù)庫全部結(jié)果,而沒有做結(jié)果數(shù)限制
- 如果是業(yè)務(wù)峰值壓力,可以考慮添加機器資源,或者做限流降級。
- 如果是內(nèi)存泄漏,需要找到持有的對象,修改代碼設(shè)計,比如關(guān)閉沒有釋放的連接
面試官:說說內(nèi)存泄露和內(nèi)存溢出
加送個知識點,三連的終將成為大神~~
內(nèi)存泄露和內(nèi)存溢出
內(nèi)存溢出(out of memory),是指程序在申請內(nèi)存時,沒有足夠的內(nèi)存空間供其使用,出現(xiàn)out of memory;比如申請了一個 Integer,但給它存了 Long 才能存下的數(shù),那就是內(nèi)存溢出。
內(nèi)存泄露( memory leak),是指程序在申請內(nèi)存后,無法釋放已申請的內(nèi)存空間,一次內(nèi)存泄露危害可以忽略,但內(nèi)存泄露堆積后果很嚴重,無論多少內(nèi)存,遲早會被占光。
memory leak 最終會導(dǎo)致 out of memory!
三、GC overhead limit exceeded
JVM 內(nèi)置了垃圾回收機制GC,所以作為 Javaer 的我們不需要手工編寫代碼來進行內(nèi)存分配和釋放,但是當 Java 進程花費 98% 以上的時間執(zhí)行 GC,但只恢復(fù)了不到 2% 的內(nèi)存,且該動作連續(xù)重復(fù)了 5 次,就會拋出 java.lang.OutOfMemoryError:GC overhead limit exceeded 錯誤(俗稱:垃圾回收上頭)。簡單地說,就是應(yīng)用程序已經(jīng)基本耗盡了所有可用內(nèi)存, GC 也無法回收。
假如不拋出 GC overhead limit exceeded 錯誤,那 GC 清理的那么一丟丟內(nèi)存很快就會被再次填滿,迫使 GC 再次執(zhí)行,這樣惡性循環(huán),CPU 使用率 100%,而 GC 沒什么效果。
3.1 寫個 bug
出現(xiàn)這個錯誤的實例,其實我們寫個無限循環(huán),往 List 或 Map 加數(shù)據(jù)就會一直 Full GC,直到扛不住,這里用一個不容易發(fā)現(xiàn)的栗子。我們往 map 中添加 1000 個元素。
/**
?* JVM 參數(shù):?-Xmx14m -XX:+PrintGCDetails
?*/
public?class?KeylessEntry?{
????static?class?Key?{
????????Integer?id;
????????Key(Integer?id)?{
????????????this.id?=?id;
????????}
????????@Override
????????public?int?hashCode()?{
????????????return?id.hashCode();
????????}
????}
????public?static?void?main(String[]?args)?{
????????Map?m?=?new?HashMap();
????????while?(true){
????????????for?(int?i?=?0;?i?1000;?i++){
????????????????if?(!m.containsKey(new?Key(i))){
????????????????????m.put(new?Key(i),?"Number:"?+?i);
????????????????}
????????????}
????????????System.out.println("m.size()="?+?m.size());
????????}
????}
}
...
m.size()=54000
m.size()=55000
m.size()=56000
Exception?in?thread?"main"?java.lang.OutOfMemoryError:?GC?overhead?limit?exceeded
從輸出結(jié)果可以看到,我們的限制 1000 條數(shù)據(jù)沒有起作用,map 容量遠超過了 1000,而且最后也出現(xiàn)了我們想要的錯誤,這是因為類 Key 只重寫了 hashCode() 方法,卻沒有重寫 equals() 方法,我們在使用 containsKey() 方法其實就出現(xiàn)了問題,于是就會一直往 HashMap 中添加 Key,直至 GC 都清理不掉。
???? 面試官又來了:說一下HashMap原理以及為什么需要同時實現(xiàn)equals和hashcode
執(zhí)行這個程序的最終錯誤,和 JVM 配置也會有關(guān)系,如果設(shè)置的堆內(nèi)存特別小,會直接報
Java heap space。算是被這個錯誤截胡了,所以有時,在資源受限的情況下,無法準確預(yù)測程序會死于哪種具體的原因。
3.2 解決方案
- 添加 JVM 參數(shù)
-XX:-UseGCOverheadLimit不推薦這么干,沒有真正解決問題,只是將異常推遲 - 檢查項目中是否有大量的死循環(huán)或有使用大內(nèi)存的代碼,優(yōu)化代碼
- dump內(nèi)存分析,檢查是否存在內(nèi)存泄露,如果沒有,加大內(nèi)存
四、Direct buffer memory
我們使用 NIO 的時候經(jīng)常需要使用 ByteBuffer 來讀取或?qū)懭霐?shù)據(jù),這是一種基于 Channel(通道) 和 Buffer(緩沖區(qū))的 I/O 方式,它可以使用 Native 函數(shù)庫直接分配堆外內(nèi)存,然后通過一個存儲在 Java 堆里面的 DirectByteBuffer 對象作為這塊內(nèi)存的引用進行操作。這樣在一些場景就避免了 Java 堆和 Native 中來回復(fù)制數(shù)據(jù),所以性能會有所提高。
Java 允許應(yīng)用程序通過 Direct ByteBuffer 直接訪問堆外內(nèi)存,許多高性能程序通過 Direct ByteBuffer 結(jié)合內(nèi)存映射文件(Memory Mapped File)實現(xiàn)高速 IO。
4.1 寫個 bug
ByteBuffer.allocate(capability) 是分配 JVM 堆內(nèi)存,屬于 GC 管轄范圍,需要內(nèi)存拷貝所以速度相對較慢;
ByteBuffer.allocateDirect(capability) 是分配 OS 本地內(nèi)存,不屬于 GC 管轄范圍,由于不需要內(nèi)存拷貝所以速度相對較快;
如果不斷分配本地內(nèi)存,堆內(nèi)存很少使用,那么 JVM 就不需要執(zhí)行 GC,DirectByteBuffer 對象就不會被回收,這時雖然堆內(nèi)存充足,但本地內(nèi)存可能已經(jīng)不夠用了,就會出現(xiàn) OOM,本地直接內(nèi)存溢出。
/**
?*? VM Options:-Xms10m,-Xmx10m,-XX:+PrintGCDetails -XX:MaxDirectMemorySize=5m
?*/
public?class?DirectBufferMemoryDemo?{
????public?static?void?main(String[]?args)?{
????????System.out.println("maxDirectMemory?is:"+sun.misc.VM.maxDirectMemory()?/?1024?/?1024?+?"MB");
????????//ByteBuffer?buffer?=?ByteBuffer.allocate(6*1024*1024);
????????ByteBuffer?buffer?=?ByteBuffer.allocateDirect(6*1024*1024);
????}
}
最大直接內(nèi)存,默認是電腦內(nèi)存的 1/4,所以我們設(shè)小點,然后使用直接內(nèi)存超過這個值,就會出現(xiàn) OOM。
maxDirectMemory?is:5MB
Exception?in?thread?"main"?java.lang.OutOfMemoryError:?Direct?buffer?memory
4.2 解決方案
- Java 只能通過
ByteBuffer.allocateDirect方法使用 Direct ByteBuffer,因此,可以通過 Arthas 等在線診斷工具攔截該方法進行排查 - 檢查是否直接或間接使用了 NIO,如 netty,jetty 等
- 通過啟動參數(shù)
-XX:MaxDirectMemorySize調(diào)整 Direct ByteBuffer 的上限值 - 檢查 JVM 參數(shù)是否有
-XX:+DisableExplicitGC選項,如果有就去掉,因為該參數(shù)會使System.gc()失效 - 檢查堆外內(nèi)存使用代碼,確認是否存在內(nèi)存泄漏;或者通過反射調(diào)用
sun.misc.Cleaner的clean()方法來主動釋放被 Direct ByteBuffer 持有的內(nèi)存空間 - 內(nèi)存容量確實不足,升級配置
五、Unable to create new native thread
每個 Java 線程都需要占用一定的內(nèi)存空間,當 JVM 向底層操作系統(tǒng)請求創(chuàng)建一個新的 native 線程時,如果沒有足夠的資源分配就會報此類錯誤。
5.1 寫個 bug
public?static?void?main(String[]?args)?{
??while(true){
????new?Thread(()?->?{
??????try?{
????????Thread.sleep(Integer.MAX_VALUE);
??????}?catch(InterruptedException?e)?{?}
????}).start();
??}
}
Error?occurred?during?initialization?of?VM
java.lang.OutOfMemoryError:?unable?to?create?new?native?thread
5.2 原因分析

JVM 向 OS 請求創(chuàng)建 native 線程失敗,就會拋出 Unableto createnewnativethread,常見的原因包括以下幾類:
- 線程數(shù)超過操作系統(tǒng)最大線程數(shù)限制(和平臺有關(guān))
- 線程數(shù)超過 kernel.pid_max(只能重啟)
- native 內(nèi)存不足;該問題發(fā)生的常見過程主要包括以下幾步:
- JVM 內(nèi)部的應(yīng)用程序請求創(chuàng)建一個新的 Java 線程;
- JVM native 方法代理了該次請求,并向操作系統(tǒng)請求創(chuàng)建一個 native 線程;
- 操作系統(tǒng)嘗試創(chuàng)建一個新的 native 線程,并為其分配內(nèi)存;
- 如果操作系統(tǒng)的虛擬內(nèi)存已耗盡,或是受到 32 位進程的地址空間限制,操作系統(tǒng)就會拒絕本次 native 內(nèi)存分配;
- JVM 將拋出
java.lang.OutOfMemoryError:Unableto createnewnativethread錯誤。
5.3 解決方案
- 想辦法降低程序中創(chuàng)建線程的數(shù)量,分析應(yīng)用是否真的需要創(chuàng)建這么多線程
- 如果確實需要創(chuàng)建很多線程,調(diào)高 OS 層面的線程最大數(shù):執(zhí)行
ulimia-a查看最大線程數(shù)限制,使用ulimit-u xxx調(diào)整最大線程數(shù)限制
六、Metaspace
JDK 1.8 之前會出現(xiàn) Permgen space,該錯誤表示永久代(Permanent Generation)已用滿,通常是因為加載的 class 數(shù)目太多或體積太大。隨著 1.8 中永久代的取消,就不會出現(xiàn)這種異常了。
Metaspace 是方法區(qū)在 HotSpot 中的實現(xiàn),它與永久代最大的區(qū)別在于,元空間并不在虛擬機內(nèi)存中而是使用本地內(nèi)存,但是本地內(nèi)存也有打滿的時候,所以也會有異常。
6.1 寫個 bug
/**
?*?JVM?Options:?-XX:MetaspaceSize=10m?-XX:MaxMetaspaceSize=10m
?*/
public?class?MetaspaceOOMDemo?{
????public?static?void?main(String[]?args)?{
????????while?(true)?{
????????????Enhancer?enhancer?=?new?Enhancer();
????????????enhancer.setSuperclass(MetaspaceOOMDemo.class);
????????????enhancer.setUseCache(false);
????????????enhancer.setCallback((MethodInterceptor)?(o,?method,?objects,?methodProxy)?->?{
????????????????//動態(tài)代理創(chuàng)建對象
????????????????return?methodProxy.invokeSuper(o,?objects);
????????????});
????????????enhancer.create();
????????}
????}
}
借助 Spring 的 GCLib 實現(xiàn)動態(tài)創(chuàng)建對象
Exception?in?thread?"main"?org.springframework.cglib.core.CodeGenerationException:?java.lang.OutOfMemoryError-->Metaspace
6.2 解決方案
方法區(qū)溢出也是一種常見的內(nèi)存溢出異常,在經(jīng)常運行時生成大量動態(tài)類的應(yīng)用場景中,就應(yīng)該特別關(guān)注這些類的回收情況。這類場景除了上邊的 GCLib 字節(jié)碼增強和動態(tài)語言外,常見的還有,大量 JSP 或動態(tài)產(chǎn)生 JSP ?文件的應(yīng)用(遠古時代的傳統(tǒng)軟件行業(yè)可能會有)、基于 OSGi 的應(yīng)用(即使同一個類文件,被不同的加載器加載也會視為不同的類)等。
方法區(qū)在 JDK8 中一般不太容易產(chǎn)生,HotSpot 提供了一些參數(shù)來設(shè)置元空間,可以起到預(yù)防作用
-XX:MaxMetaspaceSize設(shè)置元空間最大值,默認是 -1,表示不限制(還是要受本地內(nèi)存大小限制的)-XX:MetaspaceSize指定元空間的初始空間大小,以字節(jié)為單位,達到該值就會觸發(fā) GC 進行類型卸載,同時收集器會對該值進行調(diào)整-XX:MinMetaspaceFreeRatio在 GC 之后控制最小的元空間剩余容量的百分比,可減少因元空間不足導(dǎo)致的垃圾收集頻率,類似的還有MaxMetaspaceFreeRatio
七、Requested array size exceeds VM limit
7.1 寫個 bug
public?static?void?main(String[]?args)?{
??int[]?arr?=?new?int[Integer.MAX_VALUE];
}
這個比較簡單,建個超級大數(shù)組就會出現(xiàn) OOM,不多說了
Exception?in?thread?"main"?java.lang.OutOfMemoryError:?Requested?array?size?exceeds?VM?limit
JVM 限制了數(shù)組的最大長度,該錯誤表示程序請求創(chuàng)建的數(shù)組超過最大長度限制。
JVM 在為數(shù)組分配內(nèi)存前,會檢查要分配的數(shù)據(jù)結(jié)構(gòu)在系統(tǒng)中是否可尋址,通常為 Integer.MAX_VALUE-2。
此類問題比較罕見,通常需要檢查代碼,確認業(yè)務(wù)是否需要創(chuàng)建如此大的數(shù)組,是否可以拆分為多個塊,分批執(zhí)行。
八、Out of swap space
啟動 Java 應(yīng)用程序會分配有限的內(nèi)存。此限制是通過-Xmx和其他類似的啟動參數(shù)指定的。
在 JVM 請求的總內(nèi)存大于可用物理內(nèi)存的情況下,操作系統(tǒng)開始將內(nèi)容從內(nèi)存換出到硬盤驅(qū)動器。

該錯誤表示所有可用的虛擬內(nèi)存已被耗盡。虛擬內(nèi)存(Virtual Memory)由物理內(nèi)存(Physical Memory)和交換空間(Swap Space)兩部分組成。
這種錯誤沒見過~~~
九、Kill process or sacrifice child
操作系統(tǒng)是建立在流程概念之上的。這些進程由幾個內(nèi)核作業(yè)負責,其中一個名為“ Out of memory Killer”,它會在可用內(nèi)存極低的情況下“殺死”(kill)某些進程。OOM Killer 會對所有進程進行打分,然后將評分較低的進程“殺死”,具體的評分規(guī)則可以參考 Surviving the Linux OOM Killer。
不同于其他的 OOM 錯誤, Killprocessorsacrifice child 錯誤不是由 JVM 層面觸發(fā)的,而是由操作系統(tǒng)層面觸發(fā)的。
9.1 原因分析
默認情況下,Linux 內(nèi)核允許進程申請的內(nèi)存總量大于系統(tǒng)可用內(nèi)存,通過這種“錯峰復(fù)用”的方式可以更有效的利用系統(tǒng)資源。
然而,這種方式也會無可避免地帶來一定的“超賣”風險。例如某些進程持續(xù)占用系統(tǒng)內(nèi)存,然后導(dǎo)致其他進程沒有可用內(nèi)存。此時,系統(tǒng)將自動激活 OOM Killer,尋找評分低的進程,并將其“殺死”,釋放內(nèi)存資源。
9.2 解決方案
- 升級服務(wù)器配置/隔離部署,避免爭用
- OOM Killer 調(diào)優(yōu)。
最后附上一張“涯海”大神的圖

參考與感謝
《深入理解 Java 虛擬機 第 3 版》
https://plumbr.io/outofmemoryerror
https://yq.aliyun.com/articles/711191
https://github.com/StabilityMan/StabilityGuide/blob/master/docs/diagnosis/jvm/exception
完
? ? ? ?
???●JVM內(nèi)存模型●JVM GC算法●不可不知的 7 個 JDK 命令
覺得不錯,點個在看~

