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

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

在《Java虛擬機規範》的規定裡,除了程序計數器外,虛擬機內存的其他幾個運行時區域都有發生 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

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

一. StackOverflowError

1.1 寫個 bug

<code>

public

 

class

 

StackOverflowErrorDemo

 {

    

public

 

static

 

void

 

main

(String[] args)

 

{         javaKeeper();     }     

private

 

static

 

void

 

javaKeeper

()

 

{         javaKeeper();     } } /<code>

上一篇詳細的介紹過

JVM 運行時數據區

,JVM 虛擬機棧是有深度的,在執行方法的時候會伴隨著入棧和出棧,上邊的方法可以看到,main 方法執行後不停的遞歸,遲早把棧撐爆了

<code>

Exception

 

in

 

thread

 "

main

java

.lang

.StackOverflowError

 

at

 

oom

.StackOverflowErrorDemo

.javaKeeper

(

StackOverflowErrorDemo

.java

:15)

/<code>
教你寫Bug,常見的 OOM 異常分析

1.2 原因分析

  • 無限遞歸循環調用(最常見原因),要時刻注意代碼中是否有了循環調用方法而無法退出的情況
  • 執行了大量方法,導致線程棧空間耗盡
  • 方法內聲明瞭海量的局部變量
  • native 代碼有棧上分配的邏輯,並且要求的內存還不小,比如 java.net.SocketInputStream.read0 會在棧上要求分配一個 64KB 的緩存(64位 Linux)

1.3 解決方案

  • 修復引發無限遞歸調用的異常代碼, 通過程序拋出的異常堆棧,找出不斷重複的代碼行,按圖索驥,修復無限遞歸 Bug
  • 排查是否存在類之間的循環依賴(當兩個對象相互引用,在調用toString方法時也會產生這個異常)
  • 通過 JVM 啟動參數 -Xss 增加線程棧內存空間, 某些正常使用場景需要執行大量方法或包含大量局部變量,這時可以適當地提高線程棧空間限制

二. Java heap space

Java 堆用於存儲對象實例,我們只要不斷的創建對象,並且保證 GC Roots 到對象之間有可達路徑來避免 GC 清除這些對象,那隨著對象數量的增加,總容量觸及堆的最大容量限制後就會產生內存溢出異常。

Java 堆內存的 OOM 異常是實際應用中最常見的內存溢出異常。

2.1 寫個 bug

<code> 

public

 

class

 

JavaHeapSpaceDemo

 {

    

static

 

final

 

int

 SIZE = 

2

 * 

1024

 * 

1024

;     

public

 

static

 

void

 

main

(String[] a)

 

{         

int

[] i = 

new

 

int

[SIZE];     } } /<code>

代碼試圖分配容量為 2M 的 int 數組,如果指定啟動參數 -Xmx12m,分配內存就不夠用,就類似於將 XXXL 號的對象,往 S 號的 Java heap space 裡面塞。

<code>

Exception

 

in

 

thread

 "

main

java

.lang

.OutOfMemoryError

Java

 

heap

 

space

 

at

 

oom

.JavaHeapSpaceDemo

.main

(

JavaHeapSpaceDemo

.java

:13)

/<code>

2.2 原因分析

  • 請求創建一個超大對象,通常是一個大數組
  • 超出預期的訪問量/數據量,通常是上游系統請求流量飆升,常見於各類促銷/秒殺活動,可以結合業務流量指標排查是否有尖狀峰值
  • 過度使用終結器(Finalizer),該對象沒有立即被 GC
  • 內存洩漏(Memory Leak),大量對象引用沒有釋放,JVM 無法對其自動回收,常見於使用了 File 等資源沒有回收

2.3 解決方案

針對大部分情況,通常只需要通過 -Xmx 參數調高 JVM 堆內存空間即可。如果仍然沒有解決,可以參考以下情況做進一步處理:

  • 如果是超大對象,可以檢查其合理性,比如是否一次性查詢了數據庫全部結果,而沒有做結果數限制
  • 如果是業務峰值壓力,可以考慮添加機器資源,或者做限流降級。
  • 如果是內存洩漏,需要找到持有的對象,修改代碼設計,比如關閉沒有釋放的連接
教你寫Bug,常見的 OOM 異常分析

面試官:說說內存洩露和內存溢出

加送個知識點,三連的終將成為大神~~

內存洩露和內存溢出

內存溢出(out of memory),是指程序在申請內存時,沒有足夠的內存空間供其使用,出現out of memory;比如申請了一個 Integer,但給它存了 Long 才能存下的數,那就是內存溢出。

內存洩露( memory leak),是指程序在申請內存後,無法釋放已申請的內存空間,一次內存洩露危害可以忽略,但內存洩露堆積後果很嚴重,無論多少內存,遲早會被佔光。

memory leak 最終會導致 out of memory!

三、GC overhead limit exceeded

JVM 內置了垃圾回收機制GC,所以作為 Javaer 的我們不需要手工編寫代碼來進行內存分配和釋放,但是當 Java 進程花費 98% 以上的時間執行 GC,但只恢復了不到 2% 的內存,且該動作連續重複了 5 次,就會拋出 java.lang.OutOfMemoryError:GC overhead limit exceeded 錯誤(俗稱:垃圾回收上頭)。簡單地說,就是應用程序已經基本耗盡了所有可用內存, GC 也無法回收。

假如不拋出 GC overhead limit exceeded 錯誤,那 GC 清理的那麼一丟丟內存很快就會被再次填滿,迫使 GC 再次執行,這樣惡性循環,CPU 使用率 100%,而 GC 沒什麼效果。

3.1 寫個 bug

出現這個錯誤的實例,其實我們寫個無限循環,往 List 或 Map 加數據就會一直 Full GC,直到扛不住,這裡用一個不容易發現的栗子。我們往 map 中添加 1000 個元素。

<code> 

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());         }     } } /<code>
<code>...
m.size()=54000
m.size()=55000
m.size()=56000
Exception 

in

 thread 

"main"

 java.lang.OutOfMemoryError: GC overhead 

limit

 exceeded /<code>

從輸出結果可以看到,我們的限制 1000 條數據沒有起作用,map 容量遠超過了 1000,而且最後也出現了我們想要的錯誤,這是因為類 Key 只重寫了 hashCode() 方法,卻沒有重寫 equals() 方法,我們在使用 containsKey() 方法其實就出現了問題,於是就會一直往 HashMap 中添加 Key,直至 GC 都清理不掉。

‍ 面試官又來了:說一下HashMap原理以及為什麼需要同時實現equals和hashcode

執行這個程序的最終錯誤,和 JVM 配置也會有關係,如果設置的堆內存特別小,會直接報 Java heap space。算是被這個錯誤截胡了,所以有時,在資源受限的情況下,無法準確預測程序會死於哪種具體的原因。

3.2 解決方案

  • 添加 JVM 參數-XX:-UseGCOverheadLimit 不推薦這麼幹,沒有真正解決問題,只是將異常推遲
  • 檢查項目中是否有大量的死循環或有使用大內存的代碼,優化代碼
  • dump內存分析,檢查是否存在內存洩露,如果沒有,加大內存

四、Direct buffer memory

我們使用 NIO 的時候經常需要使用 ByteBuffer 來讀取或寫入數據,這是一種基於 Channel(通道) 和 Buffer(緩衝區)的 I/O 方式,它可以使用 Native 函數庫直接分配堆外內存,然後通過一個存儲在 Java 堆裡面的 DirectByteBuffer 對象作為這塊內存的引用進行操作。這樣在一些場景就避免了 Java 堆和 Native 中來回複製數據,所以性能會有所提高。

Java 允許應用程序通過 Direct ByteBuffer 直接訪問堆外內存,許多高性能程序通過 Direct ByteBuffer 結合內存映射文件(Memory Mapped File)實現高速 IO。

4.1 寫個 bug

  • ByteBuffer.allocate(capability) 是分配 JVM 堆內存,屬於 GC 管轄範圍,需要內存拷貝所以速度相對較慢;
  • ByteBuffer.allocateDirect(capability) 是分配 OS 本地內存,不屬於 GC 管轄範圍,由於不需要內存拷貝所以速度相對較快;

如果不斷分配本地內存,堆內存很少使用,那麼 JVM 就不需要執行 GC,DirectByteBuffer 對象就不會被回收,這時雖然堆內存充足,但本地內存可能已經不夠用了,就會出現 OOM,

本地直接內存溢出

<code> 

public

 

class

 

DirectBufferMemoryDemo

 {     

public

 

static

 

void

 

main

(

String[] args

)

 {         System.

out

.println(

"maxDirectMemory is:"

+sun.misc.VM.maxDirectMemory() / 

1024

 / 

1024

 + 

"MB"

);                  ByteBuffer buffer = ByteBuffer.allocateDirect(

6

*

1024

*

1024

);     } } /<code>

最大直接內存,默認是電腦內存的 1/4,所以我們設小點,然後使用直接內存超過這個值,就會出現 OOM。

<code>

maxDirectMemory

 

is

:5MB

Exception

 

in

 

thread

 "

main

java

.lang

.OutOfMemoryError

Direct

 

buffer

 

memory

/<code>

4.2 解決方案

  1. Java 只能通過 ByteBuffer.allocateDirect 方法使用 Direct ByteBuffer,因此,可以通過 Arthas 等在線診斷工具攔截該方法進行排查
  2. 檢查是否直接或間接使用了 NIO,如 netty,jetty 等
  3. 通過啟動參數 -XX:MaxDirectMemorySize 調整 Direct ByteBuffer 的上限值
  4. 檢查 JVM 參數是否有 -XX:+DisableExplicitGC 選項,如果有就去掉,因為該參數會使 System.gc() 失效
  5. 檢查堆外內存使用代碼,確認是否存在內存洩漏;或者通過反射調用 sun.misc.Cleaner 的 clean() 方法來主動釋放被 Direct ByteBuffer 持有的內存空間
  6. 內存容量確實不足,升級配置

五、Unable to create new native thread

每個 Java 線程都需要佔用一定的內存空間,當 JVM 向底層操作系統請求創建一個新的 native 線程時,如果沒有足夠的資源分配就會報此類錯誤。

5.1 寫個 bug

<code>

public

 

static

 

void

 

main

(String[] args)

 

{   

while

(

true

){     

new

 Thread(() -> {       

try

 {         Thread.sleep(Integer.MAX_VALUE);       } 

catch

(InterruptedException e) { }     }).start();   } } /<code>
<code>Error occurred during initialization of VM
java.lang.OutOfMemoryError: unable to 

create

 

new

 

native

 

thread

/<code>

5.2 原因分析

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

JVM 向 OS 請求創建 native 線程失敗,就會拋出 Unableto createnewnativethread,常見的原因包括以下幾類:

  • 線程數超過操作系統最大線程數限制(和平臺有關)
  • 線程數超過 kernel.pid_max(只能重啟)
  • native 內存不足;該問題發生的常見過程主要包括以下幾步:
  1. JVM 內部的應用程序請求創建一個新的 Java 線程;
  2. JVM native 方法代理了該次請求,並向操作系統請求創建一個 native 線程;
  3. 操作系統嘗試創建一個新的 native 線程,併為其分配內存;
  4. 如果操作系統的虛擬內存已耗盡,或是受到 32 位進程的地址空間限制,操作系統就會拒絕本次 native 內存分配;
  5. JVM 將拋出 java.lang.OutOfMemoryError:Unableto createnewnativethread 錯誤。

5.3 解決方案

  1. 想辦法降低程序中創建線程的數量,分析應用是否真的需要創建這麼多線程
  2. 如果確實需要創建很多線程,調高 OS 層面的線程最大數:執行 ulimia-a 查看最大線程數限制,使用 ulimit-u xxx 調整最大線程數限制

六、Metaspace

JDK 1.8 之前會出現 Permgen space,該錯誤表示永久代(Permanent Generation)已用滿,通常是因為加載的 class 數目太多或體積太大。隨著 1.8 中永久代的取消,就不會出現這種異常了。

Metaspace 是方法區在 HotSpot 中的實現,它與永久代最大的區別在於,元空間並不在虛擬機內存中而是使用本地內存,但是本地內存也有打滿的時候,所以也會有異常。

6.1 寫個 bug

<code> 

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) -> {                                  

return

 methodProxy.invokeSuper(o, objects);             });             enhancer.create();         }     } } /<code>

藉助 Spring 的 GCLib 實現動態創建對象

<code> 

Exception

 

in

 

thread

 "

main

org

.springframework

.cglib

.core

.CodeGenerationException

java

.lang

.OutOfMemoryError--

>

Metaspace

/<code>

6.2 解決方案

方法區溢出也是一種常見的內存溢出異常,在經常運行時生成大量動態類的應用場景中,就應該特別關注這些類的回收情況。這類場景除了上邊的 GCLib 字節碼增強和動態語言外,常見的還有,大量 JSP 或動態產生 JSP 文件的應用(遠古時代的傳統軟件行業可能會有)、基於 OSGi 的應用(即使同一個類文件,被不同的加載器加載也會視為不同的類)等。

方法區在 JDK8 中一般不太容易產生,HotSpot 提供了一些參數來設置元空間,可以起到預防作用

  • -XX:MaxMetaspaceSize 設置元空間最大值,默認是 -1,表示不限制(還是要受本地內存大小限制的)
  • -XX:MetaspaceSize 指定元空間的初始空間大小,以字節為單位,達到該值就會觸發 GC 進行類型卸載,同時收集器會對該值進行調整
  • -XX:MinMetaspaceFreeRatio 在 GC 之後控制最小的元空間剩餘容量的百分比,可減少因元空間不足導致的垃圾收集頻率,類似的還有 MaxMetaspaceFreeRatio

七、Requested array size exceeds VM limit

7.1 寫個 bug

<code>

public

 

static

 

void

 

main

(String[] args)

 

{   

int

[] arr = 

new

 

int

[Integer.MAX_VALUE]; } /<code>

這個比較簡單,建個超級大數組就會出現 OOM,不多說了

<code>

Exception

 in thread 

"main"

 java.lang.OutOfMemoryError: Requested 

array

 size exceeds VM limit /<code>

JVM 限制了數組的最大長度,該錯誤表示程序請求創建的數組超過最大長度限制。

JVM 在為數組分配內存前,會檢查要分配的數據結構在系統中是否可尋址,通常為 Integer.MAX_VALUE-2。

此類問題比較罕見,通常需要檢查代碼,確認業務是否需要創建如此大的數組,是否可以拆分為多個塊,分批執行。

八、Out of swap space

啟動 Java 應用程序會分配有限的內存。此限制是通過-Xmx和其他類似的啟動參數指定的。

在 JVM 請求的總內存大於可用物理內存的情況下,操作系統開始將內容從內存換出到硬盤驅動器。

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

該錯誤表示所有可用的虛擬內存已被耗盡。虛擬內存(Virtual Memory)由物理內存(Physical Memory)和交換空間(Swap Space)兩部分組成。

這種錯誤沒見過~~~

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

九、Kill process or sacrifice child

操作系統是建立在流程概念之上的。這些進程由幾個內核作業負責,其中一個名為“ Out of memory Killer”,它會在可用內存極低的情況下“殺死”(kill)某些進程。OOM Killer 會對所有進程進行打分,然後將評分較低的進程“殺死”,具體的評分規則可以參考 Surviving the Linux OOM Killer。

不同於其他的 OOM 錯誤, Killprocessorsacrifice child 錯誤不是由 JVM 層面觸發的,而是由操作系統層面觸發的。

9.1 原因分析

默認情況下,Linux 內核允許進程申請的內存總量大於系統可用內存,通過這種“錯峰複用”的方式可以更有效的利用系統資源。

然而,這種方式也會無可避免地帶來一定的“超賣”風險。例如某些進程持續佔用系統內存,然後導致其他進程沒有可用內存。此時,系統將自動激活 OOM Killer,尋找評分低的進程,並將其“殺死”,釋放內存資源。

9.2 解決方案

  • 升級服務器配置/隔離部署,避免爭用
  • OOM Killer 調優。

最後附上一張“涯海”大神的圖

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

涯海

參考與感謝

《深入理解 Java 虛擬機 第 3 版》

https://plumbr.io/outofmemoryerror

https://yq.aliyun.com/articles/711191

https://github.com/StabilityMan/StabilityGuide/blob/master/docs/diagnosis/jvm/exception


分享到:


相關文章: