一句話先說結論:
OutOfMemoryError: Java heap space不是單純的「記憶體不夠」,是 GC 反覆回收後仍騰不出空間。八成是物件被意外強引用,堆裡塞滿了本該死掉的東西。先加-XX:+HeapDumpOnOutOfMemoryError留堆快照,再用 MAT 的 Dominator Tree 順著 GC Roots 找到最大記憶體佔用者。-Xmx調大隻是緩兵之計,洩漏根源在引用關係沒斷。
背景
JVM 把物件放在堆裡,垃圾回收器(Garbage Collector,簡稱 GC)負責清理沒人引用的物件。正常情況下,「活物件」數量穩定,GC 一跑就有空間讓出來。
當某個物件本該死掉、卻一直被強引用拽著,GC 就收不走它。日積月累,堆被填滿,想分配新物件時 JVM 只能拋 OutOfMemoryError。
GC 能正常運行,也收不回足夠空間,這是堆溢出的典型特徵。它和「堆外記憶體不足」「元空間不足」是兩回事,別一看到 OOM 就下結論。
現象
報錯原文通常長這樣,關鍵在後面那串 detail message:
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
at com.example.service.ReportService.export(ReportService.java:42)
at com.example.controller.ReportController.download(ReportController.java:28)
at com.example.controller.ReportController.handleRequest(ReportController.java:19)
at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62)
at java.lang.reflect.Method.invoke(Method.java:498)
Java heap space 這幾個字說明:物件在 Java 堆裡分配不出來。
同一個 OutOfMemoryError 還有別的變體,含義完全不同:
java.lang.OutOfMemoryError: GC Overhead limit exceeded
這一句的意思是 GC 幾乎全程在跑,程式卻沒怎麼前進,回收釋放的記憶體極少。它不是堆裡塞滿大物件,而是「回收成本已經高到不划算」。
java.lang.OutOfMemoryError: Metaspace
這個和堆沒關係,是元空間滿。裡面裝的是類定義和方法元數據。
復現堆溢出最典型的一條路:程式啟動正常,跑著跑著越來越慢,GC 頻率越來越高,最後某個請求直接甩出上面那段堆疊。它帶有「隨時間惡化」的曲線,而不是瞬間崩。
根因分析
堆溢出只有兩個根因,先分清是哪個再動手。
第一,堆確實太小。-Xmx 配的容量撐不起業務的峰值,或者乾脆用了預設值。這種情況不是洩漏,是設定問題,調大就好。
第二,記憶體洩漏。物件本該被回收,卻因為某個強引用被長期按住。這是長跑應用裡最常見的誘因,Oracle 官方文檔把它稱作「Java 語言意義上的記憶體洩漏」。常見模式有這幾種:
- 靜態集合當緩存,只進不出。
static Map一直往裡塞,沒有淘汰策略。 ThreadLocal用執行緒池後忘了remove(),value 跟著執行緒一直活著。- 監聽器、回調、
@EventListener註冊後不解綁,把物件拖成 GC Root 可達。 - 流和連接不關,
Statement、ResultSet、CloseableHttpClient忘了close()。 - Spring Bean 作用域配錯,把
prototype或 request 級物件塞進單例,生命週期被無限拉長。
第三類比較隱蔽,和 finalize 有關。物件有 finalize 方法時,GC 不會當場回收,而是先丟進終結隊列等守護執行緒處理。如果終結隊列堆積速度超過處理速度,堆照樣被填滿。
解決方案
先留證據:讓 JVM 崩的時候自己 dump
這一步必須在線上提前配好。崩潰時自動留堆快照,事後才能離線分析:
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/dumps/heap.hprof
跑在容器裡的話,把 HeapDumpPath 指向掛載卷,別讓快照跟著容器一起沒了。
沒有提前配,也可以手動抓快照:
jmap -dump:format=b,file=heap.hprof <pid>
離線分析:MAT 順著 GC Roots 找元兇
拿到 .hprof 後用 Eclipse MAT 打開,三個視圖輪流看:
Dominator Tree:按「誰支配最多記憶體」排序,一眼看到最大記憶體佔用者。Histogram:按類統計實例數,某個類實例數量例外地多,就是嫌疑。Path to GC Roots:選中一個大物件,看它被誰強引用著,這條鏈的源頭往往就是洩漏點。
MAT 是免費開源的,如果不是記憶體特別吃緊,首選它。JProfiler 能力更強,但是商業軟件。
程式碼層修復:斷引用
堆快照告訴你洩漏點在哪個類,接下來是從程式碼上斷掉引用:
靜態緩存要有淘汰機制,換成 Caffeine 或包一層 WeakReference:
// 之前:只进不出,早晚爆
static Map<String, byte[]> cache = new HashMap<>();
// 之后:交给 Caffeine 管理容量和过期
Cache<String, byte[]> cache = Caffeine.newBuilder()
.maximumSize(10_000)
.expireAfterWrite(10, TimeUnit.MINUTES)
.build();
ThreadLocal 用完必須 remove(),標準寫法是放進 finally:
ThreadLocal<SimpleDateFormat> tl = ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyy-MM-dd"));
try {
String s = tl.get().format(new Date());
} finally {
tl.remove(); // 线程池复用下,不 remove 就泄漏
}
流和連接統一走 try-with-resources,編譯器幫你 close():
try (Connection conn = dataSource.getConnection();
PreparedStatement ps = conn.prepareStatement(sql);
ResultSet rs = ps.executeQuery()) {
// ...
}
觀察趨勢:別隻會看崩潰那一刻
處理完一層後,用 jstat 盯著老年代會不會穩定回落:
jstat -gcutil <pid> 5s
重點看 O(老年代佔用率)和 FGC(Full GC 次數)。修復前 O 一路爬升、FGC 越來越頻繁;修復後 O 應該在 Full GC 後明顯掉下來。
思考過程
排查時最容易踩的兩個坑。
一個是盲目 System.gc()。它既不保證執行,也斷不了根上的強引用鏈,等於給症狀打止痛針。
另一個是盲目調大 -Xmx。洩漏不除,堆再大也是慢性自愈,只是把崩潰的時間往後拖。堆越大,Full GC 卡頓越久,反而更難受。
分清楚 Java heap space、GC Overhead limit exceeded、Metaspace 這三段 detail message,能少走很多彎路。三者的治療方向完全不同:第一種增堆或查洩漏,第二種重寫熱路徑或查是否在堆裡囤垃圾,第三種清類和元數據。
定位方法論就一句:堆快照是你唯一可信的物證,MAT 的 Path to GC Roots 是把你領到洩漏點的那根線。
小結
OutOfMemoryError: Java heap space就兩個根因,堆太小,或者物件洩漏。- 線上一定先配
-XX:+HeapDumpOnOutOfMemoryError,沒有快照就沒法破案。 - MAT 看
Dominator Tree加Path to GC Roots,是定位洩漏的標準組合。 -Xmx調大和System.gc()都治標不治本,別當藥吃。
