Java記憶體溢位(OOM)異常完全指南

追尋北極發表於2017-11-23
EN川 
2016.12.03 17:34* 字數 8566 閱讀 5226評論 6

我的職業生涯中見過數以千計的記憶體溢位異常均與下文中的8種情況相關。本文分析什麼情況會導致這些異常出現,提供示例程式碼的同時為您提供解決指南。
Nikita Salnikov-Tarnovski 
Plumbr Co-Founder and VP of Engineering
本文內容來源於Plumbr,對原文內容有刪減和補充

這也許是目前最為完整的Java OOM異常的解決指南。

1、java.lang.OutOfMemoryError:Java heap space

Java應用程式在啟動時會指定所需要的記憶體大小,它被分割成兩個不同的區域:Heap space(堆空間)Permgen(永久代)

JVM記憶體模型示意圖
JVM記憶體模型示意圖

這兩個區域的大小可以在JVM(Java虛擬機器)啟動時通過引數-Xmx-XX:MaxPermSize設定,如果你沒有顯式設定,則將使用特定平臺的預設值。

當應用程式試圖向堆空間新增更多的資料,但堆卻沒有足夠的空間來容納這些資料時,將會觸發java.lang.OutOfMemoryError: Java heap space異常。需要注意的是:即使有足夠的實體記憶體可用,只要達到堆空間設定的大小限制,此異常仍然會被觸發。

原因分析

觸發java.lang.OutOfMemoryError: Java heap space最常見的原因就是應用程式需要的堆空間是XXL號的,但是JVM提供的卻是S號。解決方法也很簡單,提供更大的堆空間即可。除了前面的因素還有更復雜的成因:

  • 流量/資料量峰值:應用程式在設計之初均有使用者量和資料量的限制,某一時刻,當使用者數量或資料量突然達到一個峰值,並且這個峰值已經超過了設計之初預期的閾值,那麼以前正常的功能將會停止,並觸發java.lang.OutOfMemoryError: Java heap space異常。
  • 記憶體洩漏:特定的程式設計錯誤會導致你的應用程式不停的消耗更多的記憶體,每次使用有記憶體洩漏風險的功能就會留下一些不能被回收的物件到堆空間中,隨著時間的推移,洩漏的物件會消耗所有的堆空間,最終觸發java.lang.OutOfMemoryError: Java heap space錯誤。

示例

①、簡單示例

首先看一個非常簡單的示例,下面的程式碼試圖建立2 x 1024 x 1024個元素的整型陣列,當你嘗試編譯並指定12M堆空間執行時(java -Xmx12m OOM)將會失敗並丟擲java.lang.OutOfMemoryError: Java heap space錯誤,而當你指定13M堆空間時,將正常的執行。

計算陣列佔用記憶體大小,不再本文的範圍內,讀者有興趣,可以自行計算

class OOM {
    static final int SIZE=2*1024*1024;
    public static void main(String[] a) {
        int[] i = new int[SIZE];
    }
}

執行如下:

D:\>javac OOM.java
D:\>java -Xmx12m OOM
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
        at OOM.main(OOM.java:4)
D:\>java -Xmx13m OOM
②、記憶體洩漏示例

在Java中,當開發者建立一個新物件(比如:new Integer(5))時,不需要自己開闢記憶體空間,而是把它交給JVM。在應用程式整個生命週期類,JVM負責檢查哪些物件可用,哪些物件未被使用。未使用物件將被丟棄,其佔用的記憶體也將被回收,這一過程被稱為垃圾回收。JVM負責垃圾回收的模組集合被稱為垃圾回收器(GC)。

Java的記憶體自動管理機制依賴於GC定期查詢未使用物件並刪除它們。Java中的記憶體洩漏是由於GC無法識別一些已經不再使用的物件,而這些未使用的物件一直留在堆空間中,這種堆積最終會導致java.lang.OutOfMemoryError: Java heap space錯誤。

我們可以非常容易的寫出導致記憶體洩漏的Java程式碼:

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<Key,String> m = new HashMap<Key,String>();
        while(true) {
            for(int i=0;i<10000;i++) {
                if(!m.containsKey(new Key(i))) {
                    m.put(new Key(i), "Number:" + i);
                }
            }
        }
    }
}

程式碼中HashMap為本地快取,第一次while迴圈,會將10000個元素新增到快取中。後面的while迴圈中,由於key已經存在於快取中,快取的大小將一直會維持在10000。但事實真的如此嗎?由於Key實體沒有實現equals()方法,導致for迴圈中每次執行m.containsKey(new Key(i))結果均為false,其結果就是HashMap中的元素將一直增加。

隨著時間的推移,越來越多的Key物件進入堆空間且不能被垃圾收集器回收(m為區域性變數,GC會認為這些物件一直可用,所以不會回收),直到所有的堆空間被佔用,最後丟擲java.lang.OutOfMemoryError:Java heap space

上面的程式碼直接執行可能很久也不會丟擲異常,可以在啟動時使用-Xmx引數,設定堆記憶體大小,或者在for迴圈後列印HashMap的大小,執行後會發現HashMap的size一直再增長。

解決方法也非常簡單,只要Key實現自己的equals方法即可:

Override
public boolean equals(Object o) {
    boolean response = false;
    if (o instanceof Key) {
        response = (((Key)o).id).equals(this.id);
    }
    return response;
}

解決方案

第一個解決方案是顯而易見的,你應該確保有足夠的堆空間來正常執行你的應用程式,在JVM的啟動配置中增加如下配置:

-Xmx1024m

上面的配置分配1024M堆空間給你的應用程式,當然你也可以使用其他單位,比如用G表示GB,K表示KB。下面的示例都表示最大堆空間為1GB:

java -Xmx1073741824 com.mycompany.MyClass
java -Xmx1048576k com.mycompany.MyClass
java -Xmx1024m com.mycompany.MyClass
java -Xmx1g com.mycompany.MyClass

然後,更多的時候,單純地增加堆空間不能解決所有的問題。如果你的程式存在記憶體洩漏,一味的增加堆空間也只是推遲java.lang.OutOfMemoryError: Java heap space錯誤出現的時間而已,並未解決這個隱患。除此之外,垃圾收集器在GC時,應用程式會停止執行直到GC完成,而增加堆空間也會導致GC時間延長,進而影響程式的吞吐量。

如果你想完全解決這個問題,那就好好提升自己的程式設計技能吧,當然運用好Debuggers, profilers, heap dump analyzers等工具,可以讓你的程式最大程度的避免記憶體洩漏問題。

2、java.lang.OutOfMemoryError:GC overhead limit exceeded

Java執行時環境(JRE)包含一個內建的垃圾回收程式,而在許多其他的程式語言中,開發者需要手動分配和釋放記憶體。

Java應用程式只需要開發者分配記憶體,每當在記憶體中特定的空間不再使用時,一個單獨的垃圾收集程式會清空這些記憶體空間。垃圾收集器怎樣檢測記憶體中的某些空間不再使用已經超出本文的範圍,但你只需要相信GC可以做好這些工作即可。

預設情況下,當應用程式花費超過98%的時間用來做GC並且回收了不到2%的堆記憶體時,會丟擲java.lang.OutOfMemoryError:GC overhead limit exceeded錯誤。具體的表現就是你的應用幾乎耗盡所有可用記憶體,並且GC多次均未能清理乾淨。

原因分析

java.lang.OutOfMemoryError:GC overhead limit exceeded錯誤是一個訊號,示意你的應用程式在垃圾收集上花費了太多時間但卻沒有什麼卵用。預設超過98%的時間用來做GC卻回收了不到2%的記憶體時將會丟擲此錯誤。那如果沒有此限制會發生什麼呢?GC程式將被重啟,100%的CPU將用於GC,而沒有CPU資源用於其他正常的工作。如果一個工作本來只需要幾毫秒即可完成,現在卻需要幾分鐘才能完成,我想這種結果誰都沒有辦法接受。

所以java.lang.OutOfMemoryError:GC overhead limit exceeded也可以看做是一個fail-fast(快速失敗)實戰的例項。

示例

下面的程式碼初始化一個map並在無限迴圈中不停的新增鍵值對,執行後將會丟擲GC overhead limit exceeded錯誤:

public class Wrapper {
    public static void main(String args[]) throws Exception {
        Map map = System.getProperties();
        Random r = new Random();
        while (true) {
            map.put(r.nextInt(), "value");
        }
    }
}

正如你所預料的那樣,程式不能正常的結束,事實上,當我們使用如下引數啟動程式時:

java -Xmx100m -XX:+UseParallelGC Wrapper

我們很快就可以看到程式丟擲java.lang.OutOfMemoryError: GC overhead limit exceeded錯誤。但如果在啟動時設定不同的堆空間大小或者使用不同的GC演算法,比如這樣:

java -Xmx10m -XX:+UseParallelGC Wrapper

我們將看到如下錯誤:

Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
    at java.util.Hashtable.rehash(Unknown Source)
    at java.util.Hashtable.addEntry(Unknown Source)
    at java.util.Hashtable.put(Unknown Source)
    at cn.moondev.Wrapper.main(Wrapper.java:12)

使用以下GC演算法:-XX:+UseConcMarkSweepGC 或者-XX:+UseG1GC,啟動命令如下:

java -Xmx100m -XX:+UseConcMarkSweepGC Wrapper
java -Xmx100m -XX:+UseG1GC Wrapper

得到的結果是這樣的:

Exception: java.lang.OutOfMemoryError thrown from 
the UncaughtExceptionHandler in thread "main"

錯誤已經被預設的異常處理程式捕獲,並且沒有任何錯誤的堆疊資訊輸出。

以上這些變化可以說明,在資源有限的情況下,你根本無法無法預測你的應用是怎樣掛掉的,什麼時候會掛掉,所以在開發時,你不能僅僅保證自己的應用程式在特定的環境下正常執行。

解決方案

首先是一個毫無誠意的解決方案,如果你僅僅是不想看到java.lang.OutOfMemoryError:GC overhead limit exceeded的錯誤資訊,可以在應用程式啟動時新增如下JVM引數:

-XX:-UseGCOverheadLimit

但是強烈建議不要使用這個選項,因為這樣並沒有解決任何問題,只是推遲了錯誤出現的時間,錯誤資訊也變成了我們更熟悉的java.lang.OutOfMemoryError: Java heap space而已。

另一個解決方案,如果你的應用程式確實記憶體不足,增加堆記憶體會解決GC overhead limit問題,就如下面這樣,給你的應用程式1G的堆記憶體:

java -Xmx1024m com.yourcompany.YourClass

但如果你想確保你已經解決了潛在的問題,而不是掩蓋java.lang.OutOfMemoryError: GC overhead limit exceeded錯誤,那麼你不應該僅止步於此。你要記得還有profilersmemory dump analyzers這些工具,你需要花費更多的時間和精力來查詢問題。還有一點需要注意,這些工具在Java執行時有顯著的開銷,因此不建議在生產環境中使用。

3、java.lang.OutOfMemoryError:Permgen space

Java中堆空間是JVM管理的最大一塊記憶體空間,可以在JVM啟動時指定堆空間的大小,其中堆被劃分成兩個不同的區域:新生代(Young)和老年代(Tenured),新生代又被劃分為3個區域:EdenFrom SurvivorTo Survivor,如下圖所示。

圖片來源:併發程式設計網
圖片來源:併發程式設計網

java.lang.OutOfMemoryError: PermGen space錯誤就表明持久代所在區域的記憶體已被耗盡。

原因分析

要理解java.lang.OutOfMemoryError: PermGen space出現的原因,首先需要理解Permanent Generation Space的用處是什麼。持久代主要儲存的是每個類的資訊,比如:類載入器引用執行時常量池(所有常量、欄位引用、方法引用、屬性)欄位(Field)資料方法(Method)資料方法程式碼方法位元組碼等等。我們可以推斷出,PermGen的大小取決於被載入類的數量以及類的大小。

因此,我們可以得出出現java.lang.OutOfMemoryError: PermGen space錯誤的原因是:太多的類或者太大的類被載入到permanent generation(持久代)。

示例

①、最簡單的示例

正如前面所描述的,PermGen的使用與載入到JVM類的數量有密切關係,下面是一個最簡單的示例:

import javassist.ClassPool;
public class MicroGenerator {
    public static void main(String[] args) throws Exception {
        for (int i = 0; i < 100_000_000; i++) {
            generate("cn.moondev.User" + i);
        }
    }

    public static Class generate(String name) throws Exception {
        ClassPool pool = ClassPool.getDefault();
        return pool.makeClass(name).toClass();
    }
}

執行時請設定JVM引數:-XX:MaxPermSize=5m,值越小越好。需要注意的是JDK8已經完全移除持久代空間,取而代之的是元空間(Metaspace),所以示例最好的JDK1.7或者1.6下執行。

程式碼在執行時不停的生成類並載入到持久代中,直到撐滿持久代記憶體空間,最後丟擲java.lang.OutOfMemoryError:Permgen space。程式碼中類的生成使用了javassist庫。

②、Redeploy-time

更復雜和實際的一個例子就是Redeploy(重新部署,你可以想象一下你開發時,點選eclipse的reploy按鈕或者使用idea時按ctrl + F5時的過程)。在從伺服器解除安裝應用程式時,當前的classloader以及載入的class在沒有例項引用的情況下,持久代的記憶體空間會被GC清理並回收。如果應用中有類的例項對當前的classloader的引用,那麼Permgen區的class將無法被解除安裝,導致Permgen區的記憶體一直增加直到出現Permgen space錯誤。

不幸的是,許多第三方庫以及糟糕的資源處理方式(比如:執行緒、JDBC驅動程式、檔案系統控制程式碼)使得解除安裝以前使用的類載入器變成了一件不可能的事。反過來就意味著在每次重新部署過程中,應用程式所有的類的先前版本將仍然駐留在Permgen區中,你的每次部署都將生成幾十甚至幾百M的垃圾。

就以執行緒和JDBC驅動來說說。很多人都會使用執行緒來處理一下週期性或者耗時較長的任務,這個時候一定要注意執行緒的生命週期問題,你需要確保執行緒不能比你的應用程式活得還長。否則,如果應用程式已經被解除安裝,執行緒還在繼續執行,這個執行緒通常會維持對應用程式的classloader的引用,造成的結果就不再多說。多說一句,開發者有責任處理好這個問題,特別是如果你是第三方庫的提供者的話,一定要提供執行緒關閉介面來處理清理工作

讓我們想象一個使用JDBC驅動程式連線到關聯式資料庫的示例應用程式。當應用程式部署到伺服器上的時:伺服器建立一個classloader例項來載入應用所有的類(包含相應的JDBC驅動)。根據JDBC規範,JDBC驅動程式(比如:com.mysql.jdbc.Driver)會在初始化時將自己註冊到java.sql.DriverManager中。該註冊過程中會將驅動程式的一個例項儲存在DriverManager的靜態欄位內,程式碼可以參考:

// com.mysql.jdbc.Driver原始碼
package com.mysql.jdbc;

public class Driver extends NonRegisteringDriver implements java.sql.Driver {
    public Driver() throws SQLException {
    }
    static {
        try {
            DriverManager.registerDriver(new Driver());
        } catch (SQLException var1) {
            throw new RuntimeException("Can\'t register driver!");
        }
    }
}
// // // // // // // // // //
// 再看下DriverManager對應程式碼
private final static CopyOnWriteArrayList<DriverInfo> registeredDrivers = new CopyOnWriteArrayList<>();

public static synchronized void registerDriver(java.sql.Driver driver,DriverAction da) throws SQLException {
    if(driver != null) {
        registeredDrivers.addIfAbsent(new DriverInfo(driver, da));
    } else {
        throw new NullPointerException();
    }
}

現在,當從伺服器上解除安裝應用程式的時候,java.sql.DriverManager仍將持有那個驅動程式的引用,進而持有用於載入應用程式的classloader的一個例項的引用。這個classloader現在仍然引用著應用程式的所有類。如果此程式啟動時需要載入2000個類,佔用約10MB永久代(PermGen)記憶體,那麼只需要5~10次重新部署,就會將預設大小的永久代(PermGen)塞滿,然後就會觸發java.lang.OutOfMemoryError: PermGen space錯誤並崩潰。

解決方案

① 解決初始化時的OutOfMemoryError

當在應用程式啟動期間觸發由於PermGen耗盡引起的OutOfMemoryError時,解決方案很簡單。 應用程式需要更多的空間來載入所有的類到PermGen區域,所以我們只需要增加它的大小。 為此,請更改應用程式啟動配置,並新增(或增加,如果存在)-XX:MaxPermSize引數,類似於以下示例:

java -XX:MaxPermSize=512m com.yourcompany.YourClass
② 解決Redeploy時的OutOfMemoryError

分析dump檔案:首先,找出引用在哪裡被持有;其次,給你的web應用程式新增一個關閉的hook,或者在應用程式解除安裝後移除引用。你可以使用如下命令匯出dump檔案:

jmap -dump:format=b,file=dump.hprof <process-id>

如果是你自己程式碼的問題請及時修改,如果是第三方庫,請試著搜尋一下是否存在"關閉"介面,如果沒有給開發者提交一個bug或者issue吧。

③ 解決執行時OutOfMemoryError

首先你需要檢查是否允許GC從PermGen解除安裝類,JVM的標準配置相當保守,只要類一建立,即使已經沒有例項引用它們,其仍將保留在記憶體中,特別是當應用程式需要動態建立大量的類但其生命週期並不長時,允許JVM解除安裝類對應用大有助益,你可以通過在啟動指令碼中新增以下配置引數來實現:

-XX:+CMSClassUnloadingEnabled

預設情況下,這個配置是未啟用的,如果你啟用它,GC將掃描PermGen區並清理已經不再使用的類。但請注意,這個配置只在UseConcMarkSweepGC的情況下生效,如果你使用其他GC演算法,比如:ParallelGC或者Serial GC時,這個配置無效。所以使用以上配置時,請配合:

-XX:+UseConcMarkSweepGC

如果你已經確保JVM可以解除安裝類,但是仍然出現記憶體溢位問題,那麼你應該繼續分析dump檔案,使用以下命令生成dump檔案:

jmap -dump:file=dump.hprof,format=b <process-id>

當你拿到生成的堆轉儲檔案,並利用像Eclipse Memory Analyzer Toolkit這樣的工具來尋找應該解除安裝卻沒被解除安裝的類載入器,然後對該類載入器載入的類進行排查,找到可疑物件,分析使用或者生成這些類的程式碼,查詢產生問題的根源並解決它。

4、java.lang.OutOfMemoryError:Metaspace

前文已經提過,PermGen區域用於儲存類的名稱和欄位,類的方法,方法的位元組碼,常量池,JIT優化等,但從Java8開始,Java中的記憶體模型發生了重大變化:引入了稱為Metaspace的新記憶體區域,而刪除了PermGen區域。請注意:不是簡單的將PermGen區所儲存的內容直接移到Metaspace區,PermGen區中的某些部分,已經移動到了普通堆裡面。

OOM-example-metaspace,圖片來源:Plumbr
OOM-example-metaspace,圖片來源:Plumbr

原因分析

Java8做出如此改變的原因包括但不限於:

  • 應用程式所需要的PermGen區大小很難預測,設定太小會觸發PermGen OutOfMemoryError錯誤,過度設定導致資源浪費。
  • 提升GC效能,在HotSpot中的每個垃圾收集器需要專門的程式碼來處理儲存在PermGen中的類的後設資料資訊。從PermGen分離類的後設資料資訊到Metaspace,由於Metaspace的分配具有和Java Heap相同的地址空間,因此MetaspaceJava Heap可以無縫的管理,而且簡化了FullGC的過程,以至將來可以並行的對後設資料資訊進行垃圾收集,而沒有GC暫停。
  • 支援進一步優化,比如:G1併發類的解除安裝,也算為將來做準備吧

正如你所看到的,元空間大小的要求取決於載入的類的數量以及這種類宣告的大小。 所以很容易看到java.lang.OutOfMemoryError: Metaspace主要原因:太多的類或太大的類載入到元空間。

示例

正如上文中所解釋的,元空間的使用與載入到JVM中的類的數量密切相關。 下面的程式碼是最簡單的例子:

public class Metaspace {
    static javassist.ClassPool cp = javassist.ClassPool.getDefault();

    public static void main(String[] args) throws Exception{
        for (int i = 0; ; i++) { 
            Class c = cp.makeClass("eu.plumbr.demo.Generated" + i).toClass();
            System.out.println(i);
        }
    }
}

程式執行中不停的生成新類,所有的這些類的定義將被載入到Metaspace區,直到空間被完全佔用並且丟擲java.lang.OutOfMemoryError:Metaspace。當使用-XX:MaxMetaspaceSize = 32m啟動時,大約載入30000多個類時就會當機。

31023
31024
Exception in thread "main" javassist.CannotCompileException: by java.lang.OutOfMemoryError: Metaspace
    at javassist.ClassPool.toClass(ClassPool.java:1170)
    at javassist.ClassPool.toClass(ClassPool.java:1113)
    at javassist.ClassPool.toClass(ClassPool.java:1071)
    at javassist.CtClass.toClass(CtClass.java:1275)
    at cn.moondev.book.Metaspace.main(Metaspace.java:12)
    .....

解決方案

第一個解決方案是顯而易見的,既然應用程式會耗盡記憶體中的Metaspace區空間,那麼應該增加其大小,更改啟動配置增加如下引數:

// 告訴JVM:Metaspace允許增長到512,然後才能丟擲異常
-XX:MaxMetaspaceSize = 512m

另一個方法就是刪除此引數來完全解除對Metaspace大小的限制(預設是沒有限制的)。預設情況下,對於64位伺服器端JVM,MetaspaceSize預設大小是21M(初始限制值),一旦達到這個限制值,FullGC將被觸發進行類解除安裝,並且這個限制值將會被重置,新的限制值依賴於Metaspace的剩餘容量。如果沒有足夠空間被釋放,這個限制值將會上升,反之亦然。在技術上Metaspace的尺寸可以增長到交換空間,而這個時候本地記憶體分配將會失敗(更具體的分析,可以參考:Java PermGen 去哪裡了?)。

你可以通過修改各種啟動引數來“快速修復”這些記憶體溢位錯誤,但你需要正確區分你是否只是推遲或者隱藏了java.lang.OutOfMemoryError的症狀。如果你的應用程式確實存在記憶體洩漏或者本來就載入了一些不合理的類,那麼所有這些配置都只是推遲問題出現的時間而已,實際也不會改善任何東西。

5、java.lang.OutOfMemoryError:Unable to create new native thread

一個思考執行緒的方法是將執行緒看著是執行任務的工人,如果你只有一個工人,那麼他同時只能執行一項任務,但如果你有十幾個工人,就可以同時完成你幾個任務。就像這些工人都在物理世界,JVM中的執行緒完成自己的工作也是需要一些空間的,當有足夠多的執行緒卻沒有那麼多的空間時就會像這樣:

圖片來源:Plumbr
圖片來源:Plumbr

出現java.lang.OutOfMemoryError:Unable to create new native thread就意味著Java應用程式已達到其可以啟動執行緒數量的極限了。

原因分析

當JVM向OS請求建立一個新執行緒時,而OS卻無法建立新的native執行緒時就會丟擲Unable to create new native thread錯誤。一臺伺服器可以建立的執行緒數依賴於物理配置和平臺,建議執行下文中的示例程式碼來測試找出這些限制。總體上來說,丟擲此錯誤會經過以下幾個階段:

  • 執行在JVM內的應用程式請求建立一個新的執行緒
  • JVM向OS請求建立一個新的native執行緒
  • OS嘗試建立一個新的native執行緒,這時需要分配記憶體給新的執行緒
  • OS拒絕分配記憶體給執行緒,因為32位Java程式已經耗盡記憶體地址空間(2-4GB記憶體地址已被命中)或者OS的虛擬記憶體已經完全耗盡
  • Unable to create new native thread錯誤將被丟擲

示例

下面的示例不能的建立並啟動新的執行緒。當程式碼執行時,很快達到OS的執行緒數限制,並丟擲Unable to create new native thread錯誤。

while(true){
    new Thread(new Runnable(){
        public void run() {
            try {
                Thread.sleep(10000000);
            } catch(InterruptedException e) { }        
        }    
    }).start();
}

解決方案

有時,你可以通過在OS級別增加執行緒數限制來繞過這個錯誤。如果你限制了JVM可在使用者空間建立的執行緒數,那麼你可以檢查並增加這個限制:

// macOS 10.12上執行
$ ulimit -u
709

當你的應用程式產生成千上萬的執行緒,並丟擲此異常,表示你的程式已經出現了很嚴重的程式設計錯誤,我不覺得應該通過修改引數來解決這個問題,不管是OS級別的引數還是JVM啟動引數。更可取的辦法是分析你的應用是否真的需要建立如此多的執行緒來完成任務?是否可以使用執行緒池或者說執行緒池的數量是否合適?是否可以更合理的拆分業務來實現.....

6、java.lang.OutOfMemoryError:Out of swap space?

Java應用程式在啟動時會指定所需要的記憶體大小,可以通過-Xmx和其他類似的啟動引數來指定。在JVM請求的總記憶體大於可用實體記憶體的情況下,作業系統會將記憶體中的資料交換到磁碟上去。

圖片來源:plumbr
圖片來源:plumbr

Out of swap space?表示交換空間也將耗盡,並且由於缺少實體記憶體和交換空間,再次嘗試分配記憶體也將失敗。

原因分析

當應用程式向JVM native heap請求分配記憶體失敗並且native heap也即將耗盡時,JVM會丟擲Out of swap space錯誤。該錯誤訊息中包含分配失敗的大小(以位元組為單位)和請求失敗的原因。

Native Heap Memory是JVM內部使用的Memory,這部分的Memory可以通過JDK提供的JNI的方式去訪問,這部分Memory效率很高,但是管理需要自己去做,如果沒有把握最好不要使用,以防出現記憶體洩露問題。JVM 使用Native Heap Memory用來優化程式碼載入(JTI程式碼生成),臨時物件空間申請,以及JVM內部的一些操作。

這個問題往往發生在Java程式已經開始交換的情況下,現代的GC演算法已經做得足夠好了,當時當面臨由於交換引起的延遲問題時,GC暫停的時間往往會讓大多數應用程式不能容忍。

java.lang.OutOfMemoryError:Out of swap space?往往是由作業系統級別的問題引起的,例如:

  • 作業系統配置的交換空間不足。
  • 系統上的另一個程式消耗所有記憶體資源。

還有可能是本地記憶體洩漏導致應用程式失敗,比如:應用程式呼叫了native code連續分配記憶體,但卻沒有被釋放。

解決方案

解決這個問題有幾個辦法,通常最簡單的方法就是增加交換空間,不同平臺實現的方式會有所不同,比如在Linux下可以通過如下命令實現:

# 原作者使用,由於我手裡並沒有Linux環境,所以並未測試
# 建立並附加一個大小為640MB的新交換檔案
swapoff -a 
dd if=/dev/zero of=swapfile bs=1024 count=655360
mkswap swapfile
swapon swapfile

Java GC會掃描記憶體中的資料,如果是對交換空間執行垃圾回收演算法會使GC暫停的時間增加幾個數量級,因此你應該慎重考慮使用上文增加交換空間的方法。

如果你的應用程式部署在JVM需要同其他程式激烈競爭獲取資源的物理機上,建議將服務隔離到單獨的虛擬機器中

但在許多情況下,您唯一真正可行的替代方案是:

  • 升級機器以包含更多記憶體
  • 優化應用程式以減少其記憶體佔用

當您轉向優化路徑時,使用記憶體轉儲分析程式來檢測記憶體中的大分配是一個好的開始。

7、java.lang.OutOfMemoryError:Requested array size exceeds VM limit

Java對應用程式可以分配的最大陣列大小有限制。不同平臺限制有所不同,但通常在1到21億個元素之間。

圖片來源:plumbr
圖片來源:plumbr

當你遇到Requested array size exceeds VM limit錯誤時,意味著你的應用程式試圖分配大於Java虛擬機器可以支援的陣列。

原因分析

該錯誤由JVM中的native code丟擲。 JVM在為陣列分配記憶體之前,會執行特定於平臺的檢查:分配的資料結構是否在此平臺中是可定址的。

你很少見到這個錯誤是因為Java陣列的索引是int型別。 Java中的最大正整數為2 ^ 31 - 1 = 2,147,483,647。 並且平臺特定的限制可以非常接近這個數字,例如:我的環境上(64位macOS,執行Jdk1.8)可以初始化陣列的長度高達2,147,483,645(Integer.MAX_VALUE-2)。如果再將陣列的長度增加1到Integer.MAX_VALUE-1會導致熟悉的OutOfMemoryError:

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

但是,在使用OpenJDK 6的32位Linux上,在分配具有大約11億個元素的陣列時,您將遇到Requested array size exceeded VM limit的錯誤。 要理解你的特定環境的限制,執行下文中描述的小測試程式。

示例

for (int i = 3; i >= 0; i--) {
    try {
        int[] arr = new int[Integer.MAX_VALUE-i];
        System.out.format("Successfully initialized an array with %,d elements.\n", Integer.MAX_VALUE-i);
    } catch (Throwable t) {
        t.printStackTrace();
    }
}

該示例重複四次,並在每個回合中初始化一個長原語陣列。 該程式嘗試初始化的陣列的大小在每次迭代時增加1,最終達到Integer.MAX_VALUE。 現在,當使用Hotspot 7在64位Mac OS X上啟動程式碼片段時,應該得到類似於以下內容的輸出:

java.lang.OutOfMemoryError: Java heap space
    at eu.plumbr.demo.ArraySize.main(ArraySize.java:8)
java.lang.OutOfMemoryError: Java heap space
    at eu.plumbr.demo.ArraySize.main(ArraySize.java:8)
java.lang.OutOfMemoryError: Requested array size exceeds VM limit
    at eu.plumbr.demo.ArraySize.main(ArraySize.java:8)
java.lang.OutOfMemoryError: Requested array size exceeds VM limit
    at eu.plumbr.demo.ArraySize.main(ArraySize.java:8)

注意,在出現Requested array size exceeded VM limit之前,出現了更熟悉的java.lang.OutOfMemoryError: Java heap space。 這是因為初始化2 ^ 31-1個元素的陣列需要騰出8G的記憶體空間,大於JVM使用的預設值。

解決方案

java.lang.OutOfMemoryError:Requested array size exceeds VM limit可能會在以下任一情況下出現:

  • 陣列增長太大,最終大小在平臺限制和Integer.MAX_INT之間
  • 你有意分配大於2 ^ 31-1個元素的陣列

在第一種情況下,檢查你的程式碼庫,看看你是否真的需要這麼大的陣列。也許你可以減少陣列的大小,或者將陣列分成更小的資料塊,然後分批處理資料。

在第二種情況下,記住Java陣列是由int索引的。因此,當在平臺中使用標準資料結構時,陣列不能超過2 ^ 31-1個元素。事實上,在編譯時就會出錯:error:integer number too large

8、Out of memory:Kill process or sacrifice child

為了理解這個錯誤,我們需要補充一點作業系統的基礎知識。作業系統是建立在程式的概念之上,這些程式在核心中作業,其中有一個非常特殊的程式,名叫“記憶體殺手(Out of memory killer)”。當核心檢測到系統記憶體不足時,OOM killer被啟用,然後選擇一個程式殺掉。哪一個程式這麼倒黴呢?選擇的演算法和想法都很樸實:誰佔用記憶體最多,誰就被幹掉。如果你對OOM Killer感興趣的話,建議你閱讀參考資料2中的文章。

OOM Killer,圖片來源:plumbr
OOM Killer,圖片來源:plumbr

當可用虛擬虛擬記憶體(包括交換空間)消耗到讓整個作業系統面臨風險時,就會產生Out of memory:Kill process or sacrifice child錯誤。在這種情況下,OOM Killer會選擇“流氓程式”並殺死它。

原因分析

預設情況下,Linux核心允許程式請求比系統中可用記憶體更多的記憶體,但大多數程式實際上並沒有使用完他們所分配的記憶體。這就跟現實生活中的寬頻運營商類似,他們向所有消費者出售一個100M的頻寬,遠遠超過使用者實際使用的頻寬,一個10G的鏈路可以非常輕鬆的服務100個(10G/100M)使用者,但實際上寬頻執行商往往會把10G鏈路用於服務150人或者更多,以便讓鏈路的利用率更高,畢竟空閒在那兒也沒什麼意義。

Linux核心採用的機制跟寬頻運營商差不多,一般情況下都沒有問題,但當大多數應用程式都消耗完自己的記憶體時,麻煩就來了,因為這些應用程式的記憶體需求加起來超出了實體記憶體(包括 swap)的容量,核心(OOM killer)必須殺掉一些程式才能騰出空間保障系統正常執行。就如同上面的例子中,如果150人都佔用100M的頻寬,那麼總的頻寬肯定超過了10G這條鏈路能承受的範圍。

示例

當你在Linux上執行如下程式碼:

public static void main(String[] args){
    List<int[]> l = new java.util.ArrayList();
    for (int i = 10000; i < 100000; i++) {
        try {
            l.add(new int[100000000]);
        } catch (Throwable t) {
            t.printStackTrace();
        }
    }
}

在Linux的系統日誌中/var/log/kern.log會出現以下日誌:

Jun  4 07:41:59 plumbr kernel: [70667120.897649] Out of memory: Kill process 29957 (java) score 366 or sacrifice child
Jun  4 07:41:59 plumbr kernel: [70667120.897701] Killed process 29957 (java) total-vm:2532680kB, anon-rss:1416508kB, file-rss:0kB

注意:你可能需要調整交換檔案和堆大小,否則你將很快見到熟悉的Java heap space異常。在原作者的測試用例中,使用-Xmx2g指定的2g堆,並具有以下交換配置:

# 注意:原作者使用,由於我手裡並沒有Linux環境,所以並未測試
swapoff -a 
dd if=/dev/zero of=swapfile bs=1024 count=655360
mkswap swapfile
swapon swapfile

解決方案

解決這個問題最有效也是最直接的方法就是升級記憶體,其他方法諸如:調整OOM Killer配置、水平擴充套件應用,將記憶體的負載分攤到若干小例項上..... 我們不建議的做法是增加交換空間,具體原因已經在前文說過。參考資料②中詳細的介紹了怎樣微調OOM Killer配置以及OOM Killer選擇程式演算法的實現,建議你參考閱讀。

參考資料:

① 想要了解更多PermGen與Metaspace的內容推薦你閱讀:

② 如果你對OOM Killer感興趣的話,強烈建議你閱讀這篇文章:

備註:水平有限,難免疏漏,如果問題請留言
本文已經同步更新到微信公眾號:輕描淡寫CODE » Java記憶體溢位(OOM)異常完全指南

相關文章