10種常見OOM分析——手把手教你寫bug

π大新發表於2020-07-21

點贊+收藏 就學會系列,文章收錄在 GitHub JavaKeeper ,N線網際網路開發必備技能兵器譜,筆記自取

在《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

一. StackOverflowError

1.1 寫個 bug

public class StackOverflowErrorDemo {

    public static void main(String[] args) {
        javaKeeper();
    }

    private static void javaKeeper() {
        javaKeeper();
    }
}

上一篇詳細的介紹過JVM 執行時資料區,JVM 虛擬機器棧是有深度的,在執行方法的時候會伴隨著入棧和出棧,上邊的方法可以看到,main 方法執行後不停的遞迴,遲早把棧撐爆了

Exception in thread "main" java.lang.StackOverflowError
	at oom.StackOverflowErrorDemo.javaKeeper(StackOverflowErrorDemo.java:15)

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

/**
 * JVM引數:-Xmx12m
 */
public class JavaHeapSpaceDemo {

    static final int SIZE = 2 * 1024 * 1024;

    public static void main(String[] a) {
        int[] i = new int[SIZE];
    }
}

程式碼試圖分配容量為 2M 的 int 陣列,如果指定啟動引數 -Xmx12m,分配記憶體就不夠用,就類似於將 XXXL 號的物件,往 S 號的 Java heap space 裡面塞。

Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
	at oom.JavaHeapSpaceDemo.main(JavaHeapSpaceDemo.java:13)

2.2 原因分析

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

2.3 解決方案

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

  • 如果是超大物件,可以檢查其合理性,比如是否一次性查詢了資料庫全部結果,而沒有做結果數限制
  • 如果是業務峰值壓力,可以考慮新增機器資源,或者做限流降級。
  • 如果是記憶體洩漏,需要找到持有的物件,修改程式碼設計,比如關閉沒有釋放的連線

img

面試官:說說記憶體洩露和記憶體溢位

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

記憶體洩露和記憶體溢位

記憶體溢位(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 個元素。

/**
 * JVM 引數: -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

從輸出結果可以看到,我們的限制 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,本地直接記憶體溢位

/**
 *  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);

    }
}

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

maxDirectMemory is:5MB
Exception in thread "main" java.lang.OutOfMemoryError: Direct buffer memory

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.Cleanerclean() 方法來主動釋放被 Direct ByteBuffer 持有的記憶體空間
  6. 記憶體容量確實不足,升級配置

五、Unable to create new native thread

每個 Java 執行緒都需要佔用一定的記憶體空間,當 JVM 向底層作業系統請求建立一個新的 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 請求建立 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

/**
 * 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) -> {
                //動態代理建立物件
                return methodProxy.invokeSuper(o, objects);
            });
            enhancer.create();
        }
    }
}

藉助 Spring 的 GCLib 實現動態建立物件

Exception in thread "main" org.springframework.cglib.core.CodeGenerationException: java.lang.OutOfMemoryError-->Metaspace

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

public static void main(String[] args) {
  int[] arr = new int[Integer.MAX_VALUE];
}

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

Exception in thread "main" java.lang.OutOfMemoryError: Requested array size exceeds VM limit

JVM 限制了陣列的最大長度,該錯誤表示程式請求建立的陣列超過最大長度限制。

JVM 在為陣列分配記憶體前,會檢查要分配的資料結構在系統中是否可定址,通常為 Integer.MAX_VALUE-2

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

八、Out of swap space

啟動 Java 應用程式會分配有限的記憶體。此限制是通過-Xmx和其他類似的啟動引數指定的。

在 JVM 請求的總記憶體大於可用實體記憶體的情況下,作業系統開始將內容從記憶體換出到硬碟驅動器。

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

這種錯誤沒見過~~~

九、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 調優。

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

涯海

參考與感謝

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

https://plumbr.io/outofmemoryerror

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

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

相關文章