前言
先提提神(提神後再聊技術)
提神了嘛朋友們,先看技術
這段時間我正在研究我繼承的一些Java程式碼。我正在關鍵的地方做一些速度改進,為了測試我的改進,我需要測試用例來比較不同的實現。不幸的是,手動生成測試用例太耗時了(需要數千個,手動生成一個測試用例需要幾分鐘甚至幾個小時)。不幸的是,隨機生成的測試用例也不起作用,因為我的測試用例是(命題的)LTL公式,並且隨機生成的一個我期望在實際使用程式時出現的大小,是不太可能令人滿意的,而現實生活中的公式是期望的,這在一定程度上得益於程式的持續整合和測試工具。
為了解決這個問題,我設計了一個簡單的計劃:生成測試用例並根據它們是否可滿足對它們進行排序,併為不易分類的公式新增第三個類別(即,在使用簡單但快速高效的實現進行測試時記憶體不足)。這一切導致我產生了數十萬個公式,測試它們是否令人滿意,收集其中一些,然後丟棄其餘的。
這應該很簡單,但我注意到我的程式在執行時的奇怪行為:執行之後,它變慢了,容易分類的公式變得更加頻繁。在一個例子中,我沒有可滿足的公式,8000個不可滿足的公式和1或2個失敗。讓計算執行,我有0個可滿足的公式,9000個不可滿足的公式,還有20個失敗。然後程式記憶體不足。這種行為是不可能的:我在生成過程中捕捉到OutOfMemoryException,然後應該釋放所有記憶體,所以我永遠不會耗盡記憶體。其次,公式是完全隨機生成的,因此8000次執行中有2次失敗,但1000次執行中有18次失敗似乎不太可能。總而言之,這指向了記憶體中積累的東西。
通常,您可以檢視程式碼和發生記憶體洩漏的pin點。在垃圾收集語言(如Java)中,通常會以兩種方式遇到記憶體洩漏:垃圾收集器看不到某些記憶體不再使用,或者您忘記刪除對不再使用的物件的引用。前者不太可能,因為Java使用非常複雜的技術,包括遍歷整個堆的標記和掃描演算法(儘管這不是非常頻繁地執行,因為它的計算成本很高)。這兩者中的哪一個是真正的問題並不重要,因為解決方法是相同的:確保將不再使用的引用設定為null,這樣垃圾收集器就可以發現它們不再使用。
您可能有興趣看看java.lang.ref package。它包含的類可以讓您與垃圾收集器互動,特別是WeakReference,它引用一個物件,但不會強制它停留在記憶體中,因此,如果只有對物件的弱引用,則可以對它進行垃圾收集,而SoftReference,它引用的物件應該儲存在記憶體中,但如果程式記憶體不足,則可以對其進行垃圾回收。弱引用對於實現工廠非常有用,這些工廠確保a.equals(b)那麼a==b。如果對複雜物件進行大量比較,這是一個優勢,可以透過向工廠新增一組生成的物件並確保始終在該集中查詢物件並在要求時返回相同的例項來實現創造一個相等的。不幸的是,除非使用弱引用,否則您的集合將使物件保持活動狀態,從而導致記憶體洩漏。軟引用對於建立複雜計算的記憶體快取或從較慢的儲存中獲得的值非常有用。可以很容易地重新計算/重新讀取該值,但不這樣做會更快。因此,如果可能,快取的值應該儲存在記憶體中,但是如果應用程式需要記憶體用於其他用途,則回收快取比將重要值交換到磁碟更有用。
使用JConsole監視程式首先要做的是弄清楚程式是否真的在洩漏記憶體。我們可以使用JConsole來實現這一點,JConsole附帶JDK表單Sun(或Oracle,但我不喜歡它們,因為它們將java api放在一個速度較慢的伺服器上)。我們只需啟動JConsole(在osx或Unix上,只要在提示符處鍵入JConsole,在Windows上,我想可以在開始選單中找到JConsole)。首先,我們選擇要監控的流程:
當JConsole啟動時,我們切換到Memory選項卡並監視記憶體。由於我們對長時間行為感興趣,我們選擇只顯示堆物件的最老年代。年輕化的波動很大,我像瘋了一樣分配和銷燬物品,我們只關心是否有東西積累起來,這意味著它最終會被提升到舊的狀態。為了更好地衡量,我們可以偶爾點選performgc按鈕來清理記憶體。這會使使用的記憶體略有減少,但您仍會得到這樣的總體趨勢:
生成堆映像現在,我們已經確定我的程式碼包含記憶體洩漏。我們如何從那裡繼續?一種方法是遍歷我們的程式碼並隨機刪除引用,直到您不再看到錯誤的行為,但這有點特別,而且很難知道何時完成。相反,我們將使用記憶體分析器。幸運的是,JDK內建了所有需要的工具。您所要做的就是建立一個堆映像,並對孤立物件進行分析。
基本上可以用兩種方法生成堆,每種方法各有優缺點:使用hprof或使用jmap。使用hprof要慢得多,但可以提供更多資訊,例如類名和分配堆疊,而jmap全速執行程式,但只提供某些類的名稱。這一部分是基於這篇部落格文章,但我的文章使選項和階段之間的區別更清楚,並提供了圖片。
使用Hprof只需使用hprof啟動您的程式:
java -Dcom.sun.management.jmxremote \ -Dcom.sun.management.jmxremote.port=9000 \ -Dcom.sun.management.jmxremote.authenticate=false \ -Dcom.sun.management.jmxremote.ssl=false \ -agentlib:hprof=heap=dump,file=hprof.bin,format=b,depth=10 \ -jar java_app.jar
這基本上啟動探查器,禁用所有安全性並告訴它轉儲到一個名為hprof.bin檔案. 如果您的程式不在jar檔案中,您可以簡單地用應用程式的主類替換最後一行。現在練習您的程式,完成後,按Ctrl-C轉儲堆並退出應用程式。
使用Jmap正常啟動程式,可以從命令列、檔案管理器(Finder或Windows資源管理器或現在Lunix使用者使用的任何討厭的工具)或IDE啟動程式。然後找到程式的程序id。您可以用幾種不同的方法來實現這一點,但更簡單的方法是檢視JConsole,在JConsole中可以使用它。在上面的示例中,程序id是59735(請參閱程序選擇螢幕上的PID列或監視程序時的標題欄)。現在練習你的程式。
然後使用jmap轉儲影象:
jmap -dump:format=b,file=hprof.bin 59735
記住用JConsole找到的程序id替換59735。
訓練你的程式我們現在從兩個不同的分支合併。你的讀者沒有比你更愉快的閱讀體驗,但是你呢?嗯,我們需要鍛鍊我們的計劃。也就是說,執行我們認為會導致錯誤的操作。在我的例子中,我應該啟動程式並讓它執行,但是你可能需要載入一個檔案,列印它或者你的程式做什麼。重複幾次,這樣堆中的錯誤就更明顯了。
分析堆映像當我們使用任何一種方法生成了一個堆映像時,就應該分析它了。幸運的是,JDK有這樣一個工具,jhat(我不知道是誰想出了這些名字,但他們應該被槍殺)。以以下方式開始jhat:
jhat -J-Xmx2G hprof.bin
注意-J和-Xmx2G之間沒有空格,我們設定這個選項是為了允許jhat使用2GB的RAM,這可能需要它來分析我們相當大的堆映像。
jhat將處理(這可能需要很長時間),並在本地計算機上的埠7000上啟動一個web伺服器,因為為什麼不呢。要檢視生成的資料,請將瀏覽器指向localhost:7000。你會得到這樣的結果:
立即滾動到底部:
選擇Show heap histogram連結(注意:你的瀏覽器可能沒有一個紅色的矩形指出正確的連結,正如我在後處理中新增的那樣)。對於我的應用程式,如果使用hprof,柱狀圖如下所示:
或者像這樣如果我用jhat的話:
數字不同,因為hprof的時間取決於我願意等待多長時間,而jmap的時間取決於我轉儲影象的速度。注意,jmap輸出並不總是顯示好的類名。在下面,我將只顯示hprof輸出,因為它更好,但是使用jmap輸出也可以完成任何事情。
這個輸出應該像一個普通的profiler輸出一樣閱讀:關注那些你擁有很多的輸出。在我的例子中,我有一大堆公式和HashMap$Entry類。事實上,我比其他任何東西都多了幾百個。這是有道理的,我有一堆公式類,因為我處理公式,但不超過一百萬。雖然資料結構是遞迴的,但對於一個複雜的公式,我可能會用幾千,而不是一百萬。另外,HashMap$條目也吸引了我。這表示某個東西儲存在HashMap中,並且比較這些數字,表明某些東西可能只是公式。檢視Formula類沒有顯示HashMap,所以我有點不知所措:什麼包含所有這些公式?
快速地按型別連結引用摘要,因為它開始載入一個很長的列表,其中包含超過一百萬個引用,這可能會殺死你的瀏覽器。然後我們看到:
這告訴我們兩件事:Formula顯然是遞迴資料型別(大多數例項由其他Formula物件引用),其餘的由HashMap$Entry物件引用。第三個地方的公式陣列是可以的,因為它包含生成公式的模板(我可以透過連結看到這一點,但我沒有在這裡顯示)。讓我們輕點HashMap$條目,看看它會帶我們去哪裡。它把我們帶到這裡:
因此,它們要麼從陣列引用,要麼從其他HashMap$Entry物件引用。Guess Java的雜湊表是透過使用HashMap$Entry物件的連結串列來處理衝突的條目陣列來實現的。18000次碰撞並不是很好…總之,讓我們看看陣列:
毫不奇怪,這些都在HashMaps中。以下內容:
這些程式碼在很多地方都有使用,但是我的程式碼只在少數地方出現。節點非常有意義:我確實有一些引用節點的圖,節點的數量似乎很小,可以理解。SinglePropertyGraph還包含公式,而且物件的數量(1)再次非常合理。不過,DefaultFormulaFactory有點奇怪。為什麼它要有一個公式圖?而且,它是唯一一個在我的程式碼執行過程中存在的物件。啟動Eclipse並載入工廠:
好像我們找到了罪魁禍首。工廠規範化公式物件。這顯然是不好的,當你產生數以百萬計。規範化物件的原因是為了允許快速比較,所以我可以使用WeakReferences來解決這個問題,但是我使Formula物件不可變(無法修改),並添加了雜湊值的預計算和有效的相等函式:
現在,我剛剛擺脫了規範化,取而代之的是更有效的雜湊函式和等式測試。然後我執行我的迴歸測試,以確保我沒有破壞任何東西(迴歸測試建立起來很糟糕,但當你做這樣的事情時,它們真的很震撼)。
驗證記憶體洩漏是否消失為了驗證這是否真的修復了記憶體洩漏,我再次嘗試JConsole,它現在顯示瞭如下內容:
即使隨著時間的推移,我們的記憶體消耗也不再穩定增長。釘子沒問題。
為了確保我沒有錯過任何東西,我再開始我的程式。我讓執行幾秒鐘並建立一個轉儲(使用jmap),讓程式繼續執行一兩分鐘並建立一個輔助轉儲(到一個新檔案)。
然後我們又去查jhat,這次
jhat -J-Xmx2G -baseline heap1.bin heap2.bin
這裡heap1.bin是我修復錯誤後生成的第一個堆映像,heap2.bin是我讓程式執行幾分鐘後建立的映像。-baseline選項告訴jhat使用第一個堆映像作為基線,並將兩個映像中的任何物件標記為“old”,將僅在後一個映像中的任何物件標記為“new”。啟動瀏覽器,我們現在選擇顯示例項計數連結整潔的底部:
這顯示了有多少例項以及其中有多少是新例項:
這表明,雖然我仍然有很多公式例項,但所有這些都是新的,因此我沒有洩漏任何公式例項。任何與HashMap相關的問題也是如此。事實上,我看到幾乎所有的物件都是新的,這是不尋常的,但對我的程式來說是非常有意義的,它分配一堆公式,測試它們,轉儲它們,然後丟棄它們。
結論在這裡,我們看到了如何使用JDK提供的工具來消除程式中的記憶體洩漏。我的程式有點特別,因為隨著時間的推移,它幾乎會轉儲記憶體中的所有內容,使得檢測記憶體洩漏特別容易。不過,用於驗證記憶體洩漏是否已被發現和刪除的技術也可以用於一般情況:例如,啟動程式並使其處於穩定狀態。然後倒垃圾。現在練習你的程式,使它回到穩定狀態。然後建立一個新的轉儲,並將其與舊轉儲進行比較。您應該幾乎看不到任何新物件-新物件對應於洩漏。
您還可以探索程式的一些選項;例如,jmap有一個-histo選項,它可以立即在控制檯中顯示記憶體歷史記錄,這比生成和分析轉儲檔案要快得多。
除此之外,它只是關於在時間和空間上對程式碼進行分析,並確保在出於效能原因對工作程式進行更改之前擁有可靠的迴歸測試套件。
原文連結:http://javakk.com/1140.html