問(wèn)題的提出
筆者曾經(jīng)參與開(kāi)發(fā)的網(wǎng)管系統,系統規模龐大,涉及上百萬(wàn)行代碼。系統主要采用Java語(yǔ)言開(kāi)發(fā),大體上分為客戶(hù)端、服務(wù)器和數據庫三個(gè)層次。在版本進(jìn)入測試和試用的過(guò)程中,現場(chǎng)人員和測試部人員紛紛反映:系統的穩定性比較差,經(jīng)常會(huì )出現服務(wù)器端運行一晝夜就死機的現象,客戶(hù)端跑死的現象也比較頻繁地發(fā)生。對于網(wǎng)管系統來(lái)講,經(jīng)常性的服務(wù)器死機是個(gè)比較嚴重的問(wèn)題,因為頻繁的死機不僅可能導致前后臺數據不一致,發(fā)生錯誤,更會(huì )引起用戶(hù)的不滿(mǎn),降低客戶(hù)的信任度。因此,服務(wù)器端的穩定性問(wèn)題必須盡快解決。
解決思路 通過(guò)察看服務(wù)器端日志,發(fā)現死機前服務(wù)器端頻繁拋出OutOfMemoryException內存溢出錯誤,因此初步把死機的原因定位為內存泄漏引起內存不足,進(jìn)而引起內存溢出錯誤。如何查找引起內存泄漏的原因呢?有兩種思路:第一種,安排有經(jīng)驗的編程人員對代碼進(jìn)行走查和分析,找出內存泄漏發(fā)生的位置;第二種,使用專(zhuān)門(mén)的內存泄漏測試工具Optimizeit進(jìn)行測試。這兩種方法都是解決系統穩定性問(wèn)題的有效手段,使用內存測試工具對于已經(jīng)暴露出來(lái)的內存泄漏問(wèn)題的定位和解決非常有效;但是軟件測試的理論也告訴我們,系統中永遠存在一些沒(méi)有暴露出來(lái)的問(wèn)題,而且,系統的穩定性問(wèn)題也不僅僅只是內存泄漏的問(wèn)題,代碼走查是提高系統的整體代碼質(zhì)量乃至解決潛在問(wèn)題的有效手段?;谶@樣的考慮,我們的內存穩定性工作決定采用代碼走查結合測試工具的使用,雙管齊下,爭取比較徹底地解決系統的穩定性問(wèn)題。
在代碼走查的工作中,安排了對系統業(yè)務(wù)和開(kāi)發(fā)語(yǔ)言工具比較熟悉的開(kāi)發(fā)人員對應用的代碼進(jìn)行了交叉走查,找出代碼中存在的數據庫連接聲明和結果集未關(guān)閉、代碼冗余和低效等故障若干,取得了良好的效果,文中主要講述結合工具的使用對已經(jīng)出現的內存泄漏問(wèn)題的定位方法。
內存泄漏的基本原理 在C++語(yǔ)言程序中,使用new操作符創(chuàng )建的對象,在使用完畢后應該通過(guò)delete操作符顯示地釋放,否則,這些對象將占用堆空間,永遠沒(méi)有辦法得到回收,從而引起內存空間的泄漏。如下的簡(jiǎn)單代碼就可以引起內存的泄漏:
void function(){ Int[] vec = new int[5]; } |
在function()方法執行完畢后,vec數組已經(jīng)是不可達對象,在C++語(yǔ)言中,這樣的對象永遠也得不到釋放,稱(chēng)這種現象為內存泄漏。
而Java是通過(guò)垃圾收集器(Garbage Collection,GC)自動(dòng)管理內存的回收,程序員不需要通過(guò)調用函數來(lái)釋放內存,但它只能回收無(wú)用并且不再被其它對象引用的那些對象所占用的空間。在下面的代碼中,循環(huán)申請Object對象,并將所申請的對象放入一個(gè)Vector中,如果僅僅釋放對象本身,但是因為Vector仍然引用該對象,所以這個(gè)對象對GC來(lái)說(shuō)是不可回收的。因此,如果對象加入到Vector后,還必須從Vector中刪除,最簡(jiǎn)單的方法就是將Vector對象設置為null。
Vector v = new Vector(10); for (int i = 1; i < 100; i++) { Object o = new Object(); v.add(o); o = null; }//此時(shí),所有的Object對象都沒(méi)有被釋放,因為變量v引用這些對象。 |
實(shí)際上無(wú)用,而還被引用的對象,GC就無(wú)能為力了(事實(shí)上GC認為它還有用),這一點(diǎn)是導致內存泄漏最重要的原因。
Java的內存回收機制可以形象地理解為在堆空間中引入了重力場(chǎng),已經(jīng)加載的類(lèi)的靜態(tài)變量和處于活動(dòng)線(xiàn)程的堆??臻g的變量是這個(gè)空間的牽引對象。這里牽引對象是指按照Java語(yǔ)言規范,即便沒(méi)有其它對象保持對它的引用也不能夠被回收的對象,即Java內存空間中的本原對象。當然類(lèi)可能被去加載,活動(dòng)線(xiàn)程的堆棧也是不斷變化的,牽引對象的集合也是不斷變化的。對于堆空間中的任何一個(gè)對象,如果存在一條或者多條從某個(gè)或者某幾個(gè)牽引對象到該對象的引用鏈,則就是可達對象,可以形象地理解為從牽引對象伸出的引用鏈將其拉住,避免掉到回收池中;而其它的不可達對象由于不存在牽引對象的拉力,在重力的作用下將掉入回收池。在圖1中,A、B、C、D、E、F六個(gè)對象都被牽引對象所直接或者間接地“牽引”,使得它們避免在重力的作用下掉入回收池。如果TR1-A鏈和TR2-D鏈斷開(kāi),則A、B、C三個(gè)對象由于失去牽引,在重力的作用下掉入回收池(被回收),D對象也是同樣的原因掉入回收池,而F對象仍然存在一個(gè)牽引鏈(TR3-E-F),所以不會(huì )被回收,如圖2、3所示。
圖1 初始狀態(tài)
圖2 TR1-A鏈和TR2-D鏈斷開(kāi),A、B、C、D掉入回收池
圖3 A、B、C、D四個(gè)對象被回收
通過(guò)前面的介紹可以看到,由于采用了垃圾回收機制,任何不可達對象都可以由垃圾收集線(xiàn)程回收。因此通常說(shuō)的Java內存泄漏其實(shí)是指無(wú)意識的、非故意的對象引用,或者無(wú)意識的對象保持。無(wú)意識的對象引用是指代碼的開(kāi)發(fā)人員本來(lái)已經(jīng)對對象使用完畢,卻因為編碼的錯誤而意外地保存了對該對象的引用(這個(gè)引用的存在并不是編碼人員的主觀(guān)意愿),從而使得該對象一直無(wú)法被垃圾回收器回收掉,這種本來(lái)以為可以釋放掉的卻最終未能被釋放的空間可以認為是被“泄漏了”。
這里通過(guò)一個(gè)例子來(lái)演示Java的內存泄漏。假設有一個(gè)日志類(lèi)Logger,其提供一個(gè)靜態(tài)的log(String msg)方法,任何其它類(lèi)都可以調用Logger.Log(message)來(lái)將message的內容記錄到系統的日志文件中。Logger類(lèi)有一個(gè)類(lèi)型為HashMap的靜態(tài)變量temp,每次在執行log(message)方法的時(shí)候,都首先將message的值丟入temp中(以當前線(xiàn)程+當前時(shí)間為鍵),在方法退出之前再從temp中將以當前線(xiàn)程和當前時(shí)間為鍵的條目刪除。注意,這里當前時(shí)間是不斷變化的,所以log方法在退出之前執行刪除條目的操作并不能刪除方法執行之初丟入的條目。這樣,任何一個(gè)作為參數傳給log方法的字符串最終由于被Logger的靜態(tài)變量temp引用,而無(wú)法得到回收,這種違背實(shí)現者主觀(guān)意圖的無(wú)意識的對象保持就是我們所說(shuō)的Java內存泄漏。
鑒別泄漏對象的方法 一般說(shuō)來(lái),一個(gè)正常的系統在其運行穩定后其內存的占用量是基本穩定的,不應該是無(wú)限制的增長(cháng)的,同樣,對任何一個(gè)類(lèi)的對象的使用個(gè)數也有一個(gè)相對穩定的上限,不應該是持續增長(cháng)的。根據這樣的基本假設,我們可以持續地觀(guān)察系統運行時(shí)使用的內存的大小和各實(shí)例的個(gè)數,如果內存的大小持續地增長(cháng),則說(shuō)明系統存在內存泄漏,如果某個(gè)類(lèi)的實(shí)例的個(gè)數持續地增長(cháng),則說(shuō)明這個(gè)類(lèi)的實(shí)例可能存在泄漏情況。
Optimizeit是Borland公司的產(chǎn)品,主要用于協(xié)助對軟件系統進(jìn)行代碼優(yōu)化和故障診斷,其功能眾多,使用方便,其中的OptimizeIt Profiler主要用于內存泄漏的分析。Profiler的堆視圖(如圖4)就是用來(lái)觀(guān)察系統運行使用的內存大小和各個(gè)類(lèi)的實(shí)例分配的個(gè)數的,其界面如圖四所示,各列自左至右分別為類(lèi)名稱(chēng)、當前實(shí)例個(gè)數、自上個(gè)標記點(diǎn)開(kāi)始增長(cháng)的實(shí)例個(gè)數、占用的內存空間的大小、自上次標記點(diǎn)開(kāi)始增長(cháng)的內存的大小、被釋放的實(shí)例的個(gè)數信息、自上次標記點(diǎn)開(kāi)始增長(cháng)的內存的大小被釋放的實(shí)例的個(gè)數信息,表的最后一行是匯總數據,分別表示目前JVM中的對象實(shí)例總數、實(shí)例增長(cháng)總數、內存使用總數、內存使用增長(cháng)總數等。
在實(shí)踐中,可以分別在系統運行四個(gè)小時(shí)、八個(gè)小時(shí)、十二個(gè)小時(shí)和二十四個(gè)小時(shí)時(shí)間點(diǎn)記錄當時(shí)的內存狀態(tài)(即抓取當時(shí)的內存快照,是工具提供的功能,這個(gè)快照也是供下一步分析使用),找出實(shí)例個(gè)數增長(cháng)的前十位的類(lèi),記錄下這十個(gè)類(lèi)的名稱(chēng)和當前實(shí)例的個(gè)數。在記錄完數據后,點(diǎn)擊Profiler中右上角的Mark按鈕,將該點(diǎn)的狀態(tài)作為下一次記錄數據時(shí)的比較點(diǎn)。
圖4 Profiler 堆視圖
系統運行二十四小時(shí)以后可以得到四個(gè)內存快照。對這四個(gè)內存快照進(jìn)行綜合分析,如果每一次快照的內存使用都比上一次有增長(cháng),可以認定系統存在內存泄漏,找出在四個(gè)快照中實(shí)例個(gè)數都保持增長(cháng)的類(lèi),這些類(lèi)可以初步被認定為存在泄漏。
分析與定位 通過(guò)上面的數據收集和初步分析,可以得出初步結論:系統是否存在內存泄漏和哪些對象存在泄漏(被泄漏),如果結論是存在泄漏,就可以進(jìn)入分析和定位階段了。
前面已經(jīng)談到Java中的內存泄漏就是無(wú)意識的對象保持,簡(jiǎn)單地講就是因為編碼的錯誤導致了一條本來(lái)不應該存在的引用鏈的存在(從而導致了被引用的對象無(wú)法釋放),因此內存泄漏分析的任務(wù)就是找出這條多余的引用鏈,并找到其形成的原因。前面還講到過(guò)牽引對象,包括已經(jīng)加載的類(lèi)的靜態(tài)變量和處于活動(dòng)線(xiàn)程的堆??臻g的變量。由于活動(dòng)線(xiàn)程的堆??臻g是迅速變化的,處于堆??臻g內的牽引對象集合是迅速變化的,而作為類(lèi)的靜態(tài)變量的牽引對象的集合在系統運行期間是相對穩定的。
對每個(gè)被泄漏的實(shí)例對象,必然存在一條從某個(gè)牽引對象出發(fā)到達該對象的引用鏈。處于堆??臻g的牽引對象在被從棧中彈出后就失去其牽引的能力,變?yōu)榉菭恳龑ο?,因此,在長(cháng)時(shí)間的運行后,被泄露的對象基本上都是被作為類(lèi)的靜態(tài)變量的牽引對象牽引。
Profiler的內存視圖除了堆視圖以外,還包括實(shí)例分配視圖(圖5)和實(shí)例引用圖(圖6)。
Profiler的實(shí)例引用圖為找出從牽引對象到泄漏對象的引用鏈提供了非常直接的方法,其界面的第二個(gè)欄目中顯示的就是從泄漏對象出發(fā)的逆向引用鏈。需要注意的是,當一個(gè)類(lèi)的實(shí)例存在泄漏時(shí),并非其所有的實(shí)例都是被泄漏的,往往只有一部分是被泄漏對象,其它則是正常使用的對象,要判斷哪些是正常的引用鏈,哪些是不正常的引用鏈(引起泄漏的引用鏈)。通過(guò)抽取多個(gè)實(shí)例進(jìn)行引用圖的分析統計以后,可以找出一條或者多條從牽引對象出發(fā)的引用鏈,下面的任務(wù)就是找出這條引用鏈形成的原因。
實(shí)例分配圖提供的功能是對每個(gè)類(lèi)的實(shí)例的分配位置進(jìn)行統計,查看實(shí)例分配的統計結果對于分析引用鏈的形成具有一定的作用,因為找到分配鏈與引用鏈的交點(diǎn)往往就可以找到了引用鏈形成的原因,下面將具體介紹。
圖5 實(shí)例分配圖
圖6 實(shí)例引用圖
設想一個(gè)實(shí)例對象a在方法f中被分配,最終被實(shí)例對象b所引用,下面來(lái)分析從b到a的引用鏈可能的形成原因。方法f在創(chuàng )建對象a后,對它的使用分為四種情況:1、將a作為返回值返回;2、將a作為參數調用其它方法;3、在方法內部將a的引用傳遞給其它對象;4、其它情況。其中情況4不會(huì )造成由b到a的引用鏈的生成,不用考慮。下面考慮其它三種情況:對于1、2兩種情況,其造成的結果都是在另一個(gè)方法內部獲得了對象a的引用,它的分析與方法f的分析完全一樣(遞歸分析);考慮第3種情況:1、假設方法f直接將對象a的引用加入到對象b,則對象b到a的引用鏈就找到了,分析結束;2、假設方法f將對象a的引用加入到對象c,則接下來(lái)就需要跟蹤對象c的使用,對象c的分析比對象a的分析步驟更多一些,但大體原理都是一樣的,就是跟蹤對象從創(chuàng )建后被使用的歷程,最終找到其被牽引對象引用的原因。
現在將泄漏對象的引用鏈以及引用鏈形成的原因找到了,內存泄漏測試與分析的工作就到此結束,接下來(lái)的工作就是修改相應的設計或者實(shí)現中的錯誤了。
總結 使用上述的測試和分析方法,在實(shí)踐中先后進(jìn)行了三次測試,找出了好幾處內存泄漏錯誤。系統的穩定性得到很大程度的提高,最初運行1~2天就拋出內存溢出異常,修改完成后,系統從未出現過(guò)內存溢出異常。此方法適用于任何使用Java語(yǔ)言開(kāi)發(fā)的、對穩定性有比較高要求的軟件系統。