Java的內存泄漏

Java的一個重要優點就是通過垃圾收集器(Garbage Collection,GC)自動管理內存的回收,程序員不需要通過調用函數來釋放內存。因此,很多程序員認爲Java不存在內存泄漏問題,或者認爲即使有內存泄漏也不是程序的責任,而是GC或JVM的問題。其實,這種想法是不正確的,因爲Java也存在內存泄露,但它的表現與C++不同。

 

問題的提出

Java的一個重要優點就是通過垃圾收集器(Garbage Collection,GC)自動管理內存的回收,程序員不需要通過調用函數來釋放內存。因此,很多程序員認爲Java不存在內存泄漏問題,或者認爲即使有內存泄漏也不是程序的責任,而是GC或JVM的問題。其實,這種想法是不正確的,因爲Java也存在內存泄露,但它的表現與C++不同。

隨着越來越多的服務器程序採用Java技術,例如JSP,Servlet, EJB等,服務器程序往往長期運行。另外,在很多嵌入式系統中,內存的總量非常有限。內存泄露問題也就變得十分關鍵,即使每次運行少量泄漏,長期運行之後,系統也是面臨崩潰的危險。


JAVA是如何管理內存的

爲了判斷Java中是否有內存泄露,我們首先必須瞭解Java是如何管理內存的。Java的內存管理就是對象的分配和釋放問題。在Java中,程序員需要通過關鍵字new爲每個對象申請內存空間 (基本類型除外),所有的對象都在堆 (Heap)中分配空間。另外,對象的釋放是由GC決定和執行的。在Java中,內存的分配是由程序完成的,而內存的釋放是有GC完成的,這種收支兩條線的方法確實簡化了程序員的工作。但同時,它也加重了JVM的工作。這也是Java程序運行速度較慢的原因之一。因爲,GC爲了能夠正確釋放對象,GC必須監控每一個對象的運行狀態,包括對象的申請、引用、被引用、賦值等,GC都需要進行監控。

監視對象狀態是爲了更加準確地、及時地釋放對象,而釋放對象的根本原則就是該對象不再被引用。

爲了更好理解GC的工作原理,我們可以將對象考慮爲有向圖的頂點,將引用關係考慮爲圖的有向邊,有向邊從引用者指向被引對象。另外,每個線程對象可以作爲一個圖的起始頂點,例如大多程序從main進程開始執行,那麼該圖就是以main進程頂點開始的一棵根樹。在這個有向圖中,根頂點可達的對象都是有效對象,GC將不回收這些對象。如果某個對象 (連通子圖)與這個根頂點不可達(注意,該圖爲有向圖),那麼我們認爲這個(這些)對象不再被引用,可以被GC回收。

以下,我們舉一個例子說明如何用有向圖表示內存管理。對於程序的每一個時刻,我們都有一個有向圖表示JVM的內存分配情況。以下右圖,就是左邊程序運行到第6行的示意圖。



圖1

Java使用有向圖的方式進行內存管理,可以消除引用循環的問題,例如有三個對象,相互引用,只要它們和根進程不可達的,那麼GC也是可以回收它們的。這種方式的優點是管理內存的精度很高,但是效率較低。另外一種常用的內存管理技術是使用計數器,例如COM模型採用計數器方式管理構件,它與有向圖相比,精度行低(很難處理循環引用的問題),但執行效率很高。


什麼是JAVA的內存泄露

下面,我們就可以描述什麼是內存泄漏。在Java中,內存泄漏就是存在一些被分配的對象,這些對象有下面兩個特點,首先,這些對象是可達的,即在有向圖中,存在通路可以與其相連;其次,這些對象是無用的,即程序以後不會再使用這些對象。如果對象滿足這兩個條件,這些對象就可以判定爲Java中的內存泄漏,這些對象不會被GC所回收,然而它卻佔用內存。

在C++中,內存泄漏的範圍更大一些。有些對象被分配了內存空間,然後卻不可達,由於C++中沒有GC,這些內存將永遠收不回來。在Java中,這些不可達的對象都由GC負責回收,因此程序員不需要考慮這部分的內存泄露。

通過分析,我們得知,對於C++,程序員需要自己管理邊和頂點,而對於Java程序員只需要管理邊就可以了(不需要管理頂點的釋放)。通過這種方式,Java提高了編程的效率。


圖2

因此,通過以上分析,我們知道在Java中也有內存泄漏,但範圍比C++要小一些。因爲Java從語言上保證,任何對象都是可達的,所有的不可達對象都由GC管理。

對於程序員來說,GC基本是透明的,不可見的。雖然,我們只有幾個函數可以訪問GC,例如運行GC的函數System.gc(),但是根據Java語言規範定義, 該函數不保證JVM的垃圾收集器一定會執行。因爲,不同的JVM實現者可能使用不同的算法管理GC。通常,GC的線程的優先級別較低。JVM調用GC的策略也有很多種,有的是內存使用到達一定程度時,GC纔開始工作,也有定時執行的,有的是平緩執行GC,有的是中斷式執行GC。但通常來說,我們不需要關心這些。除非在一些特定的場合,GC的執行影響應用程序的性能,例如對於基於Web的實時系統,如網絡遊戲等,用戶不希望GC突然中斷應用程序執行而進行垃圾回收,那麼我們需要調整GC的參數,讓GC能夠通過平緩的方式釋放內存,例如將垃圾回收分解爲一系列的小步驟執行,Sun提供的HotSpot JVM就支持這一特性。

下面給出了一個簡單的內存泄露的例子。在這個例子中,我們循環申請Object對象,並將所申請的對象放入一個Vector中,如果我們僅僅釋放引用本身,那麼Vector仍然引用該對象,所以這個對象對GC來說是不可回收的。因此,如果對象加入到Vector後,還必須從Vector中刪除,最簡單的方法就是將Vector對象設置爲null。

Vector v=new Vector(10);
for (int i=1;i<100; i++)
{
	Object o=new Object();
	v.add(o);
	o=null;	
}

//此時,所有的Object對象都沒有被釋放,因爲變量v引用這些對象。


如何檢測內存泄露 

最後一個重要的問題,就是如何檢測Java的內存泄漏。目前,我們通常使用一些工具來檢查Java程序的內存泄漏問題。市場上已有幾種專業檢查Java內存泄漏的工具,它們的基本工作原理大同小異,都是通過監測Java程序運行時,所有對象的申請、釋放等動作,將內存管理的所有信息進行統計、分析、可視化。開發人員將根據這些信息判斷程序是否有內存泄漏問題。這些工具包括Optimizeit Profiler,JProbe Profiler,JinSight , Rational 公司的Purify等。

下面,我們將簡單介紹Optimizeit的基本功能和工作原理。

Optimizeit Profiler版本4.11支持Application,Applet,Servlet和Romote Application四類應用,並且可以支持大多數類型的JVM,包括SUN JDK系列,IBM的JDK系列,和Jbuilder的JVM等。並且,該軟件是由Java編寫,因此它支持多種操作系統。Optimizeit系列還包括Thread Debugger和Code Coverage兩個工具,分別用於監測運行時的線程狀態和代碼覆蓋面。

當設置好所有的參數了,我們就可以在OptimizeIt環境下運行被測程序,在程序運行過程中,Optimizeit可以監視內存的使用曲線(如下圖),包括JVM申請的堆(heap)的大小,和實際使用的內存大小。另外,在運行過程中,我們可以隨時暫停程序的運行,甚至強行調用GC,讓GC進行內存回收。通過內存使用曲線,我們可以整體瞭解程序使用內存的情況。這種監測對於長期運行的應用程序非常有必要,也很容易發現內存泄露。



圖3

在運行過程中,我們還可以從不同視角觀查內存的使用情況,Optimizeit提供了四種方式:

  • 堆視角。 這是一個全面的視角,我們可以瞭解堆中的所有的對象信息(數量和種類),並進行統計、排序,過濾。瞭解相關對象的變化情況。
  • 方法視角。通過方法視角,我們可以得知每一種類的對象,都分配在哪些方法中,以及它們的數量。
  • 對象視角。給定一個對象,通過對象視角,我們可以顯示它的所有出引用和入引用對象,我們可以瞭解這個對象的所有引用關係。
  • 引用圖。 給定一個根,通過引用圖,我們可以顯示從該頂點出發的所有出引用。

在運行過程中,我們可以隨時觀察內存的使用情況,通過這種方式,我們可以很快找到那些長期不被釋放,並且不再使用的對象。我們通過檢查這些對象的生存週期,確認其是否爲內存泄露。在實踐當中,尋找內存泄露是一件非常麻煩的事情,它需要程序員對整個程序的代碼比較清楚,並且需要豐富的調試經驗,但是這個過程對於很多關鍵的Java程序都是十分重要的。

綜上所述,Java也存在內存泄露問題,其原因主要是一些對象雖然不再被使用,但它們仍然被引用。爲了解決這些問題,我們可以通過軟件工具來檢查內存泄露,檢查的主要原理就是暴露出所有堆中的對象,讓程序員尋找那些無用但仍被引用的對象。 

 

Java系統中內存泄漏測試方法的研究

問題的提出

  筆者曾經參與開發的網管系統,系統規模龐大,涉及上百萬行代碼。系統主要採用Java語言開發,大體上分爲客戶端、服務器和數據庫三個層次。在版本進入測試和試用的過程中,現場人員和測試部人員紛紛反映:系統的穩定性比較差,經常會出現服務器端運行一晝夜就死機的現象,客戶端跑死的現象也比較頻繁地發生。對於網管系統來講,經常性的服務器死機是個比較嚴重的問題,因爲頻繁的死機不僅可能導致前後臺數據不一致,發生錯誤,更會引起用戶的不滿,降低客戶的信任度。因此,服務器端的穩定性問題必須儘快解決。

解決思路

  通過察看服務器端日誌,發現死機前服務器端頻繁拋出OutOfMemoryException內存溢出錯誤,因此初步把死機的原因定位爲內存泄漏引起內存不足,進而引起內存溢出錯誤。如何查找引起內存泄漏的原因呢?有兩種思路:第一種,安排有經驗的編程人員對代碼進行走查和分析,找出內存泄漏發生的位置;第二種,使用專門的內存泄漏測試工具Optimizeit進行測試。這兩種方法都是解決系統穩定性問題的有效手段,使用內存測試工具對於已經暴露出來的內存泄漏問題的定位和解決非常有效;但是軟件測試的理論也告訴我們,系統中永遠存在一些沒有暴露出來的問題,而且,系統的穩定性問題也不僅僅只是內存泄漏的問題,代碼走查是提高系統的整體代碼質量乃至解決潛在問題的有效手段。基於這樣的考慮,我們的內存穩定性工作決定採用代碼走查結合測試工具的使用,雙管齊下,爭取比較徹底地解決系統的穩定性問題。

  在代碼走查的工作中,安排了對系統業務和開發語言工具比較熟悉的開發人員對應用的代碼進行了交叉走查,找出代碼中存在的數據庫連接聲明和結果集未關閉、代碼冗餘和低效等故障若干,取得了良好的效果,文中主要講述結合工具的使用對已經出現的內存泄漏問題的定位方法。

內存泄漏的基本原理

  在C++語言程序中,使用new操作符創建的對象,在使用完畢後應該通過delete操作符顯示地釋放,否則,這些對象將佔用堆空間,永遠沒有辦法得到回收,從而引起內存空間的泄漏。如下的簡單代碼就可以引起內存的泄漏:

void function(){
 Int[] vec = new int[5];
}

  在function()方法執行完畢後,vec數組已經是不可達對象,在C++語言中,這樣的對象永遠也得不到釋放,稱這種現象爲內存泄漏。

  而Java是通過垃圾收集器(Garbage Collection,GC)自動管理內存的回收,程序員不需要通過調用函數來釋放內存,但它只能回收無用並且不再被其它對象引用的那些對象所佔用的空間。在下面的代碼中,循環申請Object對象,並將所申請的對象放入一個Vector中,如果僅僅釋放對象本身,但是因爲Vector仍然引用該對象,所以這個對象對GC來說是不可回收的。因此,如果對象加入到Vector後,還必須從Vector中刪除,最簡單的方法就是將Vector對象設置爲null。

Vector v = new Vector(10);
for (int i = 1; i < 100; i++)
{
 Object o = new Object();
 v.add(o);
 o = null;
}//此時,所有的Object對象都沒有被釋放,因爲變量v引用這些對象。

  實際上無用、而還被引用的對象,GC就無能爲力了(事實上GC認爲它還有用),這一點是導致內存泄漏最重要的原因。

Java的內存回收機制可以形象地理解爲在堆空間中引入了重力場,已經加載的類的靜態變量和處於活動線程的堆棧空間的變量是這個空間的牽引對象。這裏牽引對象是指按照Java語言規範,即便沒有其它對象保持對它的引用也不能夠被回收的對象,即Java內存空間中的本原對象。當然類可能被去加載,活動線程的堆棧也是不斷變化的,牽引對象的集合也是不斷變化的。對於堆空間中的任何一個對象,如果存在一條或者多條從某個或者某幾個牽引對象到該對象的引用鏈,則就是可達對象,可以形象地理解爲從牽引對象伸出的引用鏈將其拉住,避免掉到回收池中;而其它的不可達對象由於不存在牽引對象的拉力,在重力的作用下將掉入回收池。在圖1中,A、B、C、D、E、F六個對象都被牽引對象所直接或者間接地“牽引”,使得它們避免在重力的作用下掉入回收池。如果TR1-A鏈和TR2-D鏈斷開,則A、B、C三個對象由於失去牽引,在重力的作用下掉入回收池(被回收),D對象也是同樣的原因掉入回收池,而F對象仍然存在一個牽引鏈(TR3-E-F),所以不會被回收,如圖2、3所示。

  
  圖1 初始狀態

  
  圖2 TR1-A鏈和TR2-D鏈斷開,A、B、C、D掉入回收池

  
  圖3 A、B、C、D四個對象被回收

  通過前面的介紹可以看到,由於採用了垃圾回收機制,任何不可達對象都可以由垃圾收集線程回收。因此通常說的Java內存泄漏其實是指無意識的、非故意的對象引用,或者無意識的對象保持。無意識的對象引用是指代碼的開發人員本來已經對對象使用完畢,卻因爲編碼的錯誤而意外地保存了對該對象的引用(這個引用的存在並不是編碼人員的主觀意願),從而使得該對象一直無法被垃圾回收器回收掉,這種本來以爲可以釋放掉的卻最終未能被釋放的空間可以認爲是被“泄漏了”。

  這裏通過一個例子來演示Java的內存泄漏。假設有一個日誌類Logger,其提供一個靜態的log(String msg)方法,任何其它類都可以調用Logger.Log(message)來將message的內容記錄到系統的日誌文件中。Logger類有一個類型爲HashMap的靜態變量temp,每次在執行log(message)方法的時候,都首先將message的值丟入temp中(以當前線程+當前時間爲鍵),在方法退出之前再從temp中將以當前線程和當前時間爲鍵的條目刪除。注意,這裏當前時間是不斷變化的,所以log方法在退出之前執行刪除條目的操作並不能刪除方法執行之初丟入的條目。這樣,任何一個作爲參數傳給log方法的字符串最終由於被Logger的靜態變量temp引用,而無法得到回收,這種違背實現者主觀意圖的無意識的對象保持就是我們所說的Java內存泄漏。

  鑑別泄漏對象的方法

  一般說來,一個正常的系統在其運行穩定後其內存的佔用量是基本穩定的,不應該是無限制的增長的,同樣,對任何一個類的對象的使用個數也有一個相對穩定的上限,不應該是持續增長的。根據這樣的基本假設,我們可以持續地觀察系統運行時使用的內存的大小和各實例的個數,如果內存的大小持續地增長,則說明系統存在內存泄漏,如果某個類的實例的個數持續地增長,則說明這個類的實例可能存在泄漏情況。

  Optimizeit是Borland公司的產品,主要用於協助對軟件系統進行代碼優化和故障診斷,其功能衆多,使用方便,其中的OptimizeIt Profiler主要用於內存泄漏的分析。Profiler的堆視圖(如圖4)就是用來觀察系統運行使用的內存大小和各個類的實例分配的個數的,其界面如圖四所示,各列自左至右分別爲類名稱、當前實例個數、自上個標記點開始增長的實例個數、佔用的內存空間的大小、自上次標記點開始增長的內存的大小、被釋放的實例的個數信息、自上次標記點開始增長的內存的大小被釋放的實例的個數信息,表的最後一行是彙總數據,分別表示目前JVM中的對象實例總數、實例增長總數、內存使用總數、內存使用增長總數等。

  在實踐中,可以分別在系統運行四個小時、八個小時、十二個小時和二十四個小時時間點記錄當時的內存狀態(即抓取當時的內存快照,是工具提供的功能,這個快照也是供下一步分析使用),找出實例個數增長的前十位的類,記錄下這十個類的名稱和當前實例的個數。在記錄完數據後,點擊Profiler中右上角的Mark按鈕,將該點的狀態作爲下一次記錄數據時的比較點。

  
  圖4 Profiler 堆視圖

  系統運行二十四小時以後可以得到四個內存快照。對這四個內存快照進行綜合分析,如果每一次快照的內存使用都比上一次有增長,可以認定系統存在內存泄漏,找出在四個快照中實例個數都保持增長的類,這些類可以初步被認定爲存在泄漏。

  分析與定位

  通過上面的數據收集和初步分析,可以得出初步結論:系統是否存在內存泄漏和哪些對象存在泄漏(被泄漏),如果結論是存在泄漏,就可以進入分析和定位階段了。

  前面已經談到Java中的內存泄漏就是無意識的對象保持,簡單地講就是因爲編碼的錯誤導致了一條本來不應該存在的引用鏈的存在(從而導致了被引用的對象無法釋放),因此內存泄漏分析的任務就是找出這條多餘的引用鏈,並找到其形成的原因。前面還講到過牽引對象,包括已經加載的類的靜態變量和處於活動線程的堆棧空間的變量。由於活動線程的堆棧空間是迅速變化的,處於堆棧空間內的牽引對象集合是迅速變化的,而作爲類的靜態變量的牽引對象的集合在系統運行期間是相對穩定的。

  對每個被泄漏的實例對象,必然存在一條從某個牽引對象出發到達該對象的引用鏈。處於堆棧空間的牽引對象在被從棧中彈出後就失去其牽引的能力,變爲非牽引對象,因此,在長時間的運行後,被泄露的對象基本上都是被作爲類的靜態變量的牽引對象牽引。

  Profiler的內存視圖除了堆視圖以外,還包括實例分配視圖(圖5)和實例引用圖(圖6)。

  Profiler的實例引用圖爲找出從牽引對象到泄漏對象的引用鏈提供了非常直接的方法,其界面的第二個欄目中顯示的就是從泄漏對象出發的逆向引用鏈。需要注意的是,當一個類的實例存在泄漏時,並非其所有的實例都是被泄漏的,往往只有一部分是被泄漏對象,其它則是正常使用的對象,要判斷哪些是正常的引用鏈,哪些是不正常的引用鏈(引起泄漏的引用鏈)。通過抽取多個實例進行引用圖的分析統計以後,可以找出一條或者多條從牽引對象出發的引用鏈,下面的任務就是找出這條引用鍊形成的原因。

  實例分配圖提供的功能是對每個類的實例的分配位置進行統計,查看實例分配的統計結果對於分析引用鏈的形成具有一定的作用,因爲找到分配鏈與引用鏈的交點往往就可以找到了引用鍊形成的原因,下面將具體介紹。

  
  圖5 實例分配圖

  
  圖6 實例引用圖

  設想一個實例對象a在方法f中被分配,最終被實例對象b所引用,下面來分析從b到a的引用鏈可能的形成原因。方法f在創建對象a後,對它的使用分爲四種情況:1、將a作爲返回值返回;2、將a作爲參數調用其它方法;3、在方法內部將a的引用傳遞給其它對象;4、其它情況。其中情況4不會造成由b到a的引用鏈的生成,不用考慮。下面考慮其它三種情況:對於1、2兩種情況,其造成的結果都是在另一個方法內部獲得了對象a的引用,它的分析與方法f的分析完全一樣(遞歸分析);考慮第3種情況:1、假設方法f直接將對象a的引用加入到對象b,則對象b到a的引用鏈就找到了,分析結束;2、假設方法f將對象a的引用加入到對象c,則接下來就需要跟蹤對象c的使用,對象c的分析比對象a的分析步驟更多一些,但大體原理都是一樣的,就是跟蹤對象從創建後被使用的歷程,最終找到其被牽引對象引用的原因。

  現在將泄漏對象的引用鏈以及引用鍊形成的原因找到了,內存泄漏測試與分析的工作就到此結束,接下來的工作就是修改相應的設計或者實現中的錯誤了。

  總結

  使用上述的測試和分析方法,在實踐中先後進行了三次測試,找出了好幾處內存泄漏錯誤。系統的穩定性得到很大程度的提高,最初運行1~2天就拋出內存溢出異常,修改完成後,系統從未出現過內存溢出異常。此方法適用於任何使用Java語言開發的、對穩定性有比較高要求的軟件系統。



 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章