1. 程式人生 > >OutOfMemoryError系列(1): Java heap space

OutOfMemoryError系列(1): Java heap space

這是本系列的第一篇文章, 相關文章列表:

每個Java程式都只能使用一定量的記憶體, 這種限制是由JVM的啟動引數決定的。而更復雜的情況在於, Java程式的記憶體分為兩部分: 堆記憶體(Heap space)和 永久代(Permanent Generation, 簡稱 Permgen):

01_01_java-heap-space.png

這兩個區域的最大記憶體大小, 由JVM啟動引數 -Xmx-XX:MaxPermSize 指定. 如果沒有明確指定, 則根據平臺型別(OS版本+ JVM版本)和實體記憶體的大小來確定。

假如在建立新的物件時, 堆記憶體中的空間不足以存放新建立的物件, 就會引發java.lang.OutOfMemoryError: Java heap space

錯誤。

不管機器上還沒有空閒的實體記憶體, 只要堆記憶體使用量達到最大記憶體限制,就會丟擲 java.lang.OutOfMemoryError: Java heap space 錯誤。

原因分析

產生 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 錯誤就爆發了。

具體示例

一個非常簡單的示例

以下程式碼非常簡單, 程式試圖分配容量為 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];
    }
}

記憶體洩漏示例

這個示例更真實一些。在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。

隨著時間推移, “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;
}

說實話, 在尋找真正的記憶體洩漏原因時, 你可能會死掉很多很多的腦細胞。

一個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 引數即可。

解決方案

如果設定的最大記憶體不滿足程式的正常執行, 只需要增大堆記憶體即可, 配置引數可以參考下文。

但很多情況下, 增加堆記憶體空間並不能解決問題。比如存在記憶體洩漏, 增加堆記憶體只會推遲 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機制的理解。

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

Plumbr 在後臺負責收集資料 —— 包括堆記憶體使用情況(只統計物件分佈圖, 不涉及實際資料),以及在堆轉儲中不容易發現的各種問題。 如果發生 java.lang.OutOfMemoryError , 還能在不停機的情況下, 做必要的資料處理. 下面是Plumbr 對一個 java.lang.OutOfMemoryError 的提醒:

01_02_outofmemoryerror-analyzed.png

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

  • 哪類物件佔用了最多的記憶體(此處是 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 

翻譯日期: 2017年7月29日