摘 要 穩定性是衡量軟件系統質量的重要指標,內存泄漏是破壞系統穩定性的重要因素。由于采用垃圾回收機制,Java語言的內存泄漏的模式與C++等語言相比有很大的不同。全文通過與C++中的內存泄漏問題進行對比,講述了Java內存泄漏的基本原理,以及如何借助Optimizeit profiler工具來測試內存泄漏和分析內存泄漏的原因,在實踐中證明這是一套行之有效的方法。
關鍵詞Java; 內存泄漏; GC(垃圾收集器) 引用; Optimizeit
問題的提出
筆者曾經參與開發的網管系統,系統規模龐大,涉及上百萬行代碼。系統主要采用Java語言開發,大體上分為客戶端、服務器和數據庫三個層次。在版本進入測試和試用的過程中,現場人員和測試部人員紛紛反映:系統的穩定性比較差,經常會出現服務器端運行一晝夜就死機的現象,客戶端跑死的現象也比較頻繁地發生。對于網管系統來講,經常性的服務器死機是個比較嚴重的問題,因為頻繁的死機不僅可能導致前后臺數據不一致,發生錯誤,更會引起用戶的不滿,降低客戶的信任度。因此,服務器端的穩定性問題必須盡快解決。
解決思路
通過察看服務器端日志,發現死機前服務器端頻繁拋出OutOfMemoryException內存溢出錯誤,因此初步把死機的原因定位為內存泄漏引起內存不足,進而引起內存溢出錯誤。如何查找引起內存泄漏的原因呢?有兩種思路:第一種,安排有經驗的編程人員對代碼進行走查和分析,找出內存泄漏發生的位置;第二種,使用專門的內存泄漏測試工具Optimizeit進行測試。這兩種方法都是解決系統穩定性問題的有效手段,使用內存測試工具對于已經暴露出來的內存泄漏問題的定位和解決非常有效;但是軟件測試的理論也告訴我們,系統中永遠存在一些沒有暴露出來的問題,而且,系統的穩定性問題也不僅僅只是內存泄漏的問題,代碼走查是提高系統的整體代碼質量乃至解決潛在問題的有效手段。基于這樣的考慮,我們的內存穩定性工作決定采用代碼走查結合測試工具的使用,雙管齊下,爭取比較徹底地解決系統的穩定性問題。
在代碼走查的工作中,安排了對系統業務和開發語言工具比較熟悉的開發人員對應用的代碼進行了交叉走查,找出代碼中存在的數據庫連接聲明和結果集未關閉、代碼冗余和低效等故障若干,取得了良好的效果,文中主要講述結合工具的使用對已經出現的內存泄漏問題的定位方法。
內存泄漏的基本原理
在C++語言程序中,使用new操作符創建的對象,在使用完畢后應該通過delete操作符顯示地釋放,否則,這些對象將占用堆空間,永遠沒有辦法得到回收,從而引起內存空間的泄漏。如下的簡單代碼就可以引起內存的泄漏:
根據這樣的基本假設,我們可以持續地觀察系統運行時使用的內存的大小和各實例的個數,如果內存的大小持續地增長,則說明系統存在內存泄漏,如果某個類的實例的個數持續地增長,則說明這個類的實例可能存在泄漏情況。
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語言開發的、對穩定性有比較高要求的軟件系統。
原文地址:http://www.it55.com/html/xueyuan/chengxukaifa/JAVAjiaocheng/20070719/111179_2.html
本博客為學習交流用,凡未注明引用的均為本人作品,轉載請注明出處,如有版權問題請及時通知。由于博客時間倉促,錯誤之處敬請諒解,有任何意見可給我留言,愿共同學習進步。