深入解析Java OutOfMemoryError
在Java中,所有對象都存儲在堆中。他們通過new關鍵字來進行分配,JVM會檢查是否所有線程都無法在訪問他們了,并且會將他們進行回收。在大多數時候程序員都不會有一絲一毫的察覺,這些工作都被靜悄悄的執(zhí)行。但是,有時候在發(fā)布前的***一天,程序掛了。
- Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
OutOfMemoryError是一個讓人很郁悶的異常。它通常說明你干了寫錯誤的事情:沒必要的長時間保存一些沒必要的數據,或者同一時間處理了過多的數據。有些時候,這些問題并不一定受你的控制,比如說一些第三方的庫對一些字符串做了緩存,或者一些應用服務器在部署的時候并沒有進行清理。并且,對于堆中已經存在的對象,我們往往拿他們沒辦法。
這篇文章分析了導致OutOfMemoryError的不同原因,以及你該怎樣應對這種原因的方法。以下分析僅限于Sun Hotspot虛擬機,但是大多數結論都適用于其他任何的JVM實現(xiàn)。它們大多數基于網上的文章以及我自己的經驗。我沒有直接做JVM開發(fā)的工作,因此結論并不代表JVM的作者。但是我確實曾經遇到過并解決了很多內存相關的問題。
垃圾回收介紹
我在這篇文章中已經詳細介紹了垃圾回收的過程。簡單的說,標記-清除算法(mark-sweep collect)以garbage collection roots作為掃描的起點,并對整個對象圖進行掃描,對所有可達的對象進行標記。那些沒有被標記的對象會被清除并回收。
Java的垃圾回收算法過程意味著如果出現(xiàn)了OOM,那么說明你在不停的往對象圖中添加對象并且沒有移除它們。這通常是因為你在往一個集合類中添加了很多對象,比如Map,并且這個集合對象是static的?;蛘?,這個集合類被保存在了ThreadLocal對象中,而這個對應的Thread卻又長時間的運行,一直不退出。
這與C和C++的內存泄露完全不一樣。在這些語言中,如果一些方法調用了malloc()或者new,并且在方法退出的時候沒有調用相應的free()或者delete,那么內存就會產生泄露。這些是真正意義上得泄露,你在這個進程范圍內不可能再恢復這些內存,除非使用一些特定的工具來保證每一個內存分配方法都有其對應的內存釋放操作相對應。
在java中,“泄露”這個詞往往被誤用了。因為從JVM的角度來說,所有的內存都是被良好管理的。問題僅僅是作為程序員的你不知道這些內存是被哪些對象占用了。但是幸運的是,你還是有辦法去找到和定位它們。
在深入探討之前,你還有***一件關于垃圾收集的知識需要了解:JVM會盡***的能力去釋放內存,直到發(fā)生OOM。這就意味著OOM不能通過簡單的調用System.gc()來解決,你需要找到這些“泄露”點,并自己處理它們。
設置堆大小
學院派的人非常喜歡說Java語言規(guī)范并沒有對垃圾收集器進行任何約定,你甚至可以實現(xiàn)一個從來不釋放內存的JVM(實際是毫無意義的)。Java虛擬機規(guī)范中提到堆是由垃圾回收器進行管理,但是卻沒有說明任何相關細節(jié)。僅僅說了我剛才提到的那句話:垃圾回收會發(fā)生在OOM之前。
實際上,Sun Hotspot虛擬機使用了一個固定大小的堆空間,并且允許在最小空間和***空間之間進行自動增長。如果你沒有指定最小值和***值,那么對于’client’模式將會默認使用2Mb最為最小值,64Mb最為***值;對于’server’模式,JVM會根據當前可用內存來決定默認值。2000年后,默認的***堆大小改為了64M,并且在當時已經認為足夠大了(2000年前的時候默認值是16M),但是對于現(xiàn)在的應用程序來說很容易就用完了。
這意味著你需要顯示的通過JVM參數來指定堆的最小值和***值:
- java -Xms256m -Xmx512m MyClass
這里有很多經驗上得法則來設定***值和最小值。顯然,堆的***值應該設定為足以容下整個應用程序所需要的全部對象。但是,將它設定為“剛剛好足夠大”也不是一個很好的注意,因為這樣會增加垃圾回收器的負載。因此,對于一個長時間運行的應用程序,你一般需要保持有20%-25%的空閑堆空間。(你得應用程序可能需要不同的參數設置,GC調優(yōu)是一門藝術,并且不在該文章討論范圍內)
讓你奇怪的時,設置合適的堆的最小值往往比設置合適的***值更加重要。垃圾回收器會盡可能的保證當前的的堆大小,而不是不停的增長堆空間。這會導致應用程序不停的創(chuàng)建和回收大量的對象,而不是獲取新的堆空間,相對于初始(最小)堆空間。Java堆會盡量保持這樣的堆大小,并且會不停的運行GC以保持這樣的容量。因此,我認為在生產環(huán)境中,我們***是將堆的最小值和***值設置成一樣的。
你可能會困惑于為什么Java堆會有一個***值上限:操作系統(tǒng)并不會分配真正的物理內存,除非他們真的被使用了。并且,實際使用的虛擬內存空間實際上會比Java堆空間要大。如果你運行在一個32位系統(tǒng)上,一個過大的堆空間可能會限制classpath中能夠使用的jar的數量,或者你可以創(chuàng)建的線程數。
另外一個原因是,一個受限的***堆空間可以讓你及時發(fā)現(xiàn)潛在的內存泄露問題。在開發(fā)環(huán)境中,對應用程序的壓力往往是不夠的,如果你在開發(fā)環(huán)境中就擁有一個非常大得堆空間,那么你很有可能永遠不會發(fā)現(xiàn)可能的內存泄露問題,直到進入產品環(huán)境。
在運行時跟蹤垃圾回收
所有的JVM實現(xiàn)都提供了-verbos:gc選項,它可以讓垃圾回收器在工作的時候打印出日志信息:
- java -verbose:gc com.kdgregory.example.memory.SimpleAllocator
- [GC 1201K->1127K(1984K), 0.0020460 secs]
- [Full GC 1127K->103K(1984K), 0.0196060 secs]
- [GC 1127K->1127K(1984K), 0.0006680 secs]
- [Full GC 1127K->103K(1984K), 0.0180800 secs]
- [GC 1127K->1127K(1984K), 0.0001970 secs]
- ...
Sun的JVM提供了額外的兩個參數來以內存帶分類輸出,并且會顯示垃圾收集的開始時間:
- java -XX:+PrintGCDetails -XX:+PrintGCTimeStamps com.kdgregory.example.memory.SimpleAllocator
- 0.095: [GC 0.095: [DefNew: 177K->64K(576K), 0.0020030 secs]0.097: [Tenured: 1063K->103K(1408K), 0.0178500 secs] 1201K->103K(1984K), 0.0201140 secs]
- 0.117: [GC 0.118: [DefNew: 0K->0K(576K), 0.0007670 secs]0.119: [Tenured: 1127K->103K(1408K), 0.0392040 secs] 1127K->103K(1984K), 0.0405130 secs]
- 0.164: [GC 0.164: [DefNew: 0K->0K(576K), 0.0001990 secs]0.164: [Tenured: 1127K->103K(1408K), 0.0173230 secs] 1127K->103K(1984K), 0.0177670 secs]
- 0.183: [GC 0.184: [DefNew: 0K->0K(576K), 0.0003400 secs]0.184: [Tenured: 1127K->103K(1408K), 0.0332370 secs] 1127K->103K(1984K), 0.0342840 secs]
- ...
從上面的輸出我們可以看出什么?首先,前面的幾次垃圾回收發(fā)生的非常頻繁。每行的***個字段顯示了JVM啟動后的時間,我們可以看到在一秒鐘內有上百次的GC。并且,還加入了每次GC執(zhí)行時間的開始時間(在每行的***一個字段),可以看出垃圾搜集器是在不停的運行的。
但是在實時系統(tǒng)中,這會造成很大的問題,因為垃圾搜集器的執(zhí)行會奪走很多的CPU周期。就像我之前提到的,這很可能是由于初始堆大小設置的太小了,并且GC日志顯示了:每次堆的大小達到了1.1Mb,它就開始執(zhí)行GC。如果你得系統(tǒng)也有類似的現(xiàn)象,請在改變自己的應用程序之前使用-Xms來增大初始堆大小。
對于GC日志還有一些很有趣的地方:除了***次垃圾回收,沒有任何對象是存放在了新生代(“DefNew”)。這說明了這個應用程序分配了包含大量數據的數組,在顯示世界里這是很少出現(xiàn)的。如果在一個實時系統(tǒng)中出現(xiàn)這樣的狀況,我想到的***個問題是“這些數組拿來干什么用?”。
堆轉儲(Heap Dumps)
一個堆轉儲可以顯示你在應用程序說使用的所有對象。從基礎上講,它僅僅反映了對象實例的數量和類文件所占用的字節(jié)數。當然你也可以將分配這些內存的代碼一起dump出來,并且對比歷史存貨對象。但是,如果你要dump的數據信息越多,JVM的負載就會越大,因此這些技術僅僅應該使用在開發(fā)環(huán)境中。
怎樣獲得一個內存轉儲
命令行參數-XX:+HeapDumpOnOutOfMemoryError是最簡單的方式生成內存轉儲。就像它的名字所說的,它會在內存被用完的時候(發(fā)生OOM)進行轉儲,這在產品環(huán)境非常好用。但是由于這個是一種事后轉儲(已經發(fā)生了OOM),它只能提供一種歷史性的數據。它會產生一個二進制文件,你可以使用jhat來操作該文件(這個工具在JDK1.6中已經提供,但是可以讀取JDK1.5產生的文件)。
你可以使用jmap(JDK1.5之后就自帶了)來為一個運行中得java程序產生堆轉儲,可以產生一個在jhat中使用的dump文件,或者是一個存文本的統(tǒng)計文件。統(tǒng)計圖可以在進行分析時優(yōu)先使用,特別是你要在一段時間內多次轉儲堆并進行分析和對比歷史數據。
從轉儲內容和JVM的負荷的擴展性上考慮的話,可以使用profilers。Profiles使用JVM的調試接口(debuging interface)來搜集對象的內存分配信息,包括具體的代碼行和方法調用棧。這個是非常有用的:不僅僅可以知道你分配了一個數GB的數組,你還可以知道你在一個特定的地方分配了950MB的對象,并且直接忽略其他的對象。當然,這些結果肯定會對JVM有開銷,包括CPU的開銷和內存的開銷(保存一些原始數據)。你不應該在產品環(huán)境中使用profiles。
堆轉儲分析:live objects
Java中的內存泄露是這樣定義的:你在內存中分配了一些對象,但是并沒有清除掉所有對它們的引用,也就是說垃圾搜集器不能回收它們。使用堆轉儲直方圖可以很容易的查找這些泄露對象:它不僅僅可以告訴你在內存中分配了哪些對象,并且顯示了這些對象在內存中所占用的大小。但是這種直方圖***的問題是:對于同一個類的所有對象都被聚合(group)在一起了,所以你還需要進一步做一些檢測來確定這些內存在哪里被分配了。
使用jmap并且加上-histo參數可以為你產生一個直方圖,它顯示了從程序運行到現(xiàn)在所有對象的數量和內存消耗,并且包含了已經被回收的對象和內存。如果使用-histo:live參數會顯示當前還在堆中得對象數量及其內存消耗,不論這些對象是否要被垃圾搜集器進行回收。
也就是說,如果你要得到一個當前時間下得準確信息,你需要在使用jmap之前強制執(zhí)行一次垃圾回收。如果你的應用程序是運行在本地,最簡單的方式是直接使用jconsole:在’Memory’標簽下,有一個’Perform GC’的按鈕。如果應用程序是運行在服務端環(huán)境,并且JMX beans被暴露了,MemoryMXBean有一個gc()操作。如果上述的兩種方案都沒辦法滿足你得要求,你就只有等待JVM自己觸發(fā)一次垃圾搜集過程了。如果你有一個很嚴重的內存泄露問題,那么***次major collection很可能預示著不久后就會OOM。
有兩種方法使用jmap產生的直方圖。其中最有效的方法,適用于長時間運行的程序,可以使用帶live的命令行參數,并且在一段時間內多次使用該命令,檢查哪些對象的數量在不斷增長。但是,根據當前程序的負載,該過程可能會花費1個小時或者更多的時間。
另外一個更加快速的方式是直接比較當前存活的對象數量和總的對象數量。如果有些對象占據了總對象數量的大部分,那么這些對象很有可能發(fā)生內存泄露。這里有一個例子,這個應用程序已經連續(xù)幾周為100多個用戶提供了服務,結果列舉了前12個數量最多的對象。據我所知,這個程序沒有內存泄露的問題,但是像其他應用程序一樣做了常規(guī)性的內存轉儲分析操作。
- ~, 510> jmap -histo 7626 | more
- num #instances #bytes class name
- ----------------------------------------------
- 1: 339186 63440816 [C
- 2: 84847 18748496 [I
- 3: 69678 15370640 [Ljava.util.HashMap$Entry;
- 4: 381901 15276040 java.lang.String
- 5: 30508 13137904 [B
- 6: 182713 10231928 java.lang.ThreadLocal$ThreadLocalMap$Entry
- 7: 63450 8789976
- 8: 181133 8694384 java.lang.ref.WeakReference
- 9: 43675 7651848 [Ljava.lang.Object;
- 10: 63450 7621520
- 11: 6729 7040104
- 12: 134146 6439008 java.util.HashMap$Entry
- ~, 511> jmap -histo:live 7626 | more
- num #instances #bytes class name
- ----------------------------------------------
- 1: 200381 35692400 [C
- 2: 22804 12168040 [I
- 3: 15673 10506504 [Ljava.util.HashMap$Entry;
- 4: 17959 9848496 [B
- 5: 63208 8766744
- 6: 199878 7995120 java.lang.String
- 7: 63208 7592480
- 8: 6608 6920072
- 9: 93830 5254480 java.lang.ThreadLocal$ThreadLocalMap$Entry
- 10: 107128 5142144 java.lang.ref.WeakReference
- 11: 93462 5135952
- 12: 6608 4880592
當我們要嘗試尋找內存泄露問題,可以從消耗內存最多的對象著手。這聽上去很明顯,但是往往它們并不是內存泄露的根源。但是,它們任然是應該***下手的地方,在這個例子中,最占用內存的是一些char[]的數組對象(總大小是60MB,基本上沒有任何問題)。但是很奇怪的是當前存貨(live)的對象竟然占了歷史分配的總對象大小的三分之二。
一般來說,一個應用程序會分配對象,并且在不久之后就會釋放它們。如果保存一些對象的應用過長的時間,就很有可能會導致內存泄露。但是雖然是這么說的,實際上還是要具體情況具體分析,主要還是要看這個程序到底在做什么事情。字符數組對象(char[])往往和字符串對象(String)同時存在,大部分的應用程序都會在整個運行過程中一直保持著一些字符串對象的引用。例如,基于JSP的web應用程序在JSP頁面中定義了很多HTML字符串表達式。這種特殊的應用程序提供HTML服務,但是它們需要保持字符串引用的需求卻不一定那么清晰:它們提供的是目錄服務,并不是靜態(tài)文本。如果我遇到了OOM,我就會嘗試找到這些字符串在哪里被分配,為什么沒有被釋放。
另一個需要關注的是字節(jié)數組([B)。在JDK中有很多類都會使用它們(比如BufferedInputStream),但是卻很少在應用程序代碼中直接看到它們。通常它們會被用作緩存(buffer),但是緩存的生命周期不會很長。在這個例子中我們看到,有一半的字節(jié)數組任然保持存活。這個是令人擔憂的,并且它凸顯了直方圖的一個問題:所有的對象都按照它的類型被分組聚合了。對于應用程序對象(非JDK類型或者原始類型,在應用程序代碼中定義的類),這不是一個問題,因為它們會在程序的一個部分被集中分配。但是字節(jié)數組有可能會在任何地方被定義,并且在大多數應用程序中都被隱藏在一些庫中。我們是否應當搜索調用了new byte[]或者new ByteArrayOutputStream()的代碼?
堆轉儲分析:相關的原因和影響分析
為了找到導致內存泄露的最終原因,僅僅考慮按照類別(class)的分組的內存占用字節(jié)數是不夠的。你還需要將應用程序分配的對象和內存泄露的對象關聯(lián)起來考慮。一個方法是更加深入查看對象的數量,以便將具有關聯(lián)性的對象找出來。下面是一個具有嚴重內存問題的程序的轉儲信息:
- num #instances #bytes class name
- ----------------------------------------------
- 1: 1362278 140032936 [Ljava.lang.Object;
- 2: 12624 135469922 [B
- ...
- 5: 352166 45077248 com.example.ItemDetails
- ...
- 9: 1360742 21771872 java.util.ArrayList
- ...
- 41: 6254 200128 java.net.DatagramPacket
如果你僅僅去看信息的前幾行,你可能會去定位Object[]或者byte[],這些都是徒勞的。真正的問題出在ItemDetails和DatagramPacket上:前者分配了大量的ArrayList,進而又分配了大量的Object[];后者使用了大量的byte[]來保存從網絡上接收到的數據。
***個問題,分配了大量的數組,實際上不是內存泄露。ArrayList的默認構造函數會分配容量是10的數組,但是程序本身一般只使用1個或者2個槽位,這對于64位JVM來說會浪費62個字節(jié)的內存空間。一個更好的涉及方案是僅僅在有需要的時候才使用List,這樣對每個實例來說可以節(jié)約額外的48個字節(jié)。但是,對于這種問題也可以很輕易的通過加內存來解決,因為現(xiàn)在的內存非常便宜。
但是對于datagram的泄露就比較麻煩(如同定位這個問題一樣困難):這表明接收到的數據沒有被盡快的處理掉。
為了跟蹤問題的原因和影響,你需要知道你的程序是怎樣在使用這些對象。不多的程序才會直接使用Object[]:如果確實要使用數組,程序員一般都會使用帶類型的數組。但是,ArrayList會在內部使用。但是僅僅知道ArrayList的內存分配是不夠的,你還需要順著調用鏈往上走,看看誰分配了這些ArrayList。
其中一個方法是對比相關的對象數量。在上面的例子中,byte[]和DatagramPackage的關系是很明顯的:其中一個基本上是另外一個的兩倍。但是ArrayList和ItemDetails的關系就不那么明顯了。(實際上一個ItemDetails中會包含多個ArrayList)
這往往是個陷阱,讓你去關注那么數量最多的一些對象。我們有數百萬的ArrayList對象,并且它們分布在不同的class中,也有可能集中在一小部分class中。盡管如此,數百萬的對象引用是很容易被定位的。就算有10來個class可能會包含ArrayList,那么每個class的實體對象也會有十萬個,這個是很容易被定位的。
從直方圖中跟蹤這種引用關系鏈是需要花費大量精力的,幸運的是,jmap不僅僅可以提供直方圖,它還可以提供可以瀏覽的堆轉儲信息。
堆轉儲分析:跟蹤引用鏈
瀏覽堆轉儲引用鏈具有兩個步驟:首先需要使用-dump參數來使用jmap,然后需要用jhat來使用轉儲文件。如果你確定要使用這種方法,請一定要保證有足夠多的內存:一個轉儲文件通常都有數百M,jhat需要好幾個G的內存來處理這些轉儲文件。
- tmp, 517> jmap -dump:live,file=heapdump.06180803 7626
- Dumping heap to /home/kgregory/tmp/heapdump.06180803 ...
- Heap dump file created
- tmp, 518> jhat -J-Xmx8192m heapdump.06180803
- Reading from heapdump.06180803...
- Dump file created Sat Jun 18 08:04:22 EDT 2011
- Snapshot read, resolving...
- Resolving 335643 objects...
- Chasing references, expect 67 dots...................................................................
- Eliminating duplicate references...................................................................
- Snapshot resolved.
- Started HTTP server on port 7000
- Server is ready.
提供給你的默認URL顯示了所有加載進系統(tǒng)的class,但是我覺得并不是很有用。相反,我直接使用http://localhost:7000/histo/,這個地址是一個直方圖的視角來進行顯示,并且是按照對象數量和占用的內存空間進行排序了的。
這個直方圖里的每個class的名稱都是一個鏈接,點擊這個鏈接可以查看關于這個類型的詳細信息。你可以在其中看到這個類的繼承關系,它的成員變量,以及很多指向這個類的實體變量信息的鏈接。我不認為這個詳細信息頁面非常有用,而且實體變量的鏈接列表很占用很多的瀏覽器內存。
為了能夠跟蹤你的內存問題,最有用的頁面是’Reference by Type’。這個頁面含有兩個表格:入引用和出引用,他們都被引用的數量進行排序了。點擊一個類的名字可以看到這個引用的信息。
你可以在類的詳細信息(class details)頁面中找到這個頁面的鏈接。
堆轉儲分析:內存分配情況
在大多數情況下,知道了是哪些對象消耗了大量的內存往往就可以知道它們?yōu)槭裁磿l(fā)生內存泄露。你可以使用jhat來找到所有引用了他們的對象,并且你還可以看到使用了這些對象的引用的代碼。但是在有些時候,這樣還是不夠的。
比如說你有關于字符串對象的內存泄露問題,那么就很有可能會花費你好幾天的時間去檢查所有和字符串相關的代碼。要解決這種問題,你就需要能夠顯示內存在哪里被分配的堆轉儲。但是需要注意的是,這種類型的堆轉儲會對你的應用程序產生更多的負載,因為負責轉儲的代理需要記錄每一個new操作符。
有許多交互式的程序可以做到這種級別的數據記錄,但是我找到了一個更簡單的方法,那就是使用內置的hprof代理來啟動JVM。
- java -Xrunhprof:heap=sites,depth=2 com.kdgregory.example.memory.Gobbler
hprof有許多選項:不僅僅可以用多種方式輸出內存使用情況,它還可以跟蹤CPU的使用情況。當它運行的時候,我指定了一個事后的內存轉儲,它記錄了哪些對象被分配,以及分配的位置。它的輸出被記錄在了java.hprof.txt文件中,其中關于堆轉儲的部分如下:
- SITES BEGIN (ordered by live bytes) Tue Sep 29 10:43:34 2009
- percent live alloc'ed stack class
- rank self accum bytes objs bytes objs trace name
- 1 99.77% 99.77% 66497808 2059 66497808 2059 300157 byte[]
- 2 0.01% 99.78% 9192 1 27512 13 300158 java.lang.Object[]
- 3 0.01% 99.80% 8520 1 8520 1 300085 byte[]
- SITES END
這個應用程序沒有分配多種不同類型的對象,也沒有將它們分配到很多不同的地方。一般的轉儲有成百上千行的信息,顯示了每一種類型的對象被分配到了哪里。幸運的是,大多數問題都會出現(xiàn)在開頭的幾行。在這個例子中,最突出的是64M的存活著的字節(jié)數組,并且每一個平均32K。
大多數程序中都不會一直持有這么大得數據,這就表明這個程序沒有很好的抽取和處理這些數據。你會發(fā)現(xiàn)這常常發(fā)生在讀取一些大的字符串,并且保存了substring之后的字符串:很少有人知道String.substring()后會共享原始字符串對象的字節(jié)數組。如果你按照一行一行地讀取了一個文件,但是卻使用了每行的前五個字符,實際上你任然保存的是整個文件在內存中。
轉儲文件也顯示出這些數組被分配的數量和現(xiàn)在存活的數量完全相等。這是一種典型的泄露,并且我們可以通過搜索’trace’號來找到真正的代碼:
- TRACE 300157:
- com.kdgregory.example.memory.Gobbler.main(Gobbler.java:22)
好了,這下就足夠簡單了:當我在代碼中找到指定的代碼行時,我發(fā)現(xiàn)這些數組被存放在了ArrayList中,并且它也一直沒有出作用域。但是有時候,堆棧的跟蹤并沒有直接關聯(lián)到你寫的代碼上:
- TRACE 300085:
- java.util.zip.InflaterInputStream.(InflaterInputStream.java:71)
- java.util.zip.ZipFile$2.(ZipFile.java:348)
在這個例子中,你需要增加堆棧跟蹤的深度,并且重新運行你的程序。但是這里有一個需要平衡的地方:當你獲取到了更多的堆棧信息,你也同時增加了profile的負載。默認地,如果你沒有指定depth參數,那么默認值就會是4。我發(fā)現(xiàn)當堆棧深度為2的時候就可以發(fā)現(xiàn)和定位我程序中得大部分問題了,當然我也使用過深度為12的參數來運行程序。
另外一個增大堆棧深度的好處是,***的報告結果會更加細粒度:你可能會發(fā)現(xiàn)你泄露的對象來自兩到三個地方,并且它們都使用了相同的方法。
堆轉儲分析:位置、地點
當很多對象在分配的不久后就被丟棄時,分代垃圾搜集器就會開始運行。你可以使用同樣的原則來找發(fā)現(xiàn)內存泄露:使用調試器,在對象被分配的地方打上斷點,并且運行這段代碼。在大多數時候,當它們被分配不久后就會加入到長時間存活(long-live)的集合中。
***代
除了JVM中的新生代和老年代外,JVM還管理著一片叫‘***代’的區(qū)域,它存儲了class信息和字符串表達式等對象。通常,你不會觀察到***代中的垃圾回收;大多數的垃圾回收發(fā)生在應用程序堆中。但是不像它的名字,在***代中的對象不會是***不變的。舉個例子,被應用程序classloader加載的class,當不再被classloader引用時就會被清理掉。當應用程序服務被頻繁的熱部署時就可能會發(fā)生:
- Exception in thread "main" java.lang.OutOfMemoryError: PermGen space
這一這個信息:這個不管應用程序堆的事。當應用程序堆中還有很多空間時,也有可能用完***代的空間。通常,這發(fā)生在重新部署EAR和WAR文件時,并且***代還不夠大到可以同時容納新的class信息和老的class信息(老的class會一直被保存著直到所有的請求在使用完它們)。當在運行處于開發(fā)狀態(tài)的應用時更容易發(fā)生。
解決***代錯誤的***個方法就是增大***大的空間,你可以使用-XX:MaxPermSize命令行參數。默認是64M,但是web應用程序或者IDE一般都需要256M。
- java -XX:MaxPermSize=256m
但是在通常情況下并不是這么簡單的。***代的內存泄露一般都和在應用堆中的內存泄露原因一樣:在一些地方的對象引用了并不該再引用的對象。以我的經驗,很有可能有些對象直接引用了一些Class對象,或者在java.lang.reflect包下面的對象,而不是某些類的實例對象。正式因為web引用的classloader的組織方式,通常罪魁禍首都出現(xiàn)在服務的配置當中。
例如,你使用了Tomcat,并且有一個目錄里面有很多共享的jars:shared/lib。如果你在一個容器里同時運行好幾個web應用,將一些公用的jar放在這個目錄是很有道理的,因為這樣的話這些class僅僅被加載一次,可以減少內存的使用量。但是,如果其中的一些庫具有對象緩存的話,會發(fā)生什么事情呢?
答案是這些被緩存了的對象的類永遠不會被卸載,直到緩存釋放了這些對象。解決方案就是將這些庫移動到WAR或者EAR中。但是在某些時候情況也不會像這么簡單:JDKs bean introspector會緩存住由root classloader加載的BeanInfo對象。并且任何使用了反射的庫也會緩存這些對象,這樣就導致你不能直到真正的問題所在。
解決***代的問題通常都是比較痛苦的。一般可以先考慮加上-XX:+TraceClassLoading和-XX:+TraceClassUnloading命令行選項以便找出那些被加載了但是沒有被卸載的類。如果你加上了-XX:+TraceClassResolution命令行選項,你還可以看到哪些類訪問了其他類,但是沒有被正常卸載。
這里有針對這三個選項的一個實例。***行顯示了MyClassLoader類從classpath中被加載了。因為它又從URLClassLoader繼承,因此我們看到了接下來的’RESOLVE’消息,緊跟著又是一條’RESOLVE’消息,說明Class類也被解析了。
- [Loaded com.kdgregory.example.memory.PermgenExhaustion$MyClassLoader from file:/home/kgregory/Workspace/Website/programming/examples/bin/]
- RESOLVE com.kdgregory.example.memory.PermgenExhaustion$MyClassLoader java.net.URLClassLoader
- RESOLVE java.net.URLClassLoader java.lang.Class URLClassLoader.java:188
所有的信息都在這里的,但是通常情況下將一些共享庫移動到WAR/EAR中往往可以很快速的解決問題。
當堆內存還有空間時發(fā)生的OutOfMemoryError
就像你剛才看到的關于***代的消息,也許應用程序堆中還有空閑空間,但是也任然可能會發(fā)生OOM。這里有幾個例子:
連續(xù)的內存分配
當我描述分代的堆空間時,我一般會說對象會首先被分配在新生代,然后最終會被移動到老年代。但這不是絕對正確的:如果你的對象足夠大,那么它就會直接被分配在老年代。一般用戶自己定義的對象是不會(也不應該)達到這個臨界值,但是數組卻卻有可能:在JDK1.5中,當數組的對象超過0.5M的時候就會被直接分配到老年代。
在32位機器上,0.5M換算成Object[]數組的話就可以包含131,072個元素。這已經是很大的了,但是在企業(yè)級的應用中這是很有可能的。特別是當使用了HashMap時,它經常需要重新resize自己(里面的數組數據結構)。一些應用程序可能還需要更大的數組。
當沒有連續(xù)的堆空間來存放這些數組對象時(就算在垃圾回收并且對內存進行了緊湊之后),問題就產生了。這很少見,但是如果當前的程序已經很接近堆空間的上限時,這就變得很有可能了。增大堆空間上限是***的解決方案,但是你也許可以試試事先分配好你的容器的大小。(后面的小對象可以不需要連續(xù)的內存空間)
線程
JavaDoc中對OOM的描述是,當垃圾搜集器不能在釋放更多的內存空間時,JVM會拋出OOM。這里只對了一半:當JVM的內部代碼收到來自操作系統(tǒng)的ENOMEM錯誤時,JVM也會拋出OOM。Unix程序員一般都知道,這里有很多地方可以收到ENOMEN錯誤,創(chuàng)建線程的過程是其中之一:
- Exception in thread "main" java.lang.OutOfMemoryError: unable to create new native thread
在我的32位Linux系統(tǒng)中,使用JDK1.5,我可以最多開啟5,550個線程直到拋出異常。但是實際上在堆中任然有很多空閑空間,這是怎么回事呢?
在這個場景的背后,線程實際上是被操作系統(tǒng)所管理,而不是JVM,創(chuàng)建線程失敗的可能原因有很多很多。在我的例子中,每一個線程都需要占用大概0.5M的虛擬內存作為它的??臻g,在5000個線程被創(chuàng)建之后,大約就有2G的內存空間被占用。有些操作系統(tǒng)就強制制定了一個進程所能創(chuàng)建的線程數的上限。
***,針對這個問題沒有一個解決方案,除非更換你的應用程序。大多數程序是不需要創(chuàng)建這么多得線程的,它們會將大部分的時間都浪費在等待操作系統(tǒng)調度上。但是有些服務程序需要創(chuàng)建數千個線程去處理請求,但是它們中得大多數都是在等待數據。針對這種場景,NIO和selector就是一個不錯的解決方案。
Direct ByteBuffers
從JDK1.4之后Java允許程序程序使用bytebuffers來訪問堆外的內存空間(受限)。雖然ByteBuffer對象本身很小,但是堆外的內存可不一定很?。?/p>
- Exception in thread "main" java.lang.OutOfMemoryError: Direct buffer memory
這里有多個原因會導致bytebuffer分配失敗。通常情況下,你可能超過了最多的虛擬內存上限(僅限于32位系統(tǒng)),或者超過了所有物理內存和交換區(qū)內存的上限。除非你是在以很簡單的方式處理超過你的機器內存上限的數據,否則你在使用direct buffer產生OOM的原因和你使用堆的原因基本上是一樣的:你保持著一些你不該引用的數據。前面介紹的堆分析技術可以幫助你找到泄露點。
申請的內存超過物理內存
就像我前面提到的,你在啟動一個JVM時,你需要指定堆的最小值和***值。這就意味著,JVM會在運行期動態(tài)改變它對虛擬內存的需求。在一個內存受限的機器上,你可以同時運行多個JVM,甚至它們所有指定的***值之和大于了物理內存和交換區(qū)的大小。當然,這就有可能會導致OOM,就算你的程序中存活的對象大小小于你指定的堆空間也是一樣的。
這種情況和跑多個C++程序使用完所有的物理內存的原因是一樣的。使用JVM可能會讓你產生一種假象,以為不會出現(xiàn)這種問題。唯一的解決方案是購買更多的內存,或者不要同時跑那么多程序。沒有辦法讓JVM可以’快速失敗’;但是在Linux上你可以申請比總內存更多的內存。
堆外內存的使用
***一個需要注意的問題是:Java中得堆僅僅是所占用內存的一部分。JVM還會為它所創(chuàng)建的線程、內部代碼、工作空間、共享庫、direct buffer、內存映射文件分配內存。在32位的JVM中,這所有的內存都需要被映射到2G的虛擬內存空間中,這是非常有限的(特別是對于服務端或者后端應用程序)。在64位的JVM中,虛擬內存基本沒存在什么限制,但是實際的物理內存(含交換區(qū))可能會很稀缺。
一般來說,虛擬內存不會造成什么大問題;操作系統(tǒng)和JVM可以很好的管理它們。通常情況下,你需要查看虛擬內存的映射情況主要是為了direct buffer所使用的大塊的內存或者是內存映射文件。但是你還是很有必要知道什么是虛擬內存的映射。
要查看在Linux上的虛擬內存映射情況可以使用pmap;在Windows中可以使用VMMap。下面是使用pmap來dump的一個Tomcat應用。實際的dump文件有好幾百行,所展示的部分僅僅是比較有意思的部分:
- 08048000 60K r-x-- /usr/local/java/jdk-1.5/bin/java
- 08057000 8K rwx-- /usr/local/java/jdk-1.5/bin/java
- 081e5000 6268K rwx-- [ anon ]
- 889b0000 896K rwx-- [ anon ]
- 88a90000 4096K rwx-- [ anon ]
- 88e90000 10056K rwx-- [ anon ]
- 89862000 50488K rwx-- [ anon ]
- 8c9b0000 9216K rwx-- [ anon ]
- 8d2b0000 56320K rwx-- [ anon ]
- ...
- afd70000 504K rwx-- [ anon ]
- afdee000 12K ----- [ anon ]
- afdf1000 504K rwx-- [ anon ]
- afe6f000 12K ----- [ anon ]
- afe72000 504K rwx-- [ anon ]
- ...
- b0cba000 24K r-xs- /usr/local/java/netbeans-5.5/enterprise3/apache-tomcat-5.5.17/server/lib/catalina-ant-jmx.jar
- b0cc0000 64K r-xs- /usr/local/java/netbeans-5.5/enterprise3/apache-tomcat-5.5.17/server/lib/catalina-storeconfig.jar
- b0cd0000 632K r-xs- /usr/local/java/netbeans-5.5/enterprise3/apache-tomcat-5.5.17/server/lib/catalina.jar
- b0d6e000 164K r-xs- /usr/local/java/netbeans-5.5/enterprise3/apache-tomcat-5.5.17/server/lib/tomcat-ajp.jar
- b0d97000 88K r-xs- /usr/local/java/netbeans-5.5/enterprise3/apache-tomcat-5.5.17/server/lib/tomcat-http.jar
- ...
- b6ee3000 3520K r-x-- /usr/local/java/jdk-1.5/jre/lib/i386/client/libjvm.so
- b7253000 120K rwx-- /usr/local/java/jdk-1.5/jre/lib/i386/client/libjvm.so
- b7271000 4192K rwx-- [ anon ]
- b7689000 1356K r-x-- /lib/tls/i686/cmov/libc-2.11.1.so
- ...
dump文件展示給你了關于虛擬內存映射的4個部分:虛擬內存地址,大小,權限,源(從文件加載的部分)。最有意思的部分是它的權限部分,它表示了該內存段是否是只讀的(r-)還是讀寫的(rw)。
我會從讀寫段開始分析。所有的段都具有名字”[ anon ]“,它在Linux中說明了該段不是由文件加載而來。這里還有很多被命名的讀寫段,它們和共享庫關聯(lián)。我相信這些庫都具有每個進程的地址表。
因為所有的讀寫段都具有相同的名字,一次要找出出問題的部分需要花費一點時間。對于Java堆,有4個相關的大塊內存被分配(新生代有2個,老年代1個,***代1個),他們的大小由GC和堆配置來決定。
其他問題
這部分的內容并不是對所有地方都適用。大部分都是我解決問題的過程中總結的實際經驗。
不要被虛擬內存的統(tǒng)計信息所誤導
有很多抱怨說Java是’memory hog’,經常被top命令的’VIRT’部分和Windows任務管理器的’Mem Usage’列所證實。需要澄清的是,有太多的東西都不會算進這個統(tǒng)計信息中,有些還是與其他程序共享的(比如說C的庫)。實際上也有很多‘空’的區(qū)域在虛擬內存映射空間中:如果你適用-Xms1000m來啟動JVM,就算你還沒有開始分配對象,虛擬內存的大小也會超過1000m。
一個更好的測量方法是使用駐留集的大?。耗愕膽贸绦蛘嬲褂玫奈锢韮却娴捻摂?,不包含共享頁。這就是top命令中得’RES’列。但是,駐留集并不是對你的程序所需使用的總內存***的測量方法。操作系統(tǒng)只有在你的程序真正需要使用它們的時候才會將它們放進進程的內存空間中,一般來說是在你的系統(tǒng)處于高負載的情況下才會出現(xiàn),這會花費一段較長的時間。
***:始終使用工具來提供所需的詳細信息來分析Java中的內存問題。并且只有當出現(xiàn)OOM的時候才考慮下結論。
OOM的罪魁禍首經常離它的拋出點很近
內存泄露一般在內存被分配之后不久發(fā)生。一個相似的結論是,OOM的根源一般都離它的拋出點很近,可以使用堆跟蹤技術來首先進行分析。其基本原理是,內存泄露一般和產生大量的內存相關聯(lián)。這說明了,導致泄露的代碼具有更高的失敗風險率,不管是因為其內存分配代碼被調用的過于頻繁,還是因為每次調用都分配的過大的內存。因此,可以優(yōu)先考慮使用棧跟蹤來定位問題。
和緩存相關的部分最值得懷疑
我在這篇文章中提到緩存了很多次:在我數十年的Java工作經歷中發(fā)現(xiàn),和內存泄露相關的類進場都是和緩存相關的。實際上緩存是很難編寫的。
使用緩存有很多很多很好的理由,并且使用自己寫的緩存也有很多好的理由。如果你確定要使用緩存,請先回答下面的問題:
- 哪些對象會被放進緩存?如果你所要緩存的對象都是同一種類型(或者具有繼承關系),那么相比一個可以容納各種類型的緩存來說更好跟蹤問題。
- 有多少對象會被同時放進緩存?如果你像讓ProductCache緩存1000個對象,但是在內存分析結果中發(fā)現(xiàn)了10000個對象,那么這之間的關系就比較好定位。如果你指定了這個緩存最多的容量上限,那么你就可以很容易的計算出這個緩存最多需要多少內存。
- 過期和清除策略是什么?每一個緩存為了控制存在于其中的對象的存貨周期,都需要一個明確的驅逐策略。如果你沒有指定一個明確的驅逐策略,那么有些對象就很有可能比它真正需要的存活周期要長,占用更多的內存,加重垃圾搜集器的負載(記?。涸跇擞涬A段需要的時間和存活對象的數量成正比)。
- 是否會在緩存之外同時持有這些存活對象的引用?緩存***的應用場景是,調用頻繁,并且調用時間很短,并且所緩存的對象的獲取代價很大。如果你需要創(chuàng)建一個對象,并且在整個應用程序的生命周期中都需要引用這個對象,那么就沒有必要將這個對象放入緩存(也許使用池技術可以顯示總得對象數量)。
注意對象的生命周期
一般來說對象可以被劃分為兩類:一類是伴隨著整個程序的生命周期而存活;另外一來是僅僅存活并服務于一個單一的請求。搞清楚這個非常重要,你僅僅需要關心你認為是長時間存活的對象。
一種方法是在程序啟動的時候全部初始化好所有長時間(long-lived)存活的對象,不管他們是否要立刻被用到。另外一個方法是使用依賴注入框架,比如Spring。這不僅僅可以很方便的bean配置文件中找到所有l(wèi)ong-lived的對象(不需要掃描整個classpath),還可以很清楚的知道這些對象在哪里被使用。
查找在方法參數中被錯誤使用的對象
在大部分場景中,在一個方法中被分配的對象都會在方法退出的時候被清理掉(除開被返回的對象)。當你都是用局部變量來保存這些對象的時候,這個規(guī)則很容易被遵守。但是,有時候任然會使用實體變量來保存這些對象,特別是在方法中會調用大量其他方法的時候,主要是為了避免過多和麻煩的方法參數傳遞。
這樣做不是一定會產生泄漏。后續(xù)的方法調用會重新對這些變量進行賦值,這樣就可以讓之前被創(chuàng)建的對象被回收。但是這樣導致不必要的內存開銷,并且讓調試更加困難。但是從設計的角度出發(fā),當我看到這樣的代碼時,我就會考慮將這個方法單獨提出來形成一個獨立的類。
J2EE:不要濫用session
session對象是用來在多個請求之間保存和共享用戶相關的數據,主要是因為HTTP協(xié)議是無狀態(tài)的。有時候它便成了一個用于緩存的臨時性解決方案。
這也不是說一定就會產生泄漏,因為web容器會在一段時間后讓用戶的session失效。但是它卻顯著提高了整個程序的內存占用量,這是很糟糕的。并且它非常難調試:就像我之前提到的,很難看出對象被哪些其他的對象所持有。
小心過量的垃圾搜集
雖然OOM很糟糕,但是如果不停的執(zhí)行垃圾搜集將會更加糟糕:它會搶走本該屬于你的程序的CPU時間。
有些時候你僅僅是需要更多的內存
就像我在開頭的地方所說的,JVM是唯一的一個讓你指定你的數據***值(內存上限)的現(xiàn)代編程環(huán)境。因此,會有很多時候讓你以為發(fā)生了內存泄露,但是實際上你僅僅需要增加你的堆大小。解決內存問題的***步***還是先增加你的內存上限。如果你真的遇到了內存泄露問題,那么無論你增加了多少內存,你***都還是會得到OOM的錯誤。