java.lang.OutOfMemoryError 的8種典型案例


java.lang.OutOfMemoryError 的8種典型案例

本文轉摘至:https://plumbr.eu/outofmemoryerror

筆者在工作中碰到過各種各樣的 java.lang.OutOfMemoryError, 其中最常見的可以歸為以下八種類型。
本手冊闡述了各種內存溢出錯誤的形成原因,並提供了可測試這種錯誤的示例代碼,以及解決方案。 內容都來源於筆者的一線開發和實踐經驗。

1. OutOfMemoryError系列: Java heap space

JVM限制了Java程序的最大內存使用量, 由JVM的啟動參數決定。而Java程序的內存被划分為兩大部分: 堆內存(Heap space)和 永久代(Permanent Generation, 簡稱 Permgen),如下圖所示:

這兩塊內存區域的最大尺寸, 由JVM啟動參數 -Xmx-XX:MaxPermSize 指定. 如果沒有明確指定, 則根據平台類型(OS版本+ JVM版本)和物理內存的大小來確定。

假如在創建新的對象時, 堆內存中的空間不足以存放新創建的對象, 就會引發java.lang.OutOfMemoryError: Java heap space 錯誤。

不管機器上還沒有空閑的物理內存, 只要堆內存使用量達到最大內存限制,就會拋出 java.lang.OutOfMemoryError: Java heap space 錯誤。

1.1 原因分析

產生 java.lang.OutOfMemoryError: Java heap space 錯誤的原因, 很多時候, 就類似於將 XXL 號的對象,往 S 號的 Java heap space 里面塞。其實清楚了原因, 就很容易解決對不對? 只要增加堆內存的大小, 程序就能正常運行. 另外還有一些比較復雜的情況, 主要是由代碼問題導致的:

  • 超出預期的訪問量/數據量。 應用系統設計時,一般是有 “容量” 定義的, 部署這么多機器, 用來處理一定量的數據/業務。 如果訪問量突然飆升, 超過預期的閾值, 類似於時間坐標系中針尖形狀的圖譜, 那么在峰值所在的時間段, 程序很可能就會卡死、並觸發 java.lang.OutOfMemoryError: Java heap space 錯誤。

  • 內存泄露(Memory leak). 這也是一種經常出現的情形。由於代碼中的某些錯誤, 導致系統占用的內存越來越多. 如果某個方法/某段代碼存在內存泄漏, 每執行一次, 就會(有更多的垃圾對象)占用更多的內存. 隨着運行時間的推移, 泄漏的對象耗光了堆中的所有內存, 那么 java.lang.OutOfMemoryError: Java heap space 錯誤就爆發了。

1.2 示例

1.2.1 一個非常簡單的示例

以下代碼非常簡單, 程序試圖分配容量為 2M 的 int 數組. 如果指定啟動參數 -Xmx12m, 那么就會發生 java.lang.OutOfMemoryError: Java heap space 錯誤。而只要將參數稍微修改一下, 變成 -Xmx13m, 錯誤就不再發生。

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

1.2.2 內存泄漏示例

這個示例更真實一些。在Java中, 創建一個新對象時, 例如 Integer num = new Integer(5); , 並不需要手動分配內存。因為 JVM 自動封裝並處理了內存分配. 在程序執行過程中, JVM 會在必要時檢查內存中還有哪些對象仍在使用, 而不再使用的那些對象則會被丟棄, 並將其占用的內存回收和重用。這個過程稱為 垃圾收集. JVM中負責垃圾回收的模塊叫做 垃圾收集器(GC)

Java的自動內存管理依賴 GC, GC會一遍又一遍地掃描內存區域, 將不使用的對象刪除. 簡單來說, Java中的內存泄漏, 就是那些邏輯上不再使用的對象, 卻沒有被 垃圾收集程序 給干掉. 從而導致垃圾對象繼續占用堆內存中, 逐漸堆積, 最后產生 java.lang.OutOfMemoryError: Java heap space 錯誤。

很容易寫個BUG程序, 來模擬內存泄漏:

import java.util.*;

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 < 10000; i++){
           if (!m.containsKey(new Key(i))){
               m.put(new Key(i), "Number:" + i);
           }
        }
        System.out.println("m.size()=" + m.size());
        }
    }
}

粗略一看, 可能覺得沒什么問題, 因為這最多緩存 10000 個元素嘛! 但仔細審查就會發現, Key 這個類只重寫了 hashCode() 方法, 卻沒有重寫 equals() 方法, 於是就會一直往 HashMap 中添加更多的 Key。

請參考: Java中hashCode與equals方法的約定及重寫原則

隨着時間推移, “cached” 的對象會越來越多. 當泄漏的對象占滿了所有的堆內存, GC 又清理不了, 就會拋出 java.lang.OutOfMemoryError:Java heap space 錯誤。

解決辦法很簡單, 在 Key 類中恰當地實現 equals() 方法即可:

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

說實話, 在尋找真正的內存泄漏原因時, 你可能會死掉很多很多的腦細胞。

1.2.3 一個SpringMVC中的場景

譯者曾經碰到過這樣一種場景:

為了輕易地兼容從 Struts2 遷移到 SpringMVC 的代碼, 在 Controller 中直接獲取 request.

所以在 ControllerBase 類中通過 ThreadLocal 緩存了當前線程所持有的 request 對象:

public abstract class ControllerBase {

    private static ThreadLocal<HttpServletRequest> requestThreadLocal = new ThreadLocal<HttpServletRequest>();

    public static HttpServletRequest getRequest(){
        return requestThreadLocal.get();
    }
    public static void setRequest(HttpServletRequest request){
        if(null == request){
        requestThreadLocal.remove();
        return;
        }
        requestThreadLocal.set(request);
    }
}

然后在 SpringMVC的攔截器(Interceptor)實現類中, 在 preHandle 方法里, 將 request 對象保存到 ThreadLocal 中:

/**
 * 登錄攔截器
 */
public class LoginCheckInterceptor implements HandlerInterceptor {
    private List<String> excludeList = new ArrayList<String>();
    public void setExcludeList(List<String> excludeList) {
        this.excludeList = excludeList;
    }
    
    private boolean validURI(HttpServletRequest request){
        // 如果在排除列表中
        String uri = request.getRequestURI();
        Iterator<String> iterator = excludeList.iterator();
        while (iterator.hasNext()) {
        String exURI = iterator.next();
        if(null != exURI && uri.contains(exURI)){
            return true;
        }
        }
        // 可以進行登錄和權限之類的判斷
        LoginUser user = ControllerBase.getLoginUser(request);
        if(null != user){
        return true;
        }
        // 未登錄,不允許
        return false;
    }

    private void initRequestThreadLocal(HttpServletRequest request){
        ControllerBase.setRequest(request);
        request.setAttribute("basePath", ControllerBase.basePathLessSlash(request));
    }
    private void removeRequestThreadLocal(){
        ControllerBase.setRequest(null);
    }

    @Override
    public boolean preHandle(HttpServletRequest request,
        HttpServletResponse response, Object handler) throws Exception {
        initRequestThreadLocal(request);
        // 如果不允許操作,則返回false即可
        if (false == validURI(request)) {
        // 此處拋出異常,允許進行異常統一處理
        throw new NeedLoginException();
        }
        return true;
    }
    
    @Override
    public void postHandle(HttpServletRequest request,
        HttpServletResponse response, Object handler, ModelAndView modelAndView)
        throws Exception {
        removeRequestThreadLocal();
    }
    
    @Override
    public void afterCompletion(HttpServletRequest request,
        HttpServletResponse response, Object handler, Exception ex)
        throws Exception {
        removeRequestThreadLocal();
    }
}

postHandleafterCompletion 方法中, 清理 ThreadLocal 中的 request 對象。

但在實際使用過程中, 業務開發人員將一個很大的對象(如占用內存200MB左右的List)設置為 request 的 Attributes, 傳遞到 JSP 中。

JSP代碼中可能發生了異常, 則SpringMVC的postHandleafterCompletion 方法不會被執行。

Tomcat 中的線程調度, 可能會一直調度不到那個拋出了異常的線程, 於是 ThreadLocal 一直 hold 住 request。 隨着運行時間的推移,把可用內存占滿, 一直在執行 Full GC, 系統直接卡死。

后續的修正: 通過 Filter, 在 finally 語句塊中清理 ThreadLocal。

@WebFilter(value="/*", asyncSupported=true)
public class ClearRequestCacheFilter implements Filter{

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException,
            ServletException {
        clearControllerBaseThreadLocal();
        try {
            chain.doFilter(request, response);
        } finally {
            clearControllerBaseThreadLocal();
        }
    }

    private void clearControllerBaseThreadLocal() {
        ControllerBase.setRequest(null);
    }
    @Override
    public void init(FilterConfig filterConfig) throws ServletException {}
    @Override
    public void destroy() {}
}

教訓是:可以使用 ThreadLocal, 但必須有受控制的釋放措施、一般就是 try-finally 的代碼形式。

說明: SpringMVC 的 Controller 中, 其實可以通過 @Autowired 注入 request, 實際注入的是一個 HttpServletRequestWrapper 對象, 執行時也是通過 ThreadLocal 機制調用當前的 request。

常規方式: 直接在controller方法中接收 request 參數即可。

1.3 解決方案

如果設置的最大內存不滿足程序的正常運行, 只需要增大堆內存即可, 配置參數可以參考下文。

但很多情況下, 增加堆內存空間並不能解決問題。比如存在內存泄漏, 增加堆內存只會推遲 java.lang.OutOfMemoryError: Java heap space 錯誤的觸發時間。

當然, 增大堆內存, 可能會增加 GC pauses 的時間, 從而影響程序的 吞吐量或延遲

如果想從根本上解決問題, 則需要排查分配內存的代碼. 簡單來說, 需要解決這些問題:

  1. 哪類對象占用了最多內存?
  2. 這些對象是在哪部分代碼中分配的。

要搞清這一點, 可能需要好幾天時間。下面是大致的流程:

  • 獲得在生產服務器上執行堆轉儲(heap dump)的權限。“轉儲”(Dump)是堆內存的快照, 稍后可以用於內存分析. 這些快照中可能含有機密信息, 例如密碼、信用卡賬號等, 所以有時候, 由於企業的安全限制, 要獲得生產環境的堆轉儲並不容易。

  • 在適當的時間執行堆轉儲。一般來說,內存分析需要比對多個堆轉儲文件, 假如獲取的時機不對, 那就可能是一個“廢”的快照. 另外, 每次執行堆轉儲, 都會對JVM進行“凍結”, 所以生產環境中,也不能執行太多的Dump操作,否則系統緩慢或者卡死,你的麻煩就大了。

  • 用另一台機器來加載Dump文件。一般來說, 如果出問題的JVM內存是8GB, 那么分析 Heap Dump 的機器內存需要大於 8GB. 打開轉儲分析軟件(我們推薦Eclipse MAT , 當然你也可以使用其他工具)。

  • 檢測快照中占用內存最大的 GC roots。詳情請參考: Solving OutOfMemoryError (part 6) – Dump is not a waste。 這對新手來說可能有點困難, 但這也會加深你對堆內存結構以及navigation機制的理解。

  • 接下來, 找出可能會分配大量對象的代碼. 如果對整個系統非常熟悉, 可能很快就能定位了。

強大吧, 不需要其他工具和分析, 就能直接看到:

  • 哪類對象占用了最多的內存(此處是 271 個 com.example.map.impl.PartitionContainer 實例, 消耗了 173MB 內存, 而堆內存只有 248MB)

  • 這些對象在何處創建(大部分是在 MetricManagerImpl 類中,第304行處)

  • 當前是誰在引用這些對象(從 GC root 開始的完整引用鏈)

得知這些信息, 就可以定位到問題的根源, 例如是當地精簡數據結構/模型, 只占用必要的內存即可。

當然, 根據內存分析的結果, 以及Plumbr生成的報告, 如果發現對象占用的內存很合理, 也不需要修改源代碼的話, 那就增大堆內存吧。在這種情況下,修改JVM啟動參數, (按比例)增加下面的值:

    -Xmx1024m

這里配置Java堆內存最大為 1024MB。可以使用 g/G 表示 GB, m/M 代表 MB, k/K 表示 KB.

下面的這些形式都是等價的, 設置Java堆的最大空間為 1GB:

    # 等價形式: 最大1GB內存
    java -Xmx1073741824 com.mycompany.MyClass
    java -Xmx1048576k com.mycompany.MyClass
    java -Xmx1024m com.mycompany.MyClass
    java -Xmx1g com.mycompany.MyClass 

2. OutOfMemoryError系列: GC overhead limit exceeded

Java運行時環境內置了 垃圾收集(GC) 模塊. 上一代的很多編程語言中並沒有自動內存回收機制, 需要程序員手工編寫代碼來進行內存分配和釋放, 以重復利用堆內存。

在Java程序中, 只需要關心內存分配就行。如果某塊內存不再使用, 垃圾收集(Garbage Collection) 模塊會自動執行清理。GC的詳細原理請參考 GC性能優化 系列文章, 一般來說, JVM內置的垃圾收集算法就能夠應對絕大多數的業務場景。

java.lang.OutOfMemoryError: GC overhead limit exceeded 這種情況發生的原因是, 程序基本上耗盡了所有的可用內存, GC也清理不了

2.1 原因分析

JVM拋出 java.lang.OutOfMemoryError: GC overhead limit exceeded 錯誤就是發出了這樣的信號: 執行垃圾收集的時間比例太大, 有效的運算量太小. 默認情況下, 如果GC花費的時間超過 98%, 並且GC回收的內存少於 2%, JVM就會拋出這個錯誤。

注意, java.lang.OutOfMemoryError: GC overhead limit exceeded 錯誤只在連續多次 GC 都只回收了不到2%的極端情況下才會拋出。假如不拋出 GC overhead limit 錯誤會發生什么情況呢? 那就是GC清理的這么點內存很快會再次填滿, 迫使GC再次執行. 這樣就形成惡性循環, CPU使用率一直是100%, 而GC卻沒有任何成果. 系統用戶就會看到系統卡死 - 以前只需要幾毫秒的操作, 現在需要好幾分鍾才能完成。

這也是一個很好的 快速失敗原則 的案例。

2.2 示例

以下代碼在無限循環中往 Map 里添加數據。 這會導致 “GC overhead limit exceeded” 錯誤:

package com.cncounter.rtime;
import java.util.Map;
import java.util.Random;
public class TestWrapper {
    public static void main(String args[]) throws Exception {
        Map map = System.getProperties();
        Random r = new Random();
        while (true) {
            map.put(r.nextInt(), "value");
        }
    }
}

配置JVM參數: -Xmx12m。執行時產生的錯誤信息如下所示:

Exception in thread "main" java.lang.OutOfMemoryError: GC overhead limit exceeded
	at java.util.Hashtable.addEntry(Hashtable.java:435)
	at java.util.Hashtable.put(Hashtable.java:476)
	at com.cncounter.rtime.TestWrapper.main(TestWrapper.java:11)

你碰到的錯誤信息不一定就是這個。確實, 我們執行的JVM參數為:

java -Xmx12m -XX:+UseParallelGC TestWrapper

很快就看到了 java.lang.OutOfMemoryError: GC overhead limit exceeded 錯誤提示消息。但實際上這個示例是有些坑的. 因為配置不同的堆內存大小, 選用不同的GC算法, 產生的錯誤信息也不相同。例如,當Java堆內存設置為10M時:

java -Xmx10m -XX:+UseParallelGC TestWrapper

DEBUG模式下錯誤信息如下所示:

Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
	at java.util.Hashtable.rehash(Hashtable.java:401)
	at java.util.Hashtable.addEntry(Hashtable.java:425)
	at java.util.Hashtable.put(Hashtable.java:476)
	at com.cncounter.rtime.TestWrapper.main(TestWrapper.java:11)

讀者應該試着修改參數, 執行看看具體。錯誤提示以及堆棧信息可能不太一樣。

這里在 Map 進行 rehash 時拋出了 java.lang.OutOfMemoryError: Java heap space 錯誤消息. 如果使用其他 垃圾收集算法, 比如 -XX:+UseConcMarkSweepGC, 或者 -XX:+UseG1GC, 錯誤將被默認的 exception handler 所捕獲, 但是沒有 stacktrace 信息, 因為在創建 Exception 時 沒辦法填充stacktrace信息

例如配置:

-Xmx12m -XX:+UseG1GC

在Win7x64, Java8環境運行, 產生的錯誤信息為:

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

建議讀者修改內存配置, 以及垃圾收集器進行測試。

這些真實的案例表明, 在資源受限的情況下, 無法准確預測程序會死於哪種具體的原因。所以在這類錯誤面前, 不能綁死某種特定的錯誤處理順序。

2.3 解決方案

有一種應付了事的解決方案, 就是不想拋出 “java.lang.OutOfMemoryError: GC overhead limit exceeded” 錯誤信息, 則添加下面啟動參數:

// 不推薦
-XX:-UseGCOverheadLimit

我們強烈建議不要指定該選項: 因為這不能真正地解決問題,只能推遲一點 out of memory 錯誤發生的時間,到最后還得進行其他處理。指定這個選項, 會將原來的 java.lang.OutOfMemoryError: GC overhead limit exceeded 錯誤掩蓋,變成更常見的 java.lang.OutOfMemoryError: Java heap space 錯誤消息。

需要注意: 有時候觸發 GC overhead limit 錯誤的原因, 是因為分配給JVM的堆內存不足。這種情況下只需要增加堆內存大小即可。

在大多數情況下, 增加堆內存並不能解決問題。例如程序中存在內存泄漏, 增加堆內存只能推遲產生 java.lang.OutOfMemoryError: Java heap space 錯誤的時間。

當然, 增大堆內存, 還有可能會增加 GC pauses 的時間, 從而影響程序的 吞吐量或延遲

如果想從根本上解決問題, 則需要排查內存分配相關的代碼. 簡單來說, 需要回答以下問題:

  1. 哪類對象占用了最多內存?

  2. 這些對象是在哪部分代碼中分配的。

要搞清這一點, 可能需要好幾天時間。下面是大致的流程:

  • 獲得在生產服務器上執行堆轉儲(heap dump)的權限。“轉儲”(Dump)是堆內存的快照, 可用於后續的內存分析. 這些快照中可能含有機密信息, 例如密碼、信用卡賬號等, 所以有時候, 由於企業的安全限制, 要獲得生產環境的堆轉儲並不容易。

  • 在適當的時間執行堆轉儲。一般來說,內存分析需要比對多個堆轉儲文件, 假如獲取的時機不對, 那就可能是一個“廢”的快照. 另外, 每執行一次堆轉儲, 就會對JVM進行一次“凍結”, 所以生產環境中,不能執行太多的Dump操作,否則系統緩慢或者卡死,你的麻煩就大了。

  • 用另一台機器來加載Dump文件。如果出問題的JVM內存是8GB, 那么分析 Heap Dump 的機器內存一般需要大於 8GB. 然后打開轉儲分析軟件(我們推薦Eclipse MAT , 當然你也可以使用其他工具)。

  • 檢測快照中占用內存最大的 GC roots。詳情請參考: Solving OutOfMemoryError (part 6) – Dump is not a waste。 這對新手來說可能有點困難, 但這也會加深你對堆內存結構以及 navigation 機制的理解。

  • 接下來, 找出可能會分配大量對象的代碼. 如果對整個系統非常熟悉, 可能很快就能定位問題。運氣不好的話,就只有加班加點來進行排查了。

  • 哪類對象占用了最多的內存(此處是 271 個 com.example.map.impl.PartitionContainer 實例, 消耗了 173MB 內存, 而堆內存只有 248MB)

  • 這些對象在何處創建(大部分是在 MetricManagerImpl 類中,第304行處)

  • 當前是誰在引用這些對象(從 GC root 開始的完整引用鏈)

得知這些信息, 就可以定位到問題的根源, 例如是當地精簡數據結構/模型, 只占用必要的內存即可。

當然, 根據內存分析的結果, 以及Plumbr生成的報告, 如果發現對象占用的內存很合理, 也不需要修改源代碼的話, 那就增大堆內存吧。在這種情況下,修改JVM啟動參數, (按比例)增加下面的值:

java -Xmx1024m com.yourcompany.YourClass`

這里配置了最大堆內存為 1GB。請根據實際情況修改這個值. 如果 JVM 還是會拋出 OutOfMemoryError, 那么你可能還需要查詢手冊, 或者借助工具再次進行分析和診斷。

3. OutOfMemoryError系列: Permgen space

說明: Permgen(永久代) 屬於 JDK1.7 及之前版本的概念; 為了適應Java程序的發展, JDK8以后的版本采用限制更少的 MetaSpace 來代替, 詳情請參考下一篇文章: OutOfMemoryError系列(4): Metaspace

JVM有最大內存限制, 通過修改啟動參數可以改變這些值。Java將堆內存划分為多個區域, 如下圖所示:

這些區域的最大值, 由JVM啟動參數 -Xmx-XX:MaxPermSize 指定. 如果沒有明確指定, 則根據平台類型(OS版本+ JVM版本)和物理內存的大小來確定。

java.lang.OutOfMemoryError: PermGen space 錯誤信息所表達的意思是: 永久代(Permanent Generation) 內存區域已滿

3.1 原因分析

我們先看看 PermGen 是用來干什么的。

在JDK1.7及之前的版本, 永久代(permanent generation) 主要用於存儲加載/緩存到內存中的 class 定義, 包括 class 的 名稱(name), 字段(fields), 方法(methods)和字節碼(method bytecode); 以及常量池(constant pool information); 對象數組(object arrays)/類型數組(type arrays)所關聯的 class, 還有 JIT 編譯器優化后的class信息等。

很容易看出, PermGen 的使用量和JVM加載到內存中的 class 數量/大小有關。可以說 java.lang.OutOfMemoryError: PermGen space 的主要原因, 是加載到內存中的 class 數量太多或體積太大。

3.2 示例

3.2.1 最簡單的例子

我們知道, PermGen 空間的使用量, 與JVM加載的 class 數量有很大關系。下面的代碼演示了這種情況:

import javassist.ClassPool;

public class MicroGenerator {
  public static void main(String[] args) throws Exception {
    for (int i = 0; i < 100_000_000; i++) {
      generate("eu.plumbr.demo.Generated" + i);
    }
  }

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

這段代碼在 for 循環中, 動態生成了很多class。可以看到, 使用 javassist 工具類生成 class 是非常簡單的。

執行這段代碼, 會生成很多新的 class 並將其加載到內存中, 隨着生成的class越來越多,將會占滿Permgen空間, 然后拋出 java.lang.OutOfMemoryError: Permgen space 錯誤, 當然, 也有可能會拋出其他類型的 OutOfMemoryError。

要快速看到效果, 可以加上適當的JVM啟動參數, 如: -Xmx200M -XX:MaxPermSize=16M 等等。

3.2.2 Redeploy 時產生的 OutOfMemoryError

說明: 如果在開發時Tomcat產生警告,可以忽略。 生產環境建議不要 redploy,直接關閉/或Kill相關的JVM,然后從頭開始啟動即可。

下面的情形更常見,在重新部署web應用時, 很可能會引起 java.lang.OutOfMemoryError: Permgen space 錯誤. 按道理說, redeploy 時, Tomcat之類的容器會使用新的 classloader 來加載新的 class, 讓垃圾收集器 將之前的 classloader (連同加載的class一起)清理掉,。

但實際情況可能並不樂觀, 很多第三方庫, 以及某些受限的共享資源, 如 thread, JDBC驅動, 以及文件系統句柄(handles), 都會導致不能徹底卸載之前的 classloader. 那么在 redeploy 時, 之前的class仍然駐留在PermGen中, 每次重新部署都會產生幾十MB,甚至上百MB的垃圾

假設某個應用在啟動時, 通過初始化代碼加載JDBC驅動連接數據庫. 根據JDBC規范, 驅動會將自身注冊到 java.sql.DriverManager, 也就是將自身的一個實例(instance) 添加到 DriverManager 中的一個 static 域。

那么, 當應用從容器中卸載時, java.sql.DriverManager 依然持有 JDBC實例(Tomcat經常會發出警告), 而JDBC驅動實例又持有 java.lang.Classloader 實例, 那么 垃圾收集器 也就沒辦法回收對應的內存空間。

java.lang.ClassLoader 實例持有着其加載的所有 class, 通常是幾十/上百 MB的內存。可以看到, redeploy時會占用另一塊差不多大小的 PermGen 空間, 多次 redeploy 之后, 就會造成 java.lang.OutOfMemoryError: PermGen space 錯誤, 在日志文件中, 你應該會看到相關的錯誤信息。

3.3 解決方案

1. 解決程序啟動時產生的 OutOfMemoryError

在程序啟動時, 如果 PermGen 耗盡而產生 OutOfMemoryError 錯誤, 那很容易解決. 增加 PermGen 的大小, 讓程序擁有更多的內存來加載 class 即可. 修改 -XX:MaxPermSize 啟動參數, 類似下面這樣:

java -XX:MaxPermSize=512m com.yourcompany.YourClass

以上配置允許JVM使用的最大 PermGen 空間為 512MB, 如果還不夠, 就會拋出 OutOfMemoryError

2. 解決 redeploy 時產生的 OutOfMemoryError

我們可以進行堆轉儲分析(heap dump analysis) —— 在 redeploy 之后, 執行堆轉儲, 類似下面這樣:

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

然后通過堆轉儲分析器(如強悍的 Eclipse MAT)加載 dump 得到的文件。找出重復的類, 特別是類加載器(classloader)對應的 class. 你可能需要比對所有的 classloader, 來找出當前正在使用的那個。

Eclipse MAT 在各個平台都有獨立安裝包. 大約50MB左右, 官網下載地址: http://www.eclipse.org/mat/downloads.php

對於不使用的類加載器(inactive classloader), 需要先確定最短路徑的 GC root , 看看是哪一個阻止其被 垃圾收集器 所回收. 這樣才能找到問題的根源. 如果是第三方庫的原因, 那么可以搜索 Google/StackOverflow 來查找解決方案. 如果是自己的代碼問題, 則需要在恰當的時機來解除相關引用。

3. 解決運行時產生的 OutOfMemoryError

如果在運行的過程中發生 OutOfMemoryError, 首先需要確認 GC是否能從PermGen中卸載class。 官方的JVM在這方面是相當的保守(在加載class之后,就一直讓其駐留在內存中,即使這個類不再被使用). 但是, 現代的應用程序在運行過程中, 會動態創建大量的class, 而這些class的生命周期基本上都很短暫, 舊版本的JVM 不能很好地處理這些問題。那么我們就需要允許JVM卸載class。使用下面的啟動參數:

-XX:+CMSClassUnloadingEnabled

默認情況下 CMSClassUnloadingEnabled 的值為false, 所以需要明確指定。 啟用以后, GC 將會清理 PermGen, 卸載無用的 class. 當然, 這個選項只有在設置 UseConcMarkSweepGC 時生效。 如果使用了 ParallelGC, 或者 Serial GC 時, 那么需要切換為CMS:

-XX:+UseConcMarkSweepGC

如果確定 class 可以被卸載, 假若還存在 OutOfMemoryError, 那就需要進行堆轉儲分析了, 類似下面這種命令:

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

然后通過堆轉儲分析器(如 Eclipse MAT) 加載 heap dump。找出最重的 classloader, 也就是加載 class 數量最多的那個. 通過加載的 class 及對應的實例數量, 比對類加載器, 找出最靠前的部分, 挨個進行分析。

對於每個有嫌疑的類, 都需要手動跟蹤到生成這些類的代碼中, 以定位問題。

4. OutOfMemoryError系列: Metaspace

JVM限制了Java程序的最大內存, 修改/指定啟動參數可以改變這種限制。Java將堆內存划分為多個部分, 如下圖所示:

【Java8及以上】這些內存池的最大值, 由 -Xmx-XX:MaxMetaspaceSize 等JVM啟動參數指定. 如果沒有明確指定, 則根據平台類型(OS版本+JVM版本)和物理內存的大小來確定。

java.lang.OutOfMemoryError: Metaspace 錯誤所表達的信息是: 元數據區(Metaspace) 已被用滿

4.1 原因分析

如果你是Java老司機, 應該對 PermGen 比較熟悉. 但從Java 8開始,內存結構發生重大改變, 不再使用Permgen, 而是引入一個新的空間: Metaspace. 這種改變基於多方面的考慮, 部分原因列舉如下:

  • Permgen空間的具體多大很難預測。指定小了會造成 java.lang.OutOfMemoryError: Permgen size 錯誤, 設置多了又造成浪費。

  • 為了 GC 性能 的提升, 使得垃圾收集過程中的並發階段不再 停頓, 另外對 metadata 進行特定的遍歷(specific iterators)。

  • G1垃圾收集器 的並發 class unloading 進行深度優化。

在Java8中,將之前 PermGen 中的所有內容, 都移到了 Metaspace 空間。例如: class 名稱, 字段, 方法, 字節碼, 常量池, JIT優化代碼, 等等。

Metaspace 的使用量與JVM加載到內存中的 class 數量/大小有關。可以說, java.lang.OutOfMemoryError: Metaspace 錯誤的主要原因, 是加載到內存中的 class 數量太多或者體積太大。

4.2 示例

上一章的PermGen 類似, Metaspace 空間的使用量, 與JVM加載的 class 數量有很大關系。下面是一個簡單的示例:

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();
    }
  }
}

可以看到, 使用 javassist 工具庫生成 class 那是非常簡單。在 for 循環中, 動態生成很多class, 最終將這些class加載到 Metaspace 中。

執行這段代碼, 隨着生成的class越來越多, 最后將會占滿 Metaspace 空間, 拋出 java.lang.OutOfMemoryError: Metaspace. 在Mac OS X上, Java 1.8.0_05 環境下, 如果設置了啟動參數 -XX:MaxMetaspaceSize=64m, 大約加載 70000 個class后JVM就會掛掉。

4.3 解決方案

如果拋出與 Metaspace 有關的 OutOfMemoryError , 第一解決方案是增加 Metaspace 的大小. 使用下面這樣的啟動參數:

-XX:MaxMetaspaceSize=512m

這里將 Metaspace 的最大值設置為 512MB, 如果沒有用完, 就不會拋出 OutOfMemoryError

有一種看起來很簡單的方案, 是直接去掉 Metaspace 的大小限制。 但需要注意, 不限制Metaspace內存的大小, 假若物理內存不足, 有可能會引起內存交換(swapping), 嚴重拖累系統性能。 此外,還可能造成native內存分配失敗等問題。

在現代應用集群中,寧可讓應用節點死掉, 也不希望其死慢死慢的。

如果不想收到報警, 可以像鴕鳥一樣, 把 java.lang.OutOfMemoryError: Metaspace 錯誤信息隱藏起來。 但這不能真正解決問題, 只會推遲問題爆發的時間。 如果確實存在內存泄露, 請參考前面的文章, 認真尋找解決方案。

5. OutOfMemoryError系列: Unable to create new native thread

Java程序本質上是多線程的, 可以同時執行多項任務。 類似於在播放視頻的時候, 可以拖放窗口中的內容, 卻不需要暫停視頻播放, 即便是物理機上只有一個CPU。

線程(thread)可以看作是干活的工人(workers)。 如果只有一個工人, 在同一時間就只能執行一項任務. 假若有很多工人, 那么就可以同時執行多項任務。

和現實世界類似, JVM中的線程也需要內存空間來執行自己的任務. 如果線程數量太多, 就會引入新的問題:

java.lang.OutOfMemoryError: Unable to create new native thread 錯誤表達的意思是: 程序創建的線程數量已達到上限值

5.1 原因分析

JVM向操作系統申請創建新的 native thread(原生線程)時, 就有可能會碰到 java.lang.OutOfMemoryError: Unable to create new native thread 錯誤. 如果底層操作系統創建新的 native thread 失敗, JVM就會拋出相應的OutOfMemoryError. 原生線程的數量受到具體環境的限制, 通過一些測試用例可以找出這些限制, 請參考下文的示例. 但總體來說, 導致 java.lang.OutOfMemoryError: Unable to create new native thread 錯誤的場景大多經歷以下這些階段:

  1. Java程序向JVM請求創建一個新的Java線程;

  2. JVM本地代碼(native code)代理該請求, 嘗試創建一個操作系統級別的 native thread(原生線程);

  3. 操作系統嘗試創建一個新的native thread, 需要同時分配一些內存給該線程;

  4. 如果操作系統的虛擬內存已耗盡, 或者是受到32位進程的地址空間限制(約2-4GB), OS就會拒絕本地內存分配;

  5. JVM拋出 java.lang.OutOfMemoryError: Unable to create new native thread 錯誤。

5.2 示例

下面的代碼在一個死循環中創建並啟動很多新線程。代碼執行后, 很快就會達到操作系統的限制, 報出 java.lang.OutOfMemoryError: Unable to create new native thread 錯誤。

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

原生線程的數量由具體環境決定, 比如, 在 Windows, Linux 和 Mac OS X 系統上:

  • 64-bit Mac OS X 10.9, Java 1.7.0_45 – JVM 在創建 #2031 號線程之后掛掉

  • 64-bit Ubuntu Linux, Java 1.7.0_45 – JVM 在創建 #31893 號線程之后掛掉

  • 64-bit Windows 7, Java 1.7.0_45 – 由於操作系統使用了不一樣的線程模型, 這個錯誤信息似乎不會出現. 創建 #250,000 號線程之后,Java進程依然存在, 但虛擬內存(swap file) 的使用量達到了 10GB, 系統運行極其緩慢,基本上沒法運行了。

所以如果想知道系統的極限在哪兒, 只需要一個小小的測試用例就夠了, 找到觸發 java.lang.OutOfMemoryError: Unable to create new native thread 時創建的線程數量即可。

5.3 解決方案

有時可以修改系統限制來避開 Unable to create new native thread 問題. 假如JVM受到用戶空間(user space)文件數量的限制, 像下面這樣,就應該想辦法增大這個值:

[root@dev ~]# ulimit -a
core file size          (blocks, -c) 0
...... 省略部分內容 ......
max user processes              (-u) 1800

更多的情況, 觸發創建 native 線程時的OutOfMemoryError, 表明編程存在BUG. 比如, 程序創建了成千上萬的線程, 很可能就是某些地方出大問題了 —— 沒有幾個程序可以 Hold 住上萬個線程的。

一種解決辦法是執行線程轉儲(thread dump) 來分析具體情況。 一般需要花費好幾個工作日來處理。 當然, 我們推薦使用 Plumbr 來找出問題的根源, 分分鍾幫你搞定。

6. OutOfMemoryError系列: Out of swap space?

JVM啟動參數指定了最大內存限制。如 -Xmx 以及相關的其他啟動參數. 假若JVM使用的內存總量超過可用的物理內存, 操作系統就會用到虛擬內存。

錯誤信息 java.lang.OutOfMemoryError: Out of swap space? 表明, 交換空間(swap space,虛擬內存) 不足,是由於物理內存和交換空間都不足所以導致內存分配失敗。

6.1 原因分析

如果 native heap 內存耗盡, 內存分配時, JVM 就會拋出 java.lang.OutOfmemoryError: Out of swap space? 錯誤消息, 這個消息告訴用戶, 請求分配內存的操作失敗了。

Java進程使用了虛擬內存才會發生這個錯誤。 對 Java的垃圾收集 來說這是很難應付的場景。即使現代的 GC算法 很先進, 但虛擬內存交換引發的系統延遲, 會讓 GC暫停時間 膨脹到令人難以容忍的地步。

通常是操作系統層面的原因導致 java.lang.OutOfMemoryError: Out of swap space? 問題, 例如:

  • 操作系統的交換空間太小。

  • 機器上的某個進程耗光了所有的內存資源。

當然也可能是應用程序的本地內存泄漏(native leak)引起的, 例如, 某個程序/庫不斷地申請本地內存,卻不進行釋放。

6.2 示例

6.3 解決方案

這個問題有多種解決辦法。

第一種, 也是最簡單的方法, 增加虛擬內存(swap space) 的大小. 各操作系統的設置方法不太一樣, 比如Linux,可以使用下面的命令設置:

swapoff -a
dd if=/dev/zero of=swapfile bs=1024 count=655360
mkswap swapfile
swapon swapfile

其中創建了一個大小為 640MB 的 swapfile(交換文件) 並啟用該文件。

因為垃圾收集器需要清理整個內存空間, 所以虛擬內存對 Java GC 來說是難以忍受的。存在內存交換時, 執行 垃圾收集暫停時間 會增加上百倍,甚至更多, 所以最好不要增加虛擬內存。

如果程序允許環境還受到 “壞鄰居效應” 的干擾, 那么JVM還要和其他程序競爭計算資源, 提高性能的辦法就是單獨部署到專用的服務器/虛擬機中。

大多數時候, 我們唯一能做的就是升級服務器配置, 增加物理機的內存。當然也可以進行程序優化, 降低內存空間的使用量, 通過堆轉儲分析器可以檢測到哪些方法/代碼分配了大量的內存。

7. OutOfMemoryError系列: Requested array size exceeds VM limit

Java平台限制了數組的最大長度。各個版本的具體限制可能稍有不同, 但范圍都在 1 ~ 21億 之間。

如果程序拋出 java.lang.OutOfMemoryError: Requested array size exceeds VM limit 錯誤, 就說明想要創建的數組長度超過限制。

7.1 原因分析

這個錯誤是由JVM中的本地代碼拋出的. 在真正為數組分配內存之前, JVM會執行一項檢查: 要分配的數據結構在該平台是否可以尋址(addressable). 當然, 這個錯誤比你所想的還要少見得多。

一般很少看到這個錯誤, 因為Java使用 int 類型作為數組的下標(index, 索引)。在Java中, int類型的最大值為 2^31 – 1 = 2,147,483,647。大多數平台的限制都約等於這個值 —— 例如在 64位的 MB Pro 上, Java 1.7 平台可以分配長度為 2,147,483,645, 以及 Integer.MAX_VALUE-2) 的數組。

再增加一點點長度, 變成 Integer.MAX_VALUE-1 時, 就會拋出我們所熟知的 OutOfMemoryError:

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

在有的平台上, 這個最大限制可能還會更小一些, 例如在32位Linux, OpenJDK 6 上面, 數組長度大約在 11億左右(約2^30) 就會拋出 “java.lang.OutOfMemoryError: Requested array size exceeds VM limit“ 錯誤。要找出具體的限制值, 可以執行一個小小的測試用例, 具體示例參見下文。

7.2 示例

以下代碼用來演示 java.lang.OutOfMemoryError: Requested array size exceeds 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();
  }
}

其中,for循環迭代4次, 每次都去初始化一個 int 數組, 長度從 Integer.MAX_VALUE-3 開始遞增, 到 Integer.MAX_VALUE 為止. 在 64位 Mac OS X 的 Hotspot 7 平台上, 執行這段代碼會得到類似下面這樣的結果:

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)

請注意, 在后兩次迭代拋出 java.lang.OutOfMemoryError: Requested array size exceeds VM limit 錯誤之前, 先拋出了2次 java.lang.OutOfMemoryError: Java heap space 錯誤。 這是因為 2^31-1 個 int 數占用的內存超過了JVM默認的8GB堆內存。

此示例也展示了這個錯誤比較罕見的原因 —— 要取得JVM對數組大小的限制, 要分配長度差不多等於 Integer.MAX_INT 的數組. 這個示例運行在64位的Mac OS X, Hotspot 7平台時, 只有兩個長度會拋出這個錯誤: Integer.MAX_INT-1Integer.MAX_INT

7.3 解決方案

發生 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”。

如果確實需要處理超大數據集, 那就要考慮調整解決方案了. 例如拆分成多個小塊,按批次加載; 或者放棄使用標准庫,而是自己處理數據結構,比如使用 sun.misc.Unsafe 類, 通過Unsafe工具類可以像C語言一樣直接分配內存。

8. OutOfMemoryError系列: Kill process or sacrifice child

一言不合就殺進程。。。

為了理解這個錯誤,我們先回顧一下操作系統相關的基礎知識。

我們知道, 操作系統(operating system)構建在進程(process)的基礎上. 進程由內核作業(kernel jobs)進行調度和維護, 其中有一個內核作業稱為 “Out of memory killer(OOM終結者)”, 與本節所講的 OutOfMemoryError 有關。

Out of memory killer 在可用內存極低的情況下會殺死某些進程。只要達到觸發條件就會激活, 選中某個進程並殺掉。 通常采用啟發式算法, 對所有進程計算評分(heuristics scoring), 得分最低的進程將被 kill 掉。因此 Out of memory: Kill process or sacrifice child 和前面所講的 OutOfMemoryError 都不同, 因為它既不由JVM觸發,也不由JVM代理, 而是系統內核內置的一種安全保護措施。

如果可用內存(含swap)不足, 就有可能會影響系統穩定, 這時候 Out of memory killer 就會設法找出流氓進程並殺死他, 也就是引起 Out of memory: kill process or sacrifice child 錯誤。

8.1 原因分析

默認情況下, Linux kernels(內核)允許進程申請的量超過系統可用內存. 這是因為,在大多數情況下, 很多進程申請了很多內存, 但實際使用的量並沒有那么多.
有個簡單的類比, 寬帶租賃的服務商, 可能他的總帶寬只有 10Gbps, 但卻賣出遠遠超過100份以上的 100Mbps 帶寬, 原因是多數時候, 寬帶用戶之間是錯峰的, 而且不可能每個用戶都用滿服務商所承諾的帶寬。

這樣的話,可能會有一個問題, 假若某些程序占用了大量的系統內存, 那么可用內存量就會極小, 導致沒有內存頁面(pages)可以分配給需要的進程。可能這時候會出現極端情況, 就是 root 用戶也不能通過 kill 來殺掉流氓進程. 為了防止發生這種情況, 系統會自動激活 killer, 查找流氓進程並將其殺死。

更多關於 ”Out of memory killer“ 的性能調優細節, 請參考: RedHat 官方文檔.

現在我們知道了為什么會發生這種問題, 那為什么是半夜5點鍾觸發 “killer” 發報警信息給你呢? 通常觸發的原因在於操作系統配置. 例如, /proc/sys/vm/overcommit_memory 配置文件的值, 指定了是否允許所有的 malloc() 調用成功. 請注意, 在各操作系統中, 這個配置對應的 proc 文件路徑可能不同。

過量使用(overcommitting)配置, 允許流氓進程申請越來越多的內存, 最終惹得 ”Out of memory killer“ 出來搞事情。

8.2 示例

在Linux上(如最新穩定版的Ubuntu)編譯並執行以下的示例代碼:

package eu.plumbr.demo;

public class OOM {

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

將會在系統日志中(如 /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

提示: 可能需要調整 swap 的大小並設置最大堆內存, 例如堆內存配置為 -Xmx2g, swap 配置如下:

swapoff -a 
dd if=/dev/zero of=swapfile bs=1024 count=655360
mkswap swapfile
swapon swapfile

8.3 解決方案

有多種處理辦法。最簡單的辦法就是將系統遷移到內存更大的實例中。

另外, 還可以通過 OOM killer 調優, 或者做負載均衡(水平擴展,集群), 或者降低應用對內存的需求。

不太推薦的方案是加大交換空間/虛擬內存(swap space)。 試想一下, Java 包含了自動垃圾回收機制, 增加交換內存的代價會很高昂. 現代GC算法在處理物理內存時性能飛快, 但對交換內存來說,其效率就是硬傷了. 交換內存可能導致GC暫停的時間增長幾個數量級, 因此在采用這個方案之前, 看看是否真的有這個必要。

其他資源


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM