它和
Java heap space不是一回事:堆不一定真滿了,而是「GC 拼命幹、卻回收不出東西」。JVM 用這個錯誤做快速失敗,避免進程陷入「CPU 100% 跑 GC、業務毫秒變分鐘」的死循環。既然根子還是堆,就別先想著關掉這個檢查,先查漏不動。
Issue & Env(案發現場與環境)
- 現象:應用運行緩慢,CPU 長時間打滿,最終拋 OOM,日誌裡帶
GC overhead limit exceeded。 - 環境:Java 8+,使用任何 GC(Parallel / G1 等),常在堆偏小或存在記憶體洩漏時觸發。
StackTrace(堆疊日誌)
java.lang.OutOfMemoryError: GC overhead limit exceeded
at java.base/java.util.HashMap.resize(HashMap.java:700)
at java.base/java.util.HashMap.putVal(HashMap.java:658)
at com.example.cache.InMemoryCache.put(InMemoryCache.java:93)
at com.example.cache.InMemoryCache.load(InMemoryCache.java:65)
at com.example.service.ProductService.getProduct(ProductService.java:41)
at com.example.controller.ProductController.detail(ProductController.java:28)
at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
at java.lang.reflect.Method.invoke(Method.java:498)
注意堆疊頂部往往是普通的業務分配點,真正的信號是那句「GC overhead limit exceeded」本身。
Reproduce(復現程式碼)
一個無界緩存,不停往裡塞物件,物件還被強引用持有:
public class LeakyCache {
static final Map<String, byte[]> CACHE = new HashMap<>();
public static void main(String[] args) {
for (int i = 0; ; i++) {
CACHE.put("key" + i, new byte[1024 * 1024]); // 每人 1MB,强引用不释放
}
}
}
跑上一陣,堆被活物件填滿,GC 每次只能回收一點點,最終拋 GC overhead limit exceeded。
Root Cause(根因分析)
JVM 的觸發條件是:GC 花費的時間超過總運行時間的 98%,而且每次 GC 回收的堆不足 2%。滿足這兩條時,JVM 認為進程已經「近乎停擺」,乾脆拋錯快速失敗,而不是讓你卡著。
它和「記憶體真不夠」的區別在於成因:如果只是堆配小了、但物件都是短命的,GC 還能正常回收,你看到的是 Java heap space;而 GC overhead 通常是堆裡積壓了大量「長時間存活但最終無意義」的物件,每次 GC 掃一圈幾乎騰不出地方,只能反覆空轉。
典型誘因有:無界緩存、靜態集合不斷追加、事件監聽器 / 回調註冊後從不註銷、大物件被一個長生命週期集合抓著不放。所以它本質是洩漏或「活數據超過堆容量」的信號,而不是單純調大堆就能糊弄過去。
The Fix(解決方案)
先開堆轉儲,等下次 OOM 時拿到現場:
java -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/tmp/dumps -jar app.jar
拿到 .hprof 後用 Eclipse MAT 打開,看 Leak Suspects 報告:如果是一個物件或集合主導了堆,那就是洩漏,回到程式碼把引用解開;如果是一大堆正常物件、堆確實裝不下,才考慮加大堆。
真需要加堆或換收集器:
# 加堆
java -Xms2g -Xmx4g -jar app.jar
# 大堆换 G1 / ZGC,减少停顿和开销
java -XX:+UseG1GC -Xmx4g -jar app.jar
java -XX:+UseZGC -Xmx4g -jar app.jar
臨時的 -XX:-UseGCOverheadLimit 只是關掉這個「快速失敗」的保護,讓進程繼續帶著近乎癱瘓的狀態跑。可以拿來臨時抓現場,但絕不能當長期方案——它不解決任何記憶體問題,只是讓你晚一點崩潰、更難看。
