尋找 Java 內存洩漏

已發表: 2022-03-11

沒有經驗的程序員通常認為 Java 的自動垃圾收集完全讓他們不必擔心內存管理。 這是一個常見的誤解:雖然垃圾收集器盡了最大的努力,但即使是最優秀的程序員也完全有可能成為嚴重內存洩漏的犧牲品。 讓我解釋。

當不必要地維護不再需要的對象引用時,就會發生內存洩漏。 這些洩漏很糟糕。 一方面,當你的程序消耗越來越多的資源時,它們會給你的機器帶來不必要的壓力。 更糟糕的是,檢測這些洩漏可能很困難:靜態分析通常難以準確識別這些冗餘引用,而現有的洩漏檢測工具會跟踪和報告有關單個對象的細粒度信息,產生難以解釋且缺乏精確度的結果。

換句話說,洩漏要么太難識別,要么用過於具體而無用的術語來識別。

實際上有四類記憶問題,症狀相似且重疊,但原因和解決方案各不相同:

  • 性能:通常與過多的對象創建和刪除、垃圾收集的長時間延遲、過多的操作系統頁面交換等有關。

  • 資源限制:當可用內存太少或內存太碎片而無法分配大對象時發生——這可能是本機的,或者更常見的是與 Java 堆相關的。

  • Java 堆洩漏:經典的內存洩漏,Java 對像被不斷地創建而不被釋放。 這通常是由潛在對象引用引起的。

  • 機內存洩漏:與 Java 堆之外的任何持續增長的內存利用率相關,例如由 JNI 代碼、驅動程序甚至 JVM 分配進行的分配。

在本內存管理教程中,我將重點關注 Java 堆洩漏,並概述一種基於Java VisualVM報告檢測此類洩漏的方法,並利用可視化界面在基於 Java 技術的應用程序運行時對其進行分析。

但在預防和發現內存洩漏之前,您應該了解它們發生的方式和原因。 (注意:如果你對錯綜複雜的內存洩漏有很好的處理,你可以跳過。

內存洩漏:入門

對於初學者,可以將內存洩漏視為一種疾病,將 Java 的OutOfMemoryError (OOM,為簡潔起見)視為一種症狀。 但與任何疾病一樣,並非所有 OOM 都必然意味著內存洩漏:由於大量局部變量或其他此類事件的生成,可能會發生 OOM。 另一方面,並非所有內存洩漏都必然表現為 OOM ,尤其是在桌面應用程序或客戶端應用程序的情況下(它們不會在不重新啟動的情況下運行很長時間)。

將內存洩漏視為一種疾病,將 OutOfMemoryError 視為一種症狀。 但並不是所有的 OutOfMemoryErrors 都意味著內存洩漏,也不是所有的內存洩漏都表現為 OutOfMemoryErrors。

為什麼這些洩漏如此嚴重? 除此之外,程序執行期間內存塊的洩漏通常會隨著時間的推移降低系統性能,因為一旦系統用完可用的物理內存,就必須換出已分配但未使用的內存塊。 最終,程序甚至可能耗盡其可用的虛擬地址空間,從而導致 OOM。

破譯OutOfMemoryError

如上所述,OOM 是內存洩漏的常見跡象。 本質上,當沒有足夠的空間來分配新對象時會引發錯誤。 盡其所能,垃圾收集器找不到必要的空間,堆也無法進一步擴展。 因此,會出現錯誤以及堆棧跟踪。

診斷 OOM 的第一步是確定錯誤的實際含義。 這聽起來很明顯,但答案並不總是那麼清楚。 比如:出現OOM是因為Java堆滿了,還是因為原生堆滿了? 為了幫助您回答這個問題,讓我們分析一些可能的錯誤消息:

  • java.lang.OutOfMemoryError: Java heap space

  • java.lang.OutOfMemoryError: PermGen space

  • java.lang.OutOfMemoryError: Requested array size exceeds VM limit

  • java.lang.OutOfMemoryError: request <size> bytes for <reason>. Out of swap space?

  • java.lang.OutOfMemoryError: <reason> <stack trace> (Native method)

“Java 堆空間”

此錯誤消息並不一定意味著內存洩漏。 事實上,問題可以像配置問題一樣簡單。

例如,我負責分析一個始終產生這種類型的OutOfMemoryError的應用程序。 經過一番調查,我發現罪魁禍首是一個需要太多內存的數組實例化; 在這種情況下,這不是應用程序的錯,而是應用程序服務器依賴於默認的堆大小,它太小了。 我通過調整JVM的內存參數解決了這個問題。

在其他情況下,特別是對於長期存在的應用程序,該消息可能表明我們無意中持有對對象的引用,從而阻止垃圾收集器清理它們。 這是內存洩漏的 Java 語言等價物。 (注意:應用程序調用的 API 也可能無意中持有對象引用。

這些“Java 堆空間”OOM 的另一個潛在來源是使用終結器。 如果一個類有一個finalize方法,那麼該類型的對像在垃圾回收時不會回收它們的空間。 相反,在垃圾回收之後,對象會排隊等待最終確定,這將在稍後發生。 在 Sun 實現中,終結器由守護線程執行。 如果終結器線程無法跟上終結隊列,則 Java 堆可能會填滿並可能引發 OOM。

“永久代空間”

此錯誤消息表明永久代已滿。 永久代是堆中存儲類和方法對象的區域。 如果應用程序加載大量類,則可能需要使用-XX:MaxPermSize選項增加永久代的大小。

內部的java.lang.String對像也存儲在永久代中。 java.lang.String類維護一個字符串池。 調用 intern 方法時,該方法會檢查池以查看是否存在等效字符串。 如果是,則由 intern 方法返回; 如果不是,則將該字符串添加到池中。 更準確地說, java.lang.String.intern方法返回字符串的規範表示; 結果是對同一類實例的引用,如果該字符串以文字形式出現,則會返回該類實例。 如果應用程序實習生大量字符串,您可能需要增加永久代的大小。

注意:您可以使用jmap -permgen命令打印與永久代相關的統計信息,包括有關內部化 String 實例的信息。

“請求的陣列大小超出 VM 限制”

此錯誤表明應用程序(或該應用程序使用的 API)試圖分配一個大於堆大小的數組。 例如,如果應用程序嘗試分配 512MB 的數組,但最大堆大小為 256MB,則將引發 OOM 並顯示此錯誤消息。 在大多數情況下,問題要么是配置問題,要么是應用程序嘗試分配大量數組時產生的錯誤。

“為 <reason> 請求 <size> 個字節。 交換空間不足?”

此消息似乎是 OOM。 但是,當從本機堆分配失敗並且本機堆可能接近耗盡時,HotSpot VM 會拋出這個明顯的異常。 消息中包括失敗請求的大小(以字節為單位)和內存請求的原因。 在大多數情況下,<reason> 是報告分配失敗的源模塊的名稱。

如果拋出這種類型的 OOM,您可能需要使用操作系統上的故障排除實用程序來進一步診斷問題。 在某些情況下,問題甚至可能與應用程序無關。 例如,如果出現以下情況,您可能會看到此錯誤:

  • 操作系統配置的交換空間不足。

  • 系統上的另一個進程正在消耗所有可用的內存資源。

應用程序也可能由於本機洩漏而失敗(例如,如果某些應用程序或庫代碼不斷分配內存但未能將其釋放到操作系統)。

<原因> <堆棧跟踪>(本機方法)

如果您看到此錯誤消息並且堆棧跟踪的頂部框架是本機方法,則該本機方法遇到分配失敗。 此消息與上一條消息的不同之處在於,Java 內存分配失敗是在 JNI 或本機方法中檢測到的,而不是在 Java VM 代碼中檢測到的。

如果拋出這種類型的 OOM,您可能需要使用操作系統上的實用程序來進一步診斷問題。

沒有 OOM 的應用程序崩潰

有時,應用程序可能會在本機堆分配失敗後很快崩潰。 如果您運行的本機代碼不檢查內存分配函數返回的錯誤,則會發生這種情況。

例如,如果沒有可用內存, malloc系統調用將返回NULL 。 如果未檢查malloc的返回,則應用程序在嘗試訪問無效內存位置時可能會崩潰。 根據具體情況,此類問題可能難以定位。

在某些情況下,來自致命錯誤日誌或故障轉儲的信息就足夠了。 如果確定崩潰的原因是在某些內存分配中缺乏錯誤處理,那麼您必須找出所述分配失敗的原因。 與任何其他本機堆問題一樣,系統可能配置為交換空間不足,另一個進程可能正在消耗所有可用內存資源等。

診斷洩漏

在大多數情況下,診斷內存洩漏需要非常詳細的應用程序知識。 警告:該過程可能會很漫長且反复。

我們尋找內存洩漏的策略將相對簡單:

  1. 識別症狀

  2. 啟用詳細垃圾收集

  3. 啟用分析

  4. 分析軌跡

1. 識別症狀

正如所討論的,在許多情況下,Java 進程最終會拋出 OOM 運行時異常,這清楚地表明您的內存資源已經耗盡。 在這種情況下,您需要區分正常的內存耗盡和洩漏。 分析 OOM 的消息,並根據上面提供的討論嘗試找出罪魁禍首。

通常,如果 Java 應用程序請求的存儲空間比運行時堆提供的更多,可能是由於設計不佳。 例如,如果應用程序創建圖像的多個副本或將文件加載到數組中,則當圖像或文件非常大時,它將耗盡存儲空間。 這是正常的資源耗盡。 該應用程序正在按設計工作(儘管這種設計顯然是愚蠢的)。

但是,如果應用程序在處理相同類型的數據時穩步增加其內存利用率,則可能會出現內存洩漏。

2.啟用詳細垃圾收集

斷言您確實存在內存洩漏的最快方法之一是啟用詳細垃圾收集。 內存約束問題通常可以通過檢查verbosegc輸出中的模式來識別。

具體來說, -verbosegc參數允許您在每次垃圾收集 (GC) 進程開始時生成跟踪。 也就是說,當內存被垃圾收集時,摘要報告會打印為標準錯誤,讓您了解內存的管理方式。

以下是使用–verbosegc選項生成的一些典型輸出:

詳細的垃圾收集輸出

此 GC 跟踪文件中的每個塊(或節)按遞增順序編號。 要理解此跟踪,您應該查看連續的分配失敗節,並查找釋放的內存(字節和百分比)隨著時間的推移而減少,而總內存(此處為 19725304)正在增加。 這些是內存耗盡的典型跡象。

3.啟用分析

不同的 JVM 提供了不同的方法來生成跟踪文件以反映堆活動,這些跟踪文件通常包括有關對像類型和大小的詳細信息。 這稱為分析堆

4. 分析痕跡

本文重點介紹 Java VisualVM 生成的跟踪。 跟踪可以有不同的格式,因為它們可以由不同的 Java 內存洩漏檢測工俱生成,但它們背後的想法總是相同的:在堆中找到一個不應該存在的對象塊,並確定這些對像是否累積而不是釋放。 特別感興趣的是已知每次在 Java 應用程序中觸發某個事件時分配的瞬態對象。 許多本應僅以少量存在的對象實例的存在通常表明存在應用程序錯誤。

最後,解決內存洩漏需要您徹底檢查您的代碼。 了解對象洩漏的類型非常有幫助,並且可以大大加快調試速度。

垃圾回收在 JVM 中是如何工作的?

在開始分析存在內存洩漏問題的應用程序之前,讓我們先看看垃圾收集在 JVM 中是如何工作的。

JVM 使用一種稱為跟踪收集器的垃圾收集器,它的工作原理是暫停周圍的世界,標記所有根對象(由運行線程直接引用的對象),並跟隨它們的引用,標記沿途看到的每個對象。

Java 基於世代假設假設實現了一種稱為世代垃圾收集器的東西,該假設指出,大多數創建的對象會很快被丟棄,而沒有快速收集的對象可能會存在一段時間

基於這個假設,Java 將對象劃分為多代。 這是一個視覺解釋:

Java 分成多代

  • 年輕一代- 這是對像開始的地方。 它有兩個子代:

    • Eden Space - 對像從這裡開始。 大多數對像都是在伊甸園空間中​​創建和銷毀的。 在這裡,GC 執行Minor GC ,這是優化的垃圾收集。 執行 Minor GC 時,對仍然需要的對象的任何引用都將遷移到倖存者空間之一(S0 或 S1)。

    • 倖存者空間(S0 和 S1) - 在伊甸園中倖存下來的物體最終會出現在這裡。 其中有兩個,並且在任何給定時間只有一個在使用(除非我們有嚴重的內存洩漏)。 一個被指定為empty ,另一個被指定為live ,與每個 GC 週期交替。

  • Tenured Generation - 也稱為老年代(圖 2 中的舊空間),該空間保存具有更長生命週期的舊對象(如果它們存活時間足夠長,則從倖存者空間移出)。 當這個空間被填滿時,GC 會執行一次Full GC ,這在性能方面會花費更多。 如果這個空間無限增長,JVM 將拋出OutOfMemoryError - Java heap space

  • 永久代- 與終身代密切相關的第三代,永久代的特殊之處在於它保存了虛擬機所需的數據來描述在 Java 語言級別上不具有等價性的對象。 例如,描述類和方法的對象存儲在永久代中。

Java 足夠聰明,可以對每一代應用不同的垃圾收集方法。 年輕代使用稱為Parallel New Collector跟踪、複製收集器進行處理。 這個收集器停止了世界,但是因為年輕代一般很小,所以停頓很短。

有關 JVM 生成以及它們如何更詳細地工作的更多信息,請訪問 Java HotSpot 虛擬機文檔中的內存管理。

檢測內存洩漏

要查找內存洩漏並消除它們,您需要適當的內存洩漏工具。 是時候使用 Java VisualVM 檢測和消除此類洩漏了。

使用 Java VisualVM 遠程分析堆

VisualVM 是一種提供可視化界面的工具,用於在基於 Java 技術的應用程序運行時查看它們的詳細信息。

使用 VisualVM,您可以查看與本地應用程序和遠程主機上運行的應用程序相關的數據。 您還可以捕獲有關 JVM 軟件實例的數據並將數據保存到本地系統。

為了從 Java VisualVM 的所有功能中受益,您應該運行 Java 平台標準版 (Java SE) 版本 6 或更高版本。

相關:為什麼需要升級到 Java 8

為 JVM 啟用遠程連接

在生產環境中,通常很難訪問將運行我們的代碼的實際機器。 幸運的是,我們可以遠程分析我們的 Java 應用程序。

首先,我們需要在目標機器上授予自己 JVM 訪問權限。 為此,請創建一個名為jstatd.all.policy的文件,其內容如下:

 grant codebase "file:${java.home}/../lib/tools.jar" { permission java.security.AllPermission; };

創建文件後,我們需要使用 jstatd - Virtual Machine jstat Daemon 工具啟用與目標 VM 的遠程連接,如下所示:

 jstatd -p <PORT_NUMBER> -J-Djava.security.policy=<PATH_TO_POLICY_FILE>

例如:

 jstatd -p 1234 -J-Djava.security.policy=D:\jstatd.all.policy

在目標 VM 中啟動jstatd後,我們能夠連接到目標機器並遠程分析存在內存洩漏問題的應用程序。

連接到遠程主機

在客戶端計算機中,打開提示符並鍵入jvisualvm以打開 VisualVM 工具。

接下來,我們必須在 VisualVM 中添加一個遠程主機。 由於啟用了目標 JVM 以允許來自具有 J2SE 6 或更高版本的另一台機器的遠程連接,我們啟動 Java VisualVM 工具並連接到遠程主機。 如果與遠程主機的連接成功,我們將看到在目標 JVM 中運行的 Java 應用程序,如下所示:

在目標 jvm 中運行

要在應用程序上運行內存分析器,我們只需在側面板中雙擊其名稱。

現在我們都設置了內存分析器,讓我們調查一個存在內存洩漏問題的應用程序,我們將其稱為MemLeak

內存洩漏

當然,有很多方法可以在 Java 中創建內存洩漏。 為簡單起見,我們將定義一個類作為HashMap中的鍵,但我們不會定義 equals() 和 hashcode() 方法。

HashMap 是 Map 接口的哈希表實現,因此它定義了鍵和值的基本概念:每個值都與唯一的鍵相關,因此如果給定鍵值對的鍵已經存在於HashMap,它的當前值被替換。

我們的 key 類必須提供equals()hashcode()方法的正確實現。 沒有它們,就無法保證會生成一個好的密鑰。

通過不定義equals()hashcode()方法,我們一遍又一遍地向 HashMap 添加相同的鍵,而不是按照應有的方式替換鍵,HashMap 不斷增長,無法識別這些相同的鍵並拋出OutOfMemoryError .

這是 MemLeak 類:

 package com.post.memory.leak; import java.util.Map; public class MemLeak { public final String key; public MemLeak(String key) { this.key =key; } public static void main(String args[]) { try { Map map = System.getProperties(); for(;;) { map.put(new MemLeak("key"), "value"); } } catch(Exception e) { e.printStackTrace(); } } }

注意:內存洩漏不是因為第 14 行的無限循環:無限循環會導致資源耗盡,但不會導致內存洩漏。 如果我們正確地實現了equals()hashcode()方法,即使在無限循環中,代碼也能正常運行,因為我們在 HashMap 中只有一個元素。

(對於那些感興趣的人,這裡有一些(故意)產生洩漏的替代方法。)

使用 Java VisualVM

使用 Java VisualVM,我們可以對 Java 堆進行內存監控,並確定它的行為是否表明存在內存洩漏。

這是初始化後 MemLeak 的 Java 堆分析器的圖形表示(回想一下我們對各代的討論):

使用 java visualvm 監控內存洩漏

僅僅 30 秒後,Old Generation 就快滿了,這表明即使使用 Full GC,Old Generation 也在不斷增長,這是內存洩漏的明顯跡象。

下圖顯示了一種檢測此洩漏原因的方法(單擊放大),它是使用帶有heapdump的 Java VisualVM 生成的。 在這裡,我們看到50% 的 Hashtable$Entry 對像在堆中,而第二行將我們指向MemLeak類。 因此,內存洩漏是由MemLeak類中使用的哈希表引起的。

哈希表內存洩漏

最後,觀察OutOfMemoryError之後的 Java Heap,其中Young 和 Old 代都已滿

內存不足錯誤

結論

內存洩漏是最難解決的 Java 應用程序問題之一,因為其症狀多種多樣且難以重現。 在這裡,我們概述了發現內存洩漏和識別其來源的逐步方法。 但最重要的是,仔細閱讀您的錯誤消息並註意您的堆棧跟踪——並非所有洩漏都像看起來那麼簡單。

附錄

除了 Java VisualVM,還有其他幾個可以執行內存洩漏檢測的工具。 許多洩漏檢測器通過攔截對內存管理例程的調用在庫級別運行。 例如, HPROF是一個與 Java 2 平台標準版 (J2SE) 捆綁在一起的簡單命令行工具,用於堆和 CPU 分析。 HPROF的輸出可以直接分析或用作其他工具(如JHAT )的輸入。 當我們使用 Java 2 企業版 (J2EE) 應用程序時,有許多更友好的堆轉儲分析器解決方案,例如用於 Websphere 應用程序服務器的 IBM Heapdumps。