Java 記憶體洩漏:常見原因與偵測方法
你的 Java 應用程式啟動很快,但經過數小時或數天後,效能慢如蝸牛、拋出 OutOfMemoryError,或被容器終止。你重新啟動它,然後循環再次發生。這就是 Java 記憶體洩漏的典型特徵:不再需要的物件仍然可被存取,因此垃圾回收器無法回收它們。與 C/C++ 不同,Java 的 GC 會自動處理大部分記憶體,但當引用被無意中保留時,洩漏仍然會發生。本文說明最常見的原因,並提供實用的工作流程來偵測和修復它們。
什麼是 Java 記憶體洩漏?
當物件不再被應用程式使用,但仍然被引用,導致無法進行垃圾回收時,就會發生記憶體洩漏。隨著時間推移,堆積使用量增長,GC 執行得更頻繁,最終 JVM 拋出 OutOfMemoryError: Java heap space。洩漏可能很小且緩慢(每次請求幾 KB),也可能很大且快速(快取整個結果集)。
Java 記憶體洩漏的常見原因
1. 永遠增長的靜態集合
靜態欄位的生命週期與 JVM 相同。一個用作快取但沒有淘汰機制的靜態 Map 是經典的洩漏。例如:
public class UserCache {
private static final Map<Long, User> CACHE = new HashMap<>();
public static void put(Long id, User user) {
CACHE.put(id, user); // 永遠不會被移除
}
}
每個新增的使用者都會永遠留在裡面。修復方法:使用有大小限制和過期機制的有界快取,例如 Caffeine 或 Guava。
2. 未關閉的資源
串流、連線和讀取器必須關閉。如果你忘記了,原生資源及其 Java 包裝器就會洩漏。請始終使用 try-with-resources:
try (InputStream in = new FileInputStream(file)) {
// 使用 in
} // 自動關閉
這也適用於資料庫連線、HTTP 客戶端和執行緒池。
3. 未移除的監聽器和回呼
當你註冊監聽器(例如 addListener)但從未移除它時,發布者會持有對監聽器的引用,而該監聽器可能持有對你整個物件圖的引用。這在 GUI、事件總線和長生命週期上下文中很常見。
4. ThreadLocal 變數
ThreadLocal 值儲存在執行緒的 ThreadLocalMap 中。在執行緒池中,執行緒會被重複使用,因此如果你不呼叫 remove(),該值會一直附加在執行緒上並造成洩漏。請務必清理:
try {
threadLocal.set(context);
// 工作
} finally {
threadLocal.remove();
}
5. 持有外部引用的內部類別
非靜態內部類別(包括匿名類別)會隱式持有對外部實例的引用。如果內部類別實例的生命週期比外部物件長(例如儲存在靜態列表中),則外部物件無法被回收。當內部類別不需要外部實例時,請將其設為靜態。
6. 不正確的 equals() 和 hashCode()
如果你將物件用作 HashMap 的鍵,但覆寫了 equals() 而沒有覆寫 hashCode()(或反之),查找會失敗,項目會累積。請始終一致地覆寫兩者。
7. 字串駐留和子字串
在 Java 7 之前,String.substring() 會共享原始 char 陣列,因此一個小的子字串可能持有大型陣列。現代 Java 會複製,但駐留許多唯一字串(例如 String.intern())仍然可能填滿字串池。
如何偵測 Java 記憶體洩漏
偵測遵循系統化的流程。以下是逐步方法:
- 隨時間監控堆積使用量。 使用 JVisualVM、JConsole 或你的 APM。健康的應用程式會顯示鋸齒狀模式:堆積增長、GC 回收、堆積下降。洩漏則會在每次 GC 後顯示基線上升。
- 啟用 GC 日誌。 新增
-Xlog:gc*:file=gc.log:time,uptime,level,tags(Java 9+)或-XX:+PrintGCDetails(Java 8)。使用 GCeasy 等工具分析日誌,查看老年代是否持續增長。 - 取得堆積傾印。 當堆積很高時,執行
jmap -dump:live,format=b,file=heap.hprof <pid>或使用jcmd <pid> GC.heap_dump heap.hprof。你也可以使用-XX:+HeapDumpOnOutOfMemoryError在發生 OutOfMemoryError 時觸發傾印。 - 分析堆積傾印。 在 Eclipse MAT 或 VisualVM 中開啟它。查看支配樹以找出保留最多記憶體的物件。檢查是否有保留大小異常大的可疑集合。
- 取得第二個傾印並比較。 如果相同的物件在兩次傾印之間增長,你就找到了洩漏。
- 使用分析工具進行即時分析。 YourKit、JProfiler 或 async-profiler 等工具可以在不停止應用程式的情況下追蹤分配位置和引用鏈。
以下是常見工具的快速比較:
| 工具 | 類型 | 最適合 |
|---|---|---|
| Eclipse MAT | 堆積傾印分析器 | 尋找支配者和洩漏嫌疑 |
| VisualVM | 監控 + 堆積傾印 | 快速即時檢查和基本分析 |
| JProfiler / YourKit | 分析工具 | 分配追蹤和引用鏈 |
| async-profiler | 低開銷分析工具 | 使用火焰圖進行生產環境分析 |
修復和預防洩漏
一旦你識別出洩漏,就透過移除無意的引用來修復它。常見的修復方法:
- 將無界快取替換為有界快取(Caffeine、Ehcache)。
- 對所有 Closeable 資源使用 try-with-resources。
- 在 finally 區塊中取消註冊監聽器,或使用弱引用。
- 始終呼叫
ThreadLocal.remove()。 - 盡可能將內部類別設為靜態。
- 同時覆寫 equals() 和 hashCode()。
預防勝於治療。將記憶體監控新增到你的 CI/CD 流程中,執行帶有堆積檢查的負載測試,並適當設定 -Xmx。使用 SpotBugs 等靜態分析工具來捕捉常見模式。
常見問題
記憶體洩漏和記憶體尖峰有什麼區別?
記憶體尖峰是堆積使用量的暫時增加,GC 會回收它。洩漏則是隨時間穩定增加,因為物件仍然可被存取且無法被回收。
即使堆積很大,Java 記憶體洩漏也會導致 OutOfMemoryError 嗎?
是的。如果洩漏速率超過 GC 的回收能力,無論堆積大小如何,最終都會填滿。增加堆積只會延遲失敗。
如何在不停機的情況下找到生產環境中的記憶體洩漏?
使用低開銷工具,如 async-profiler 或 JFR(Java Flight Recorder)來記錄分配和堆積統計。你也可以在應用程式運行時使用 jcmd 觸發堆積傾印,儘管它可能會短暫暫停。
需要分析 GC 日誌或其他文字日誌嗎?試試我們的 Nginx 日誌分析器,快速解析和視覺化日誌模式。