首頁
編程語言
C語言|JAVA編程
Python編程
網頁編程
ASP編程|PHP編程
JSP編程
數據庫
MYSQL數據庫|SqlServer數據庫
Oracle數據庫|DB2數據庫
您现在的位置: 程式碼之家 >> 編程語言 >  >> Java 程式設計 >> Java 相關知識

Java 記憶體洩漏實戰:兩種經典 OOM 場景的構建與解析

     編輯:Java 相關知識

前言:為什麼要主動製造記憶體溢出?

在 Java 開發中,OutOfMemoryError 往往是讓開發者聞風喪膽的「噩夢」。它不僅難以重現,排查起來更是耗時費力。但若能主動在測試環境中構造出各類 OOM 場景,並深入理解其成因,便能在生產環境發生時迅速定位問題。本文將帶你動手實作兩種經典且極具代表性的記憶體溢出案例,並剖析其背後的核心機制。

案例一:HashMap 鍵值對象缺失 equals() 導致的無限增長

問題代碼

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

為什麼會 OOM?

乍看之下,這段代碼似乎只會向 Map 中放入 10,000 個鍵值對,之後 containsKey 應該總是返回 true,循環應停止增長。然而現實是:元素會無限制增長,最終撐爆堆內存。

根因在於 Key 類重寫了 hashCode() 卻沒有重寫 equals()。根據 HashMap 的查找邏輯:先比較 hash 值,再調用 equals() 確認鍵是否相同。若缺少 equals(),預設使用 Object.equals()(即比較引用地址)。每次 `new Key(i)` 都會產生新對象,引用不同,導致 containsKey 永遠返回 false,重複插入同樣邏輯鍵的不同實例,Map 體積無限膨脹。

修正方案

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

補上 equals() 後,邏輯鍵相同的對象即可被正確識別,Map 大小穩定在 10,000。這也是《Effective Java》中「覆寫 equals() 時必須覆寫 hashCode()」鐵律的反面教材。


案例二:JDK 版本差異引發的 substring() 隱性引用

問題代碼

class Stringer {
    static final int MB = 1024 * 512; // 512KB
    static String createLongString(int length) {
        StringBuilder sb = new StringBuilder(length);
        for (int i = 0; i < length; i++) sb.append('a');
        sb.append(System.nanoTime());
        return sb.toString();
    }

    public static void main(String[] args) {
        List<String> substrings = new ArrayList<>();
        for (int i = 0; i < 100; i++) {
            String longStr = createLongString(MB);
            String subStr = longStr.substring(1, 10);
            substrings.add(subStr);
        }
    }
}

現象與成因

在 JDK 7u6 之前,String.substring() 採用「共享底層 char[] 數組 + offset/count」的設計。上述代碼中,雖然只保留了 9 字符的子串,但每個子串都隱性持有對 512KB 原始大字符串的引用。若以 -Xmx100m 啟動,100 個子串即會佔用約 50MB,加上其他開銷極易觸發 OOM。

更隱蔽的是:測試環境與生產環境 JDK 微版本不同,可能導致「本地跑得好好的,上線就炸」的詭異現象。JDK 7u6 之後,substring() 改為複製新數組,此問題自動消失。

應對策略

  • 生產環境統一 JDK 版本,避免微版本差異
  • 若必須在舊版本運行,可用 new String(longStr.substring(...)) 強制複製
  • 使用 String.intern() 或顯式構建新字符串切斷引用鏈

進階「陷阱」構思:讓排查更棘手的三種手法

若想在代碼審查或測試中植入更隱蔽的記憶體炸彈,可考慮以下方向:

  1. 類加載器洩漏:在自定義類加載器中加載惡意類,協載後仍持有引用,模擬 Metaspace OOM。
  2. finalize() 延遲炸彈:在 finalize() 中執行耗時/分配大對象操作,導致 Finalizer 線程阻塞,間接引發 OOM,且堆棧極難追溯。
  3. ThreadLocal 污染線程池:在長生命周期線程(如 Tomcat 處理請求的 Worker 線程)的 ThreadLocal 中塞入大對象,且不清理,隨著線程複用導致洩漏持續累積。

總結與工具建議

記憶體溢出雖可怕,但掌握其經典模式、善用工具即可化險為夷。除上述手動構造外,強烈建議在專案中接入 Plumbr、Arthas、MAT (Memory Analyzer Tool) 等自動化診斷工具,將「人肉排查」升級為「秒級定位」。

理解這兩個案例的核心——對象生命周期管理失控與JDK 內部實現變更帶來的行為差異,將讓你在面對生產環境 OOM 時多一份從容,少幾夜失眠。

  1. 上一篇文章:
  2. 下一篇文章:
Copyright © 程式碼之家 All Rights Reserved