深入解析Java OutOfMemoryError
在Java中,所有對象都存儲在堆中。他們通過new關(guān)鍵字來進行分配,JVM會檢查是否所有線程都無法在訪問他們了,并且會將他們進行回收。在大多數(shù)時候程序員都不會有一絲一毫的察覺,這些工作都被靜悄悄的執(zhí)行。但是,有時候在發(fā)布前的***一天,程序掛了。
- Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
OutOfMemoryError是一個讓人很郁悶的異常。它通常說明你干了寫錯誤的事情:沒必要的長時間保存一些沒必要的數(shù)據(jù),或者同一時間處理了過多的數(shù)據(jù)。有些時候,這些問題并不一定受你的控制,比如說一些第三方的庫對一些字符串做了緩存,或者一些應(yīng)用服務(wù)器在部署的時候并沒有進行清理。并且,對于堆中已經(jīng)存在的對象,我們往往拿他們沒辦法。
這篇文章分析了導(dǎo)致OutOfMemoryError的不同原因,以及你該怎樣應(yīng)對這種原因的方法。以下分析僅限于Sun Hotspot虛擬機,但是大多數(shù)結(jié)論都適用于其他任何的JVM實現(xiàn)。它們大多數(shù)基于網(wǎng)上的文章以及我自己的經(jīng)驗。我沒有直接做JVM開發(fā)的工作,因此結(jié)論并不代表JVM的作者。但是我確實曾經(jīng)遇到過并解決了很多內(nèi)存相關(guān)的問題。
垃圾回收介紹
我在這篇文章中已經(jīng)詳細介紹了垃圾回收的過程。簡單的說,標(biāo)記-清除算法(mark-sweep collect)以garbage collection roots作為掃描的起點,并對整個對象圖進行掃描,對所有可達的對象進行標(biāo)記。那些沒有被標(biāo)記的對象會被清除并回收。
Java的垃圾回收算法過程意味著如果出現(xiàn)了OOM,那么說明你在不停的往對象圖中添加對象并且沒有移除它們。這通常是因為你在往一個集合類中添加了很多對象,比如Map,并且這個集合對象是static的。或者,這個集合類被保存在了ThreadLocal對象中,而這個對應(yīng)的Thread卻又長時間的運行,一直不退出。
這與C和C++的內(nèi)存泄露完全不一樣。在這些語言中,如果一些方法調(diào)用了malloc()或者new,并且在方法退出的時候沒有調(diào)用相應(yīng)的free()或者delete,那么內(nèi)存就會產(chǎn)生泄露。這些是真正意義上得泄露,你在這個進程范圍內(nèi)不可能再恢復(fù)這些內(nèi)存,除非使用一些特定的工具來保證每一個內(nèi)存分配方法都有其對應(yīng)的內(nèi)存釋放操作相對應(yīng)。
在java中,“泄露”這個詞往往被誤用了。因為從JVM的角度來說,所有的內(nèi)存都是被良好管理的。問題僅僅是作為程序員的你不知道這些內(nèi)存是被哪些對象占用了。但是幸運的是,你還是有辦法去找到和定位它們。
在深入探討之前,你還有***一件關(guān)于垃圾收集的知識需要了解:JVM會盡***的能力去釋放內(nèi)存,直到發(fā)生OOM。這就意味著OOM不能通過簡單的調(diào)用System.gc()來解決,你需要找到這些“泄露”點,并自己處理它們。
設(shè)置堆大小
學(xué)院派的人非常喜歡說Java語言規(guī)范并沒有對垃圾收集器進行任何約定,你甚至可以實現(xiàn)一個從來不釋放內(nèi)存的JVM(實際是毫無意義的)。Java虛擬機規(guī)范中提到堆是由垃圾回收器進行管理,但是卻沒有說明任何相關(guān)細節(jié)。僅僅說了我剛才提到的那句話:垃圾回收會發(fā)生在OOM之前。
實際上,Sun Hotspot虛擬機使用了一個固定大小的堆空間,并且允許在最小空間和***空間之間進行自動增長。如果你沒有指定最小值和***值,那么對于’client’模式將會默認(rèn)使用2Mb最為最小值,64Mb最為***值;對于’server’模式,JVM會根據(jù)當(dāng)前可用內(nèi)存來決定默認(rèn)值。2000年后,默認(rèn)的***堆大小改為了64M,并且在當(dāng)時已經(jīng)認(rèn)為足夠大了(2000年前的時候默認(rèn)值是16M),但是對于現(xiàn)在的應(yīng)用程序來說很容易就用完了。
這意味著你需要顯示的通過JVM參數(shù)來指定堆的最小值和***值:
- java -Xms256m -Xmx512m MyClass
這里有很多經(jīng)驗上得法則來設(shè)定***值和最小值。顯然,堆的***值應(yīng)該設(shè)定為足以容下整個應(yīng)用程序所需要的全部對象。但是,將它設(shè)定為“剛剛好足夠大”也不是一個很好的注意,因為這樣會增加垃圾回收器的負載。因此,對于一個長時間運行的應(yīng)用程序,你一般需要保持有20%-25%的空閑堆空間。(你得應(yīng)用程序可能需要不同的參數(shù)設(shè)置,GC調(diào)優(yōu)是一門藝術(shù),并且不在該文章討論范圍內(nèi))
讓你奇怪的時,設(shè)置合適的堆的最小值往往比設(shè)置合適的***值更加重要。垃圾回收器會盡可能的保證當(dāng)前的的堆大小,而不是不停的增長堆空間。這會導(dǎo)致應(yīng)用程序不停的創(chuàng)建和回收大量的對象,而不是獲取新的堆空間,相對于初始(最小)堆空間。Java堆會盡量保持這樣的堆大小,并且會不停的運行GC以保持這樣的容量。因此,我認(rèn)為在生產(chǎn)環(huán)境中,我們***是將堆的最小值和***值設(shè)置成一樣的。
你可能會困惑于為什么Java堆會有一個***值上限:操作系統(tǒng)并不會分配真正的物理內(nèi)存,除非他們真的被使用了。并且,實際使用的虛擬內(nèi)存空間實際上會比Java堆空間要大。如果你運行在一個32位系統(tǒng)上,一個過大的堆空間可能會限制classpath中能夠使用的jar的數(shù)量,或者你可以創(chuàng)建的線程數(shù)。
另外一個原因是,一個受限的***堆空間可以讓你及時發(fā)現(xiàn)潛在的內(nèi)存泄露問題。在開發(fā)環(huán)境中,對應(yīng)用程序的壓力往往是不夠的,如果你在開發(fā)環(huán)境中就擁有一個非常大得堆空間,那么你很有可能永遠不會發(fā)現(xiàn)可能的內(nèi)存泄露問題,直到進入產(chǎ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提供了額外的兩個參數(shù)來以內(nèi)存帶分類輸出,并且會顯示垃圾收集的開始時間:
- 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啟動后的時間,我們可以看到在一秒鐘內(nèi)有上百次的GC。并且,還加入了每次GC執(zhí)行時間的開始時間(在每行的***一個字段),可以看出垃圾搜集器是在不停的運行的。
但是在實時系統(tǒng)中,這會造成很大的問題,因為垃圾搜集器的執(zhí)行會奪走很多的CPU周期。就像我之前提到的,這很可能是由于初始堆大小設(shè)置的太小了,并且GC日志顯示了:每次堆的大小達到了1.1Mb,它就開始執(zhí)行GC。如果你得系統(tǒng)也有類似的現(xiàn)象,請在改變自己的應(yīng)用程序之前使用-Xms來增大初始堆大小。
對于GC日志還有一些很有趣的地方:除了***次垃圾回收,沒有任何對象是存放在了新生代(“DefNew”)。這說明了這個應(yīng)用程序分配了包含大量數(shù)據(jù)的數(shù)組,在顯示世界里這是很少出現(xiàn)的。如果在一個實時系統(tǒng)中出現(xiàn)這樣的狀況,我想到的***個問題是“這些數(shù)組拿來干什么用?”。
堆轉(zhuǎn)儲(Heap Dumps)
一個堆轉(zhuǎn)儲可以顯示你在應(yīng)用程序說使用的所有對象。從基礎(chǔ)上講,它僅僅反映了對象實例的數(shù)量和類文件所占用的字節(jié)數(shù)。當(dāng)然你也可以將分配這些內(nèi)存的代碼一起dump出來,并且對比歷史存貨對象。但是,如果你要dump的數(shù)據(jù)信息越多,JVM的負載就會越大,因此這些技術(shù)僅僅應(yīng)該使用在開發(fā)環(huán)境中。
怎樣獲得一個內(nèi)存轉(zhuǎn)儲
命令行參數(shù)-XX:+HeapDumpOnOutOfMemoryError是最簡單的方式生成內(nèi)存轉(zhuǎn)儲。就像它的名字所說的,它會在內(nèi)存被用完的時候(發(fā)生OOM)進行轉(zhuǎn)儲,這在產(chǎn)品環(huán)境非常好用。但是由于這個是一種事后轉(zhuǎn)儲(已經(jīng)發(fā)生了OOM),它只能提供一種歷史性的數(shù)據(jù)。它會產(chǎn)生一個二進制文件,你可以使用jhat來操作該文件(這個工具在JDK1.6中已經(jīng)提供,但是可以讀取JDK1.5產(chǎn)生的文件)。
你可以使用jmap(JDK1.5之后就自帶了)來為一個運行中得java程序產(chǎn)生堆轉(zhuǎn)儲,可以產(chǎn)生一個在jhat中使用的dump文件,或者是一個存文本的統(tǒng)計文件。統(tǒng)計圖可以在進行分析時優(yōu)先使用,特別是你要在一段時間內(nèi)多次轉(zhuǎn)儲堆并進行分析和對比歷史數(shù)據(jù)。
從轉(zhuǎn)儲內(nèi)容和JVM的負荷的擴展性上考慮的話,可以使用profilers。Profiles使用JVM的調(diào)試接口(debuging interface)來搜集對象的內(nèi)存分配信息,包括具體的代碼行和方法調(diào)用棧。這個是非常有用的:不僅僅可以知道你分配了一個數(shù)GB的數(shù)組,你還可以知道你在一個特定的地方分配了950MB的對象,并且直接忽略其他的對象。當(dāng)然,這些結(jié)果肯定會對JVM有開銷,包括CPU的開銷和內(nèi)存的開銷(保存一些原始數(shù)據(jù))。你不應(yīng)該在產(chǎn)品環(huán)境中使用profiles。
堆轉(zhuǎn)儲分析:live objects
Java中的內(nèi)存泄露是這樣定義的:你在內(nèi)存中分配了一些對象,但是并沒有清除掉所有對它們的引用,也就是說垃圾搜集器不能回收它們。使用堆轉(zhuǎn)儲直方圖可以很容易的查找這些泄露對象:它不僅僅可以告訴你在內(nèi)存中分配了哪些對象,并且顯示了這些對象在內(nèi)存中所占用的大小。但是這種直方圖***的問題是:對于同一個類的所有對象都被聚合(group)在一起了,所以你還需要進一步做一些檢測來確定這些內(nèi)存在哪里被分配了。
使用jmap并且加上-histo參數(shù)可以為你產(chǎn)生一個直方圖,它顯示了從程序運行到現(xiàn)在所有對象的數(shù)量和內(nèi)存消耗,并且包含了已經(jīng)被回收的對象和內(nèi)存。如果使用-histo:live參數(shù)會顯示當(dāng)前還在堆中得對象數(shù)量及其內(nèi)存消耗,不論這些對象是否要被垃圾搜集器進行回收。
也就是說,如果你要得到一個當(dāng)前時間下得準(zhǔn)確信息,你需要在使用jmap之前強制執(zhí)行一次垃圾回收。如果你的應(yīng)用程序是運行在本地,最簡單的方式是直接使用jconsole:在’Memory’標(biāo)簽下,有一個’Perform GC’的按鈕。如果應(yīng)用程序是運行在服務(wù)端環(huán)境,并且JMX beans被暴露了,MemoryMXBean有一個gc()操作。如果上述的兩種方案都沒辦法滿足你得要求,你就只有等待JVM自己觸發(fā)一次垃圾搜集過程了。如果你有一個很嚴(yán)重的內(nèi)存泄露問題,那么***次major collection很可能預(yù)示著不久后就會OOM。
有兩種方法使用jmap產(chǎn)生的直方圖。其中最有效的方法,適用于長時間運行的程序,可以使用帶live的命令行參數(shù),并且在一段時間內(nèi)多次使用該命令,檢查哪些對象的數(shù)量在不斷增長。但是,根據(jù)當(dāng)前程序的負載,該過程可能會花費1個小時或者更多的時間。
另外一個更加快速的方式是直接比較當(dāng)前存活的對象數(shù)量和總的對象數(shù)量。如果有些對象占據(jù)了總對象數(shù)量的大部分,那么這些對象很有可能發(fā)生內(nèi)存泄露。這里有一個例子,這個應(yīng)用程序已經(jīng)連續(xù)幾周為100多個用戶提供了服務(wù),結(jié)果列舉了前12個數(shù)量最多的對象。據(jù)我所知,這個程序沒有內(nèi)存泄露的問題,但是像其他應(yīng)用程序一樣做了常規(guī)性的內(nèi)存轉(zhuǎn)儲分析操作。
- ~, 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
當(dāng)我們要嘗試尋找內(nèi)存泄露問題,可以從消耗內(nèi)存最多的對象著手。這聽上去很明顯,但是往往它們并不是內(nèi)存泄露的根源。但是,它們?nèi)稳皇菓?yīng)該***下手的地方,在這個例子中,最占用內(nèi)存的是一些char[]的數(shù)組對象(總大小是60MB,基本上沒有任何問題)。但是很奇怪的是當(dāng)前存貨(live)的對象竟然占了歷史分配的總對象大小的三分之二。
一般來說,一個應(yīng)用程序會分配對象,并且在不久之后就會釋放它們。如果保存一些對象的應(yīng)用過長的時間,就很有可能會導(dǎo)致內(nèi)存泄露。但是雖然是這么說的,實際上還是要具體情況具體分析,主要還是要看這個程序到底在做什么事情。字符數(shù)組對象(char[])往往和字符串對象(String)同時存在,大部分的應(yīng)用程序都會在整個運行過程中一直保持著一些字符串對象的引用。例如,基于JSP的web應(yīng)用程序在JSP頁面中定義了很多HTML字符串表達式。這種特殊的應(yīng)用程序提供HTML服務(wù),但是它們需要保持字符串引用的需求卻不一定那么清晰:它們提供的是目錄服務(wù),并不是靜態(tài)文本。如果我遇到了OOM,我就會嘗試找到這些字符串在哪里被分配,為什么沒有被釋放。
另一個需要關(guān)注的是字節(jié)數(shù)組([B)。在JDK中有很多類都會使用它們(比如BufferedInputStream),但是卻很少在應(yīng)用程序代碼中直接看到它們。通常它們會被用作緩存(buffer),但是緩存的生命周期不會很長。在這個例子中我們看到,有一半的字節(jié)數(shù)組任然保持存活。這個是令人擔(dān)憂的,并且它凸顯了直方圖的一個問題:所有的對象都按照它的類型被分組聚合了。對于應(yīng)用程序?qū)ο?非JDK類型或者原始類型,在應(yīng)用程序代碼中定義的類),這不是一個問題,因為它們會在程序的一個部分被集中分配。但是字節(jié)數(shù)組有可能會在任何地方被定義,并且在大多數(shù)應(yīng)用程序中都被隱藏在一些庫中。我們是否應(yīng)當(dāng)搜索調(diào)用了new byte[]或者new ByteArrayOutputStream()的代碼?
堆轉(zhuǎn)儲分析:相關(guān)的原因和影響分析
為了找到導(dǎo)致內(nèi)存泄露的最終原因,僅僅考慮按照類別(class)的分組的內(nèi)存占用字節(jié)數(shù)是不夠的。你還需要將應(yīng)用程序分配的對象和內(nèi)存泄露的對象關(guān)聯(lián)起來考慮。一個方法是更加深入查看對象的數(shù)量,以便將具有關(guān)聯(lián)性的對象找出來。下面是一個具有嚴(yán)重內(nèi)存問題的程序的轉(zhuǎ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[]來保存從網(wǎng)絡(luò)上接收到的數(shù)據(jù)。
***個問題,分配了大量的數(shù)組,實際上不是內(nèi)存泄露。ArrayList的默認(rèn)構(gòu)造函數(shù)會分配容量是10的數(shù)組,但是程序本身一般只使用1個或者2個槽位,這對于64位JVM來說會浪費62個字節(jié)的內(nèi)存空間。一個更好的涉及方案是僅僅在有需要的時候才使用List,這樣對每個實例來說可以節(jié)約額外的48個字節(jié)。但是,對于這種問題也可以很輕易的通過加內(nèi)存來解決,因為現(xiàn)在的內(nèi)存非常便宜。
但是對于datagram的泄露就比較麻煩(如同定位這個問題一樣困難):這表明接收到的數(shù)據(jù)沒有被盡快的處理掉。
為了跟蹤問題的原因和影響,你需要知道你的程序是怎樣在使用這些對象。不多的程序才會直接使用Object[]:如果確實要使用數(shù)組,程序員一般都會使用帶類型的數(shù)組。但是,ArrayList會在內(nèi)部使用。但是僅僅知道ArrayList的內(nèi)存分配是不夠的,你還需要順著調(diào)用鏈往上走,看看誰分配了這些ArrayList。
其中一個方法是對比相關(guān)的對象數(shù)量。在上面的例子中,byte[]和DatagramPackage的關(guān)系是很明顯的:其中一個基本上是另外一個的兩倍。但是ArrayList和ItemDetails的關(guān)系就不那么明顯了。(實際上一個ItemDetails中會包含多個ArrayList)
這往往是個陷阱,讓你去關(guān)注那么數(shù)量最多的一些對象。我們有數(shù)百萬的ArrayList對象,并且它們分布在不同的class中,也有可能集中在一小部分class中。盡管如此,數(shù)百萬的對象引用是很容易被定位的。就算有10來個class可能會包含ArrayList,那么每個class的實體對象也會有十萬個,這個是很容易被定位的。
從直方圖中跟蹤這種引用關(guān)系鏈?zhǔn)切枰ㄙM大量精力的,幸運的是,jmap不僅僅可以提供直方圖,它還可以提供可以瀏覽的堆轉(zhuǎn)儲信息。
堆轉(zhuǎn)儲分析:跟蹤引用鏈
瀏覽堆轉(zhuǎn)儲引用鏈具有兩個步驟:首先需要使用-dump參數(shù)來使用jmap,然后需要用jhat來使用轉(zhuǎn)儲文件。如果你確定要使用這種方法,請一定要保證有足夠多的內(nèi)存:一個轉(zhuǎn)儲文件通常都有數(shù)百M,jhat需要好幾個G的內(nèi)存來處理這些轉(zhuǎn)儲文件。
- 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.
提供給你的默認(rèn)URL顯示了所有加載進系統(tǒng)的class,但是我覺得并不是很有用。相反,我直接使用http://localhost:7000/histo/,這個地址是一個直方圖的視角來進行顯示,并且是按照對象數(shù)量和占用的內(nèi)存空間進行排序了的。
這個直方圖里的每個class的名稱都是一個鏈接,點擊這個鏈接可以查看關(guān)于這個類型的詳細信息。你可以在其中看到這個類的繼承關(guān)系,它的成員變量,以及很多指向這個類的實體變量信息的鏈接。我不認(rèn)為這個詳細信息頁面非常有用,而且實體變量的鏈接列表很占用很多的瀏覽器內(nèi)存。
為了能夠跟蹤你的內(nèi)存問題,最有用的頁面是’Reference by Type’。這個頁面含有兩個表格:入引用和出引用,他們都被引用的數(shù)量進行排序了。點擊一個類的名字可以看到這個引用的信息。
你可以在類的詳細信息(class details)頁面中找到這個頁面的鏈接。
堆轉(zhuǎn)儲分析:內(nèi)存分配情況
在大多數(shù)情況下,知道了是哪些對象消耗了大量的內(nèi)存往往就可以知道它們?yōu)槭裁磿l(fā)生內(nèi)存泄露。你可以使用jhat來找到所有引用了他們的對象,并且你還可以看到使用了這些對象的引用的代碼。但是在有些時候,這樣還是不夠的。
比如說你有關(guān)于字符串對象的內(nèi)存泄露問題,那么就很有可能會花費你好幾天的時間去檢查所有和字符串相關(guān)的代碼。要解決這種問題,你就需要能夠顯示內(nèi)存在哪里被分配的堆轉(zhuǎn)儲。但是需要注意的是,這種類型的堆轉(zhuǎn)儲會對你的應(yīng)用程序產(chǎn)生更多的負載,因為負責(zé)轉(zhuǎn)儲的代理需要記錄每一個new操作符。
有許多交互式的程序可以做到這種級別的數(shù)據(jù)記錄,但是我找到了一個更簡單的方法,那就是使用內(nèi)置的hprof代理來啟動JVM。
- java -Xrunhprof:heap=sites,depth=2 com.kdgregory.example.memory.Gobbler
hprof有許多選項:不僅僅可以用多種方式輸出內(nèi)存使用情況,它還可以跟蹤CPU的使用情況。當(dāng)它運行的時候,我指定了一個事后的內(nèi)存轉(zhuǎn)儲,它記錄了哪些對象被分配,以及分配的位置。它的輸出被記錄在了java.hprof.txt文件中,其中關(guān)于堆轉(zhuǎn)儲的部分如下:
- 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
這個應(yīng)用程序沒有分配多種不同類型的對象,也沒有將它們分配到很多不同的地方。一般的轉(zhuǎn)儲有成百上千行的信息,顯示了每一種類型的對象被分配到了哪里。幸運的是,大多數(shù)問題都會出現(xiàn)在開頭的幾行。在這個例子中,最突出的是64M的存活著的字節(jié)數(shù)組,并且每一個平均32K。
大多數(shù)程序中都不會一直持有這么大得數(shù)據(jù),這就表明這個程序沒有很好的抽取和處理這些數(shù)據(jù)。你會發(fā)現(xiàn)這常常發(fā)生在讀取一些大的字符串,并且保存了substring之后的字符串:很少有人知道String.substring()后會共享原始字符串對象的字節(jié)數(shù)組。如果你按照一行一行地讀取了一個文件,但是卻使用了每行的前五個字符,實際上你任然保存的是整個文件在內(nèi)存中。
轉(zhuǎn)儲文件也顯示出這些數(shù)組被分配的數(shù)量和現(xiàn)在存活的數(shù)量完全相等。這是一種典型的泄露,并且我們可以通過搜索’trace’號來找到真正的代碼:
- TRACE 300157:
- com.kdgregory.example.memory.Gobbler.main(Gobbler.java:22)
好了,這下就足夠簡單了:當(dāng)我在代碼中找到指定的代碼行時,我發(fā)現(xiàn)這些數(shù)組被存放在了ArrayList中,并且它也一直沒有出作用域。但是有時候,堆棧的跟蹤并沒有直接關(guān)聯(lián)到你寫的代碼上:
- TRACE 300085:
- java.util.zip.InflaterInputStream.(InflaterInputStream.java:71)
- java.util.zip.ZipFile$2.(ZipFile.java:348)
在這個例子中,你需要增加堆棧跟蹤的深度,并且重新運行你的程序。但是這里有一個需要平衡的地方:當(dāng)你獲取到了更多的堆棧信息,你也同時增加了profile的負載。默認(rèn)地,如果你沒有指定depth參數(shù),那么默認(rèn)值就會是4。我發(fā)現(xiàn)當(dāng)堆棧深度為2的時候就可以發(fā)現(xiàn)和定位我程序中得大部分問題了,當(dāng)然我也使用過深度為12的參數(shù)來運行程序。
另外一個增大堆棧深度的好處是,***的報告結(jié)果會更加細粒度:你可能會發(fā)現(xiàn)你泄露的對象來自兩到三個地方,并且它們都使用了相同的方法。
堆轉(zhuǎn)儲分析:位置、地點
當(dāng)很多對象在分配的不久后就被丟棄時,分代垃圾搜集器就會開始運行。你可以使用同樣的原則來找發(fā)現(xiàn)內(nèi)存泄露:使用調(diào)試器,在對象被分配的地方打上斷點,并且運行這段代碼。在大多數(shù)時候,當(dāng)它們被分配不久后就會加入到長時間存活(long-live)的集合中。
***代
除了JVM中的新生代和老年代外,JVM還管理著一片叫‘***代’的區(qū)域,它存儲了class信息和字符串表達式等對象。通常,你不會觀察到***代中的垃圾回收;大多數(shù)的垃圾回收發(fā)生在應(yīng)用程序堆中。但是不像它的名字,在***代中的對象不會是***不變的。舉個例子,被應(yīng)用程序classloader加載的class,當(dāng)不再被classloader引用時就會被清理掉。當(dāng)應(yīng)用程序服務(wù)被頻繁的熱部署時就可能會發(fā)生:
- Exception in thread "main" java.lang.OutOfMemoryError: PermGen space
這一這個信息:這個不管應(yīng)用程序堆的事。當(dāng)應(yīng)用程序堆中還有很多空間時,也有可能用完***代的空間。通常,這發(fā)生在重新部署EAR和WAR文件時,并且***代還不夠大到可以同時容納新的class信息和老的class信息(老的class會一直被保存著直到所有的請求在使用完它們)。當(dāng)在運行處于開發(fā)狀態(tài)的應(yīng)用時更容易發(fā)生。
解決***代錯誤的***個方法就是增大***大的空間,你可以使用-XX:MaxPermSize命令行參數(shù)。默認(rèn)是64M,但是web應(yīng)用程序或者IDE一般都需要256M。
- java -XX:MaxPermSize=256m
但是在通常情況下并不是這么簡單的。***代的內(nèi)存泄露一般都和在應(yīng)用堆中的內(nèi)存泄露原因一樣:在一些地方的對象引用了并不該再引用的對象。以我的經(jīng)驗,很有可能有些對象直接引用了一些Class對象,或者在java.lang.reflect包下面的對象,而不是某些類的實例對象。正式因為web引用的classloader的組織方式,通常罪魁禍?zhǔn)锥汲霈F(xiàn)在服務(wù)的配置當(dāng)中。
例如,你使用了Tomcat,并且有一個目錄里面有很多共享的jars:shared/lib。如果你在一個容器里同時運行好幾個web應(yīng)用,將一些公用的jar放在這個目錄是很有道理的,因為這樣的話這些class僅僅被加載一次,可以減少內(nèi)存的使用量。但是,如果其中的一些庫具有對象緩存的話,會發(fā)生什么事情呢?
答案是這些被緩存了的對象的類永遠不會被卸載,直到緩存釋放了這些對象。解決方案就是將這些庫移動到WAR或者EAR中。但是在某些時候情況也不會像這么簡單:JDKs bean introspector會緩存住由root classloader加載的BeanInfo對象。并且任何使用了反射的庫也會緩存這些對象,這樣就導(dǎo)致你不能直到真正的問題所在。
解決***代的問題通常都是比較痛苦的。一般可以先考慮加上-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中往往可以很快速的解決問題。
當(dāng)堆內(nèi)存還有空間時發(fā)生的OutOfMemoryError
就像你剛才看到的關(guān)于***代的消息,也許應(yīng)用程序堆中還有空閑空間,但是也任然可能會發(fā)生OOM。這里有幾個例子:
連續(xù)的內(nèi)存分配
當(dāng)我描述分代的堆空間時,我一般會說對象會首先被分配在新生代,然后最終會被移動到老年代。但這不是絕對正確的:如果你的對象足夠大,那么它就會直接被分配在老年代。一般用戶自己定義的對象是不會(也不應(yīng)該)達到這個臨界值,但是數(shù)組卻卻有可能:在JDK1.5中,當(dāng)數(shù)組的對象超過0.5M的時候就會被直接分配到老年代。
在32位機器上,0.5M換算成Object[]數(shù)組的話就可以包含131,072個元素。這已經(jīng)是很大的了,但是在企業(yè)級的應(yīng)用中這是很有可能的。特別是當(dāng)使用了HashMap時,它經(jīng)常需要重新resize自己(里面的數(shù)組數(shù)據(jù)結(jié)構(gòu))。一些應(yīng)用程序可能還需要更大的數(shù)組。
當(dāng)沒有連續(xù)的堆空間來存放這些數(shù)組對象時(就算在垃圾回收并且對內(nèi)存進行了緊湊之后),問題就產(chǎn)生了。這很少見,但是如果當(dāng)前的程序已經(jīng)很接近堆空間的上限時,這就變得很有可能了。增大堆空間上限是***的解決方案,但是你也許可以試試事先分配好你的容器的大小。(后面的小對象可以不需要連續(xù)的內(nèi)存空間)
線程
JavaDoc中對OOM的描述是,當(dāng)垃圾搜集器不能在釋放更多的內(nèi)存空間時,JVM會拋出OOM。這里只對了一半:當(dāng)JVM的內(nèi)部代碼收到來自操作系統(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的虛擬內(nèi)存作為它的棧空間,在5000個線程被創(chuàng)建之后,大約就有2G的內(nèi)存空間被占用。有些操作系統(tǒng)就強制制定了一個進程所能創(chuàng)建的線程數(shù)的上限。
***,針對這個問題沒有一個解決方案,除非更換你的應(yīng)用程序。大多數(shù)程序是不需要創(chuàng)建這么多得線程的,它們會將大部分的時間都浪費在等待操作系統(tǒng)調(diào)度上。但是有些服務(wù)程序需要創(chuàng)建數(shù)千個線程去處理請求,但是它們中得大多數(shù)都是在等待數(shù)據(jù)。針對這種場景,NIO和selector就是一個不錯的解決方案。
Direct ByteBuffers
從JDK1.4之后Java允許程序程序使用bytebuffers來訪問堆外的內(nèi)存空間(受限)。雖然ByteBuffer對象本身很小,但是堆外的內(nèi)存可不一定很小:
- Exception in thread "main" java.lang.OutOfMemoryError: Direct buffer memory
這里有多個原因會導(dǎo)致bytebuffer分配失敗。通常情況下,你可能超過了最多的虛擬內(nèi)存上限(僅限于32位系統(tǒng)),或者超過了所有物理內(nèi)存和交換區(qū)內(nèi)存的上限。除非你是在以很簡單的方式處理超過你的機器內(nèi)存上限的數(shù)據(jù),否則你在使用direct buffer產(chǎn)生OOM的原因和你使用堆的原因基本上是一樣的:你保持著一些你不該引用的數(shù)據(jù)。前面介紹的堆分析技術(shù)可以幫助你找到泄露點。
申請的內(nèi)存超過物理內(nèi)存
就像我前面提到的,你在啟動一個JVM時,你需要指定堆的最小值和***值。這就意味著,JVM會在運行期動態(tài)改變它對虛擬內(nèi)存的需求。在一個內(nèi)存受限的機器上,你可以同時運行多個JVM,甚至它們所有指定的***值之和大于了物理內(nèi)存和交換區(qū)的大小。當(dāng)然,這就有可能會導(dǎo)致OOM,就算你的程序中存活的對象大小小于你指定的堆空間也是一樣的。
這種情況和跑多個C++程序使用完所有的物理內(nèi)存的原因是一樣的。使用JVM可能會讓你產(chǎn)生一種假象,以為不會出現(xiàn)這種問題。唯一的解決方案是購買更多的內(nèi)存,或者不要同時跑那么多程序。沒有辦法讓JVM可以’快速失敗’;但是在Linux上你可以申請比總內(nèi)存更多的內(nèi)存。
堆外內(nèi)存的使用
***一個需要注意的問題是:Java中得堆僅僅是所占用內(nèi)存的一部分。JVM還會為它所創(chuàng)建的線程、內(nèi)部代碼、工作空間、共享庫、direct buffer、內(nèi)存映射文件分配內(nèi)存。在32位的JVM中,這所有的內(nèi)存都需要被映射到2G的虛擬內(nèi)存空間中,這是非常有限的(特別是對于服務(wù)端或者后端應(yīng)用程序)。在64位的JVM中,虛擬內(nèi)存基本沒存在什么限制,但是實際的物理內(nèi)存(含交換區(qū))可能會很稀缺。
一般來說,虛擬內(nèi)存不會造成什么大問題;操作系統(tǒng)和JVM可以很好的管理它們。通常情況下,你需要查看虛擬內(nèi)存的映射情況主要是為了direct buffer所使用的大塊的內(nèi)存或者是內(nèi)存映射文件。但是你還是很有必要知道什么是虛擬內(nèi)存的映射。
要查看在Linux上的虛擬內(nèi)存映射情況可以使用pmap;在Windows中可以使用VMMap。下面是使用pmap來dump的一個Tomcat應(yīng)用。實際的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文件展示給你了關(guān)于虛擬內(nèi)存映射的4個部分:虛擬內(nèi)存地址,大小,權(quán)限,源(從文件加載的部分)。最有意思的部分是它的權(quán)限部分,它表示了該內(nèi)存段是否是只讀的(r-)還是讀寫的(rw)。
我會從讀寫段開始分析。所有的段都具有名字”[ anon ]“,它在Linux中說明了該段不是由文件加載而來。這里還有很多被命名的讀寫段,它們和共享庫關(guān)聯(lián)。我相信這些庫都具有每個進程的地址表。
因為所有的讀寫段都具有相同的名字,一次要找出出問題的部分需要花費一點時間。對于Java堆,有4個相關(guān)的大塊內(nèi)存被分配(新生代有2個,老年代1個,***代1個),他們的大小由GC和堆配置來決定。
其他問題
這部分的內(nèi)容并不是對所有地方都適用。大部分都是我解決問題的過程中總結(jié)的實際經(jīng)驗。
不要被虛擬內(nèi)存的統(tǒng)計信息所誤導(dǎo)
有很多抱怨說Java是’memory hog’,經(jīng)常被top命令的’VIRT’部分和Windows任務(wù)管理器的’Mem Usage’列所證實。需要澄清的是,有太多的東西都不會算進這個統(tǒng)計信息中,有些還是與其他程序共享的(比如說C的庫)。實際上也有很多‘空’的區(qū)域在虛擬內(nèi)存映射空間中:如果你適用-Xms1000m來啟動JVM,就算你還沒有開始分配對象,虛擬內(nèi)存的大小也會超過1000m。
一個更好的測量方法是使用駐留集的大?。耗愕膽?yīng)用程序真正使用的物理內(nèi)存的頁數(shù),不包含共享頁。這就是top命令中得’RES’列。但是,駐留集并不是對你的程序所需使用的總內(nèi)存***的測量方法。操作系統(tǒng)只有在你的程序真正需要使用它們的時候才會將它們放進進程的內(nèi)存空間中,一般來說是在你的系統(tǒng)處于高負載的情況下才會出現(xiàn),這會花費一段較長的時間。
***:始終使用工具來提供所需的詳細信息來分析Java中的內(nèi)存問題。并且只有當(dāng)出現(xiàn)OOM的時候才考慮下結(jié)論。
OOM的罪魁禍?zhǔn)捉?jīng)常離它的拋出點很近
內(nèi)存泄露一般在內(nèi)存被分配之后不久發(fā)生。一個相似的結(jié)論是,OOM的根源一般都離它的拋出點很近,可以使用堆跟蹤技術(shù)來首先進行分析。其基本原理是,內(nèi)存泄露一般和產(chǎn)生大量的內(nèi)存相關(guān)聯(lián)。這說明了,導(dǎo)致泄露的代碼具有更高的失敗風(fēng)險率,不管是因為其內(nèi)存分配代碼被調(diào)用的過于頻繁,還是因為每次調(diào)用都分配的過大的內(nèi)存。因此,可以優(yōu)先考慮使用棧跟蹤來定位問題。
和緩存相關(guān)的部分最值得懷疑
我在這篇文章中提到緩存了很多次:在我數(shù)十年的Java工作經(jīng)歷中發(fā)現(xiàn),和內(nèi)存泄露相關(guān)的類進場都是和緩存相關(guān)的。實際上緩存是很難編寫的。
使用緩存有很多很多很好的理由,并且使用自己寫的緩存也有很多好的理由。如果你確定要使用緩存,請先回答下面的問題:
- 哪些對象會被放進緩存?如果你所要緩存的對象都是同一種類型(或者具有繼承關(guān)系),那么相比一個可以容納各種類型的緩存來說更好跟蹤問題。
- 有多少對象會被同時放進緩存?如果你像讓ProductCache緩存1000個對象,但是在內(nèi)存分析結(jié)果中發(fā)現(xiàn)了10000個對象,那么這之間的關(guān)系就比較好定位。如果你指定了這個緩存最多的容量上限,那么你就可以很容易的計算出這個緩存最多需要多少內(nèi)存。
- 過期和清除策略是什么?每一個緩存為了控制存在于其中的對象的存貨周期,都需要一個明確的驅(qū)逐策略。如果你沒有指定一個明確的驅(qū)逐策略,那么有些對象就很有可能比它真正需要的存活周期要長,占用更多的內(nèi)存,加重垃圾搜集器的負載(記?。涸跇?biāo)記階段需要的時間和存活對象的數(shù)量成正比)。
- 是否會在緩存之外同時持有這些存活對象的引用?緩存***的應(yīng)用場景是,調(diào)用頻繁,并且調(diào)用時間很短,并且所緩存的對象的獲取代價很大。如果你需要創(chuàng)建一個對象,并且在整個應(yīng)用程序的生命周期中都需要引用這個對象,那么就沒有必要將這個對象放入緩存(也許使用池技術(shù)可以顯示總得對象數(shù)量)。
注意對象的生命周期
一般來說對象可以被劃分為兩類:一類是伴隨著整個程序的生命周期而存活;另外一來是僅僅存活并服務(wù)于一個單一的請求。搞清楚這個非常重要,你僅僅需要關(guān)心你認(rèn)為是長時間存活的對象。
一種方法是在程序啟動的時候全部初始化好所有長時間(long-lived)存活的對象,不管他們是否要立刻被用到。另外一個方法是使用依賴注入框架,比如Spring。這不僅僅可以很方便的bean配置文件中找到所有l(wèi)ong-lived的對象(不需要掃描整個classpath),還可以很清楚的知道這些對象在哪里被使用。
查找在方法參數(shù)中被錯誤使用的對象
在大部分場景中,在一個方法中被分配的對象都會在方法退出的時候被清理掉(除開被返回的對象)。當(dāng)你都是用局部變量來保存這些對象的時候,這個規(guī)則很容易被遵守。但是,有時候任然會使用實體變量來保存這些對象,特別是在方法中會調(diào)用大量其他方法的時候,主要是為了避免過多和麻煩的方法參數(shù)傳遞。
這樣做不是一定會產(chǎn)生泄漏。后續(xù)的方法調(diào)用會重新對這些變量進行賦值,這樣就可以讓之前被創(chuàng)建的對象被回收。但是這樣導(dǎo)致不必要的內(nèi)存開銷,并且讓調(diào)試更加困難。但是從設(shè)計的角度出發(fā),當(dāng)我看到這樣的代碼時,我就會考慮將這個方法單獨提出來形成一個獨立的類。
J2EE:不要濫用session
session對象是用來在多個請求之間保存和共享用戶相關(guān)的數(shù)據(jù),主要是因為HTTP協(xié)議是無狀態(tài)的。有時候它便成了一個用于緩存的臨時性解決方案。
這也不是說一定就會產(chǎn)生泄漏,因為web容器會在一段時間后讓用戶的session失效。但是它卻顯著提高了整個程序的內(nèi)存占用量,這是很糟糕的。并且它非常難調(diào)試:就像我之前提到的,很難看出對象被哪些其他的對象所持有。
小心過量的垃圾搜集
雖然OOM很糟糕,但是如果不停的執(zhí)行垃圾搜集將會更加糟糕:它會搶走本該屬于你的程序的CPU時間。
有些時候你僅僅是需要更多的內(nèi)存
就像我在開頭的地方所說的,JVM是唯一的一個讓你指定你的數(shù)據(jù)***值(內(nèi)存上限)的現(xiàn)代編程環(huán)境。因此,會有很多時候讓你以為發(fā)生了內(nèi)存泄露,但是實際上你僅僅需要增加你的堆大小。解決內(nèi)存問題的***步***還是先增加你的內(nèi)存上限。如果你真的遇到了內(nèi)存泄露問題,那么無論你增加了多少內(nèi)存,你***都還是會得到OOM的錯誤。