沒有經驗的程序員經常認為Java的自動垃圾回收完全使他們免於擔心內存管理。這是一個常見的誤解:雖然垃圾收集器做得很好,但即使是最好的程序員也完全有可能成為嚴重破壞內存泄漏的犧牲品。讓我解釋一下。
當不必要地維護不再需要的對象引用時,會發生內存泄漏。這些泄漏很糟糕。首先,當程序消耗越來越多的資源時,它們會對計算機施加不必要的壓力。更糟糕的是,檢測這些泄漏可能很困難:靜態分析通常很難精確識別這些冗余引用,現有的泄漏檢測工具會跟蹤和報告有關單個對象的細粒度信息,產生難以解釋且缺乏精確度的結果。
換句話說,泄漏要么太難以識別,要么用太具體而無法用術語來識別。
實際上有四類內存問題具有相似和重疊的特征,但原因和解決方案各不相同:
- Performance(性能):通常與過多的對象創建和刪除,垃圾收集的長時間延遲,過多的操作系統頁面交換等相關聯。
- Resource constraints(資源約束):當可用內存很少或內存過於分散而無法分配大對象時 - 這可能是本機的,或者更常見的是與Java堆相關。
- Java heap leaks(java堆泄漏):經典的內存泄漏,Java對象在不釋放的情況下不斷創建。這通常是由潛在對象引用引起的。
- Native memory leaks(本機內存泄漏):與Java堆之外的任何不斷增長的內存利用率相關聯,例如由JNI代碼,驅動程序甚至JVM分配。
在這個內存管理教程中,我將專注於Java堆漏洞,並概述一種基於Java VisualVM報告檢測此類泄漏的方法,並利用可視化界面在運行時分析基於Java技術的應用程序。
但在您可以預防和發現內存泄漏之前,您應該了解它們的發生方式和原因。 (注意:如果你能很好地處理錯綜復雜的內存泄漏,你可以跳過。)
1.內存泄漏:基礎
對於初學者來說,將內存泄漏視為一種疾病,將Java的OutOfMemoryError(簡稱OOM)視為一種症狀。但與任何疾病一樣,並非所有OOM都意味着內存泄漏:由於生成大量局部變量或其他此類事件,OOM可能會發生。另一方面,並非所有內存泄漏都必然表現為OOM,特別是在桌面應用程序或客戶端應用程序(沒有重新啟動時運行很長時間)的情況下。
將內存泄漏視為疾病,將OutOfMemoryError視為症狀。但並非所有OutOfMemoryErrors都意味着內存泄漏,並非所有內存泄漏都表現為OutOfMemoryErrors。
為什么這些泄漏如此糟糕?除此之外,程序執行期間泄漏的內存塊通常會降低系統性能,因為分配但未使用的內存塊必須在系統耗盡空閑物理內存時進行換出。最終,程序甚至可能耗盡其可用的虛擬地址空間,從而導致OOM。
2.解密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 bytes for . Out of swap space?
- java.lang.OutOfMemoryError: (Native method)
2.1.“Java heap space”
此錯誤消息不一定意味着內存泄漏。實際上,問題可能與配置問題一樣簡單。
例如,我負責分析一直產生這種類型的OutOfMemoryError的應用程序。經過一番調查后,我發現罪魁禍首是陣列實例化,因為需要太多的內存;在這種情況下,並不是應用程序的錯,而是應用程序服務器依賴於默認的堆太小了。我通過調整JVM的內存參數解決了這個問題。
在其他情況下,特別是對於長期存在的應用程序,該消息可能表明我們無意中持有對象的引用,從而阻止垃圾收集器清理它們。這時Java語言等同於內存泄漏。 (注意:應用程序調用的API也可能無意中持有對象引用。)
這些“Java堆空間”OOM的另一個潛在來源是使用finalizers。如果類具有finalize方法,則在垃圾收集時該類型的對象不會被回收。而是在垃圾收集之后,稍后對象將排隊等待最終確定。在Sun實現中,finalizers由守護線程執行。如果finalizers線程無法跟上finalization隊列,那么Java堆可能會填滿並且可能拋出OOM。
2.2.“PermGen space”
此錯誤消息表明永久代已滿。永久代是存儲類和方法對象的堆的區域。如果應用程序加載了大量類,則可能需要使用-XX:MaxPermSize選項增加永久代的大小。
Interned java.lang.String對象也存儲在永久代中。 java.lang.String類維護一個字符串池。調用實習方法時,該方法檢查池以查看是否存在等效字符串。如果是這樣,它由實習方法返回;如果沒有,則將字符串添加到池中。更准確地說,java.lang.String.intern方法返回一個字符串的規范表示;結果是對該字符串顯示為文字時將返回的同一個類實例的引用。如果應用程序實例化大量字符串,則可能需要增加永久代的大小。
注意:您可以使用jmap -permgen命令打印與永久生成相關的統計信息,包括有關內部化String實例的信息。
2.3.“Requested array size exceeds VM limit”
此錯誤表示應用程序(或該應用程序使用的API)嘗試分配大於堆大小的數組。例如,如果應用程序嘗試分配512MB的數組但最大堆大小為256MB,則將拋出此錯誤消息的OOM。在大多數情況下,問題是配置問題或應用程序嘗試分配海量數組時導致的錯誤。
2.4.“Request bytes for . Out of swap space?”
此消息似乎是一個OOM。但是,當本機堆的分配失敗並且本機堆可能將被耗盡時,HotSpot VM會拋出此異常。消息中包括失敗請求的大小(以字節為單位)以及內存請求的原因。在大多數情況下,是報告分配失敗的源模塊的名稱。
如果拋出此類型的OOM,則可能需要在操作系統上使用故障排除實用程序來進一步診斷問題。在某些情況下,問題甚至可能與應用程序無關。例如,您可能會在以下情況下看到此錯誤:
- 操作系統配置的交換空間不足。
- 系統上的另一個進程是消耗所有可用的內存資源。
由於本機泄漏,應用程序也可能失敗(例如,如果某些應用程序或庫代碼不斷分配內存但無法將其釋放到操作系統)。
2.5. (Native method)
如果您看到此錯誤消息並且堆棧跟蹤的頂部框架是本機方法,則該本機方法遇到分配失敗。此消息與上一個消息之間的區別在於,在JNI或本機方法中檢測到Java內存分配失敗,而不是在Java VM代碼中檢測到。
如果拋出此類型的OOM,您可能需要在操作系統上使用實用程序來進一步診斷問題。
2.6.Application Crash Without OOM
有時,應用程序可能會在從本機堆分配失敗后很快崩潰。如果您運行的本機代碼不檢查內存分配函數返回的錯誤,則會發生這種情況。
例如,如果沒有可用內存,malloc系統調用將返回NULL。如果未檢查malloc的返回,則應用程序在嘗試訪問無效的內存位置時可能會崩潰。根據具體情況,可能很難定位此類問題。
在某些情況下,致命錯誤日志或崩潰轉儲的信息就足以診斷問題。如果確定崩潰的原因是某些內存分配中缺少錯誤處理,那么您必須找到所述分配失敗的原因。與任何其他本機堆問題一樣,系統可能配置了但交換空間不足,另一個進程可能正在消耗所有可用內存資源等。
3.泄漏診斷
在大多數情況下,診斷內存泄漏需要非常詳細地了解相關應用程序。警告:該過程可能很長並且是迭代的。
我們尋找內存泄漏的策略將相對簡單:
- 識別症狀
- 啟用詳細垃圾回收
- 啟用分析
- 分析蹤跡
3.1 識別症狀
正如所討論的,在許多情況下,Java進程最終會拋出一個OOM運行時異常,這是一個明確的指示,表明您的內存資源已經耗盡。在這種情況下,您需要區分正常的內存耗盡和泄漏。分析OOM的消息並嘗試根據上面提供的討論找到罪魁禍首。
通常,如果Java應用程序請求的存儲空間超過運行時堆提供的存儲空間,則可能是由於設計不佳導致的。例如,如果應用程序創建映像的多個副本或將文件加載到數組中,則當映像或文件非常大時,它將耗盡存儲空間。這是正常的資源耗盡。該應用程序按設計工作(雖然這種設計顯然是愚蠢的)。
但是,如果應用程序在處理相同類型的數據時穩定地增加其內存利用率,則可能會發生內存泄漏。
3.2 啟用詳細垃圾收集
斷言確實存在內存泄漏的最快方法之一是啟用詳細垃圾回收。通常可以通過檢查verbosegc輸出中的模式來識別內存約束問題。
具體來說,-verbosegc參數允許您在每次垃圾收集(GC)過程開始時生成跟蹤。也就是說,當內存被垃圾收集時,摘要報告會打印到標准錯誤,讓您了解內存的管理方式。
這是使用-verbosegc選項生成的一些典型輸出:

此GC跟蹤文件中的每個塊(或節)按遞增順序編號。要理解這種跟蹤,您應該查看連續的分配失敗節,並查找隨着時間的推移而減少的釋放內存(字節和百分比),同時總內存(此處,19725304)正在增加。這些是內存耗盡的典型跡象。
3.3 啟用分析
不同的JVM提供了生成跟蹤文件以反映堆活動的不同方法,這些方法通常包括有關對象類型和大小的詳細信息。這稱為分析堆。
3.4 分析路徑
本文重點介紹Java VisualVM生成的跟蹤。跟蹤可以有不同的格式,因為它們可以由不同的Java內存泄漏檢測工具生成,但它們背后的想法總是相同的:在堆中找到不應該存在的對象塊,並確定這些對象是否累積而不是釋放。特別感興趣的是每次在Java應用程序中觸發某個事件時已知的臨時對象。應該僅存少量,但存在許多對象實例,通常表示應用程序出現錯誤。
最后,解決內存泄漏需要您徹底檢查代碼。了解對象泄漏的類型可能對此非常有用,並且可以大大加快調試速度。
4.垃圾收集如何在JVM中運行?
在我們開始分析具有內存泄漏問題的應用程序之前,讓我們首先看看垃圾收集在JVM中的工作原理。
JVM使用一種稱為跟蹤收集器的垃圾收集器,它基本上通過暫停它周圍的世界來操作,標記所有根對象(由運行線程直接引用的對象),並遵循它們的引用,標記它沿途看到的每個對象。
Java基於分代假設-實現了一種稱為分代垃圾收集器的東西,該假設表明創建的大多數對象被快速丟棄,而未快速收集的對象可能會存在一段時間。
基於此假設,[Java將對象分為多代](www.oracle.com/technetwork…. Generations|outline)。這是一個視覺解釋:

- Young Generation -這是對象的開始。它有兩個子代
- Eden Space -對象從這里開始。大多數物體都是在Eden Space中創造和銷毀的。在這里,GC執行Minor GCs,這是優化的垃圾收集。執行Minor GC時,對仍然需要的對象的任何引用都將遷移到其中一個survivors空間(S0或S1)。
- Survivor Space (S0 and S1)-幸存Eden Space的對象最終來到這里。其中有兩個,在任何給定時間只有一個正在使用(除非我們有嚴重的內存泄漏)。一個被指定為空,另一個被指定為活動,與每個GC循環交替。
- Tenured Generation -也被稱為老年代(圖2中的舊空間),這個空間容納存活較長的對象,使用壽命更長(如果它們活得足夠長,則從Survivor空間移過來)。填充此空間時,GC會執行完整GC,這會在性能方面降低成本。如果此空間無限制地增長,則JVM將拋出OutOfMemoryError - Java堆空間。
- Permanent Generation -作為與終身代密切相關的第三代,永久代是特殊的,因為它保存虛擬機所需的數據,以描述在Java語言級別上沒有等價的對象。例如,描述類和方法的對象存儲在永久代中。
Java足夠聰明,可以為每一代應用不同的垃圾收集方法。使用名為Parallel New Collector的跟蹤復制收集器處理年輕代。這個收集器阻止了這個世界,但由於年輕一代通常很小,所以暫停很短暫。
有關JVM代及其工作原理的更多信息,請查閱Memory Management in the Java HotSpot™ Virtual Machine 。
5 檢測內存泄漏
要查找內存泄漏並消除它們,您需要合適的內存泄漏工具。是時候使用Java VisualVM檢測並刪除此類泄漏。
5.1 使用Java VisualVM遠程分析堆
VisualVM是一種工具,它提供了一個可視化界面,用於查看有關基於Java技術的應用程序運行時的詳細信息。
使用VisualVM,您可以查看與本地應用程序和遠程主機上運行的應用程序相關的數據。您還可以捕獲有關JVM軟件實例的數據,並將數據保存到本地系統。
為了從Java VisualVM的所有功能中受益,您應該運行Java平台標准版(Java SE)版本6或更高版本。
Related: Why You Need to Upgrade to Java 8 Already
5.2. 為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,我們能夠連接到目標計算機並遠程分析應用程序的內存泄漏問題。
5.3. 連接到遠程主機
在客戶端計算機中,打開提示並鍵入jvisualvm以打開VisualVM工具。
接下來,我們必須在VisualVM中添加遠程主機。當目標JVM啟用以允許來自具有J2SE 6或更高版本的另一台計算機的遠程連接時,我們啟動Java VisualVM工具並連接到遠程主機。如果與遠程主機的連接成功,我們將看到在目標JVM中運行的Java應用程序,如下所示:
要在應用程序上運行內存分析器,我們只需在側面板中雙擊其名稱即可。
現在我們已經設置了內存分析器,讓我們研究一個內存泄漏問題的應用程序,我們稱之為MemLeak。
6. MemLeak
當然,有很多方法可以在Java中創建內存泄漏。為簡單起見,我們將一個類定義為HashMap中的鍵,但我們不會定義equals()和hashcode()方法。
HashMap是Map接口的哈希表實現,因此它定義了鍵和值的基本概念:每個值都與唯一鍵相關,因此如果給定鍵值對的鍵已經存在於HashMap,它的當前值被替換。
我們的密鑰類必須提供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中只有一個元素。
(對於那些感興趣的人,這里有一些(故意)產生泄漏的替代方法。)
7. 使用Java VisualVM
使用Java VisualVM,我們可以對Java Heap進行內存監視,並確定其行為是否存在內存泄漏。
這是剛剛初始化后MemLeak的Java堆分析器的圖形表示(回想一下我們對各代的討論):

僅僅30秒之后,老年代幾乎已滿,表明即使使用Full GC,老年代也在不斷增長,這是內存泄漏的明顯跡象。
檢測此泄漏原因的一種方法如下圖所示(單擊放大),使用帶有heapdump的Java VisualVM生成。在這里,我們看到50%的Hashtable $ Entry對象在堆中,而第二行指向MemLeak類。因此,內存泄漏是由MemLeak類中使用的哈希表引起的。

最后,在OutOfMemoryError之后觀察Java Heap,其中Young和Old代完全填滿。

8. 結束語
內存泄漏是最難解決的Java應用程序問題之一,因為症狀多種多樣且難以重現。在這里,我們概述了一種逐步發現內存泄漏並確定其來源的方法。但最重要的是,仔細閱讀您的錯誤消息並注意堆棧跟蹤 - 並非所有泄漏都像它們出現的那樣簡單。
9. 附錄
與Java VisualVM一起,還有其他幾種可以執行內存泄漏檢測的工具。許多泄漏檢測器通過攔截對存儲器管理例程的調用在庫級別操作。例如,HPROF是一個與Java 2平台標准版(J2SE)捆綁在一起的簡單命令行工具,用於堆和CPU分析。可以直接分析HPROF的輸出,或將其用作JHAT等其他工具的輸入。當我們使用Java 2 Enterprise Edition(J2EE)應用程序時,有許多堆轉儲分析器解決方案更友好,例如IBM Heapdumps for Websphere應用程序服務器。
作者:Jose Ferreirade Souza Filho
譯者:Emma
