深入解析 Java OutOfMemoryError

shenzhang發表於2016-10-30

在Java中,所有物件都儲存在堆中。他們通過new關鍵字來進行分配,JVM會檢查是否所有執行緒都無法在訪問他們了,並且會將他們進行回收。在大多數時候程式設計師都不會有一絲一毫的察覺,這些工作都被靜悄悄的執行。但是,有時候在釋出前的最後一天,程式掛了。

Exception in thread "main" java.lang.OutOfMemoryError: Java heap space

OutOfMemoryError是一個讓人很鬱悶的異常。它通常說明你幹了寫錯誤的事情:沒必要的長時間儲存一些沒必要的資料,或者同一時間處理了過多的資料。有些時候,這些問題並不一定受你的控制,比如說一些第三方的庫對一些字串做了快取,或者一些應用伺服器在部署的時候並沒有進行清理。並且,對於堆中已經存在的物件,我們往往拿他們沒辦法。

這篇文章分析了導致OutOfMemoryError的不同原因,以及你該怎樣應對這種原因的方法。以下分析僅限於Sun Hotspot虛擬機器,但是大多數結論都適用於其他任何的JVM實現。它們大多數基於網上的文章以及我自己的經驗。我沒有直接做JVM開發的工作,因此結論並不代表JVM的作者。但是我確實曾經遇到過並解決了很多記憶體相關的問題。

垃圾回收介紹

我在這篇文章中已經詳細介紹了垃圾回收的過程。簡單的說,標記-清除演算法(mark-sweep collect)以garbage collection roots作為掃描的起點,並對整個物件圖進行掃描,對所有可達的物件進行標記。那些沒有被標記的物件會被清除並回收。

Java的垃圾回收演算法過程意味著如果出現了OOM,那麼說明你在不停的往物件圖中新增物件並且沒有移除它們。這通常是因為你在往一個集合類中新增了很多物件,比如Map,並且這個集合物件是static的。或者,這個集合類被儲存在了ThreadLocal物件中,而這個對應的Thread卻又長時間的執行,一直不退出。

這與C和C++的記憶體洩露完全不一樣。在這些語言中,如果一些方法呼叫了malloc()或者new,並且在方法退出的時候沒有呼叫相應的free()或者delete,那麼記憶體就會產生洩露。這些是真正意義上得洩露,你在這個程式範圍內不可能再恢復這些記憶體,除非使用一些特定的工具來保證每一個記憶體分配方法都有其對應的記憶體釋放操作相對應。

在java中,“洩露”這個詞往往被誤用了。因為從JVM的角度來說,所有的記憶體都是被良好管理的。問題僅僅是作為程式設計師的你不知道這些記憶體是被哪些物件佔用了。但是幸運的是,你還是有辦法去找到和定位它們。

在深入探討之前,你還有最後一件關於垃圾收集的知識需要了解:JVM會盡最大的能力去釋放記憶體,直到發生OOM。這就意味著OOM不能通過簡單的呼叫System.gc()來解決,你需要找到這些“洩露”點,並自己處理它們。

設定堆大小

學院派的人非常喜歡說Java語言規範並沒有對垃圾收集器進行任何約定,你甚至可以實現一個從來不釋放記憶體的JVM(實際是毫無意義的)。Java虛擬機器規範中提到堆是由垃圾回收器進行管理,但是卻沒有說明任何相關細節。僅僅說了我剛才提到的那句話:垃圾回收會發生在OOM之前。

實際上,Sun Hotspot虛擬機器使用了一個固定大小的堆空間,並且允許在最小空間和最大空間之間進行自動增長。如果你沒有指定最小值和最大值,那麼對於’client’模式將會預設使用2Mb最為最小值,64Mb最為最大值;對於’server’模式,JVM會根據當前可用記憶體來決定預設值。2000年後,預設的最大堆大小改為了64M,並且在當時已經認為足夠大了(2000年前的時候預設值是16M),但是對於現在的應用程式來說很容易就用完了。

這意味著你需要顯示的通過JVM引數來指定堆的最小值和最大值:

java -Xms256m -Xmx512m MyClass

這裡有很多經驗上得法則來設定最大值和最小值。顯然,堆的最大值應該設定為足以容下整個應用程式所需要的全部物件。但是,將它設定為“剛剛好足夠大”也不是一個很好的注意,因為這樣會增加垃圾回收器的負載。因此,對於一個長時間執行的應用程式,你一般需要保持有20%-25%的空閒堆空間。(你得應用程式可能需要不同的引數設定,GC調優是一門藝術,並且不在該文章討論範圍內)

讓你奇怪的時,設定合適的堆的最小值往往比設定合適的最大值更加重要。垃圾回收器會盡可能的保證當前的的堆大小,而不是不停的增長堆空間。這會導致應用程式不停的建立和回收大量的物件,而不是獲取新的堆空間,相對於初始(最小)堆空間。Java堆會盡量保持這樣的堆大小,並且會不停的執行GC以保持這樣的容量。因此,我認為在生產環境中,我們最好是將堆的最小值和最大值設定成一樣的。

你可能會困惑於為什麼Java堆會有一個最大值上限:作業系統並不會分配真正的實體記憶體,除非他們真的被使用了。並且,實際使用的虛擬記憶體空間實際上會比Java堆空間要大。如果你執行在一個32位系統上,一個過大的堆空間可能會限制classpath中能夠使用的jar的數量,或者你可以建立的執行緒數。

另外一個原因是,一個受限的最大堆空間可以讓你及時發現潛在的記憶體洩露問題。在開發環境中,對應用程式的壓力往往是不夠的,如果你在開發環境中就擁有一個非常大得堆空間,那麼你很有可能永遠不會發現可能的記憶體洩露問題,直到進入產品環境。

在執行時跟蹤垃圾回收

所有的JVM實現都提供了-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]
...

從上面的輸出我們可以看出什麼?首先,前面的幾次垃圾回收發生的非常頻繁。每行的第一個欄位顯示了JVM啟動後的時間,我們可以看到在一秒鐘內有上百次的GC。並且,還加入了每次GC執行時間的開始時間(在每行的最後一個欄位),可以看出垃圾蒐集器是在不停的執行的。

但是在實時系統中,這會造成很大的問題,因為垃圾蒐集器的執行會奪走很多的CPU週期。就像我之前提到的,這很可能是由於初始堆大小設定的太小了,並且GC日誌顯示了:每次堆的大小達到了1.1Mb,它就開始執行GC。如果你得系統也有類似的現象,請在改變自己的應用程式之前使用-Xms來增大初始堆大小。

對於GC日誌還有一些很有趣的地方:除了第一次垃圾回收,沒有任何物件是存放在了新生代(“DefNew”)。這說明了這個應用程式分配了包含大量資料的陣列,在顯示世界裡這是很少出現的。如果在一個實時系統中出現這樣的狀況,我想到的第一個問題是“這些陣列拿來幹什麼用?”。

堆轉儲(Heap Dumps)

一個堆轉儲可以顯示你在應用程式說使用的所有物件。從基礎上講,它僅僅反映了物件例項的數量和類檔案所佔用的位元組數。當然你也可以將分配這些記憶體的程式碼一起dump出來,並且對比歷史存貨物件。但是,如果你要dump的資料資訊越多,JVM的負載就會越大,因此這些技術僅僅應該使用在開發環境中。

怎樣獲得一個記憶體轉儲

命令列引數-XX:+HeapDumpOnOutOfMemoryError是最簡單的方式生成記憶體轉儲。就像它的名字所說的,它會在記憶體被用完的時候(發生OOM)進行轉儲,這在產品環境非常好用。但是由於這個是一種事後轉儲(已經發生了OOM),它只能提供一種歷史性的資料。它會產生一個二進位制檔案,你可以使用jhat來操作該檔案(這個工具在JDK1.6中已經提供,但是可以讀取JDK1.5產生的檔案)。

你可以使用jmap(JDK1.5之後就自帶了)來為一個執行中得java程式產生堆轉儲,可以產生一個在jhat中使用的dump檔案,或者是一個存文字的統計檔案。統計圖可以在進行分析時優先使用,特別是你要在一段時間內多次轉儲堆並進行分析和對比歷史資料。

從轉儲內容和JVM的負荷的擴充套件性上考慮的話,可以使用profilers。Profiles使用JVM的除錯介面(debuging interface)來蒐集物件的記憶體分配資訊,包括具體的程式碼行和方法呼叫棧。這個是非常有用的:不僅僅可以知道你分配了一個數GB的陣列,你還可以知道你在一個特定的地方分配了950MB的物件,並且直接忽略其他的物件。當然,這些結果肯定會對JVM有開銷,包括CPU的開銷和記憶體的開銷(儲存一些原始資料)。你不應該在產品環境中使用profiles。

堆轉儲分析:live objects

Java中的記憶體洩露是這樣定義的:你在記憶體中分配了一些物件,但是並沒有清除掉所有對它們的引用,也就是說垃圾蒐集器不能回收它們。使用堆轉儲直方圖可以很容易的查詢這些洩露物件:它不僅僅可以告訴你在記憶體中分配了哪些物件,並且顯示了這些物件在記憶體中所佔用的大小。但是這種直方圖最大的問題是:對於同一個類的所有物件都被聚合(group)在一起了,所以你還需要進一步做一些檢測來確定這些記憶體在哪裡被分配了。

使用jmap並且加上-histo引數可以為你產生一個直方圖,它顯示了從程式執行到現在所有物件的數量和記憶體消耗,並且包含了已經被回收的物件和記憶體。如果使用-histo:live引數會顯示當前還在堆中得物件數量及其記憶體消耗,不論這些物件是否要被垃圾蒐集器進行回收。

也就是說,如果你要得到一個當前時間下得準確資訊,你需要在使用jmap之前強制執行一次垃圾回收。如果你的應用程式是執行在本地,最簡單的方式是直接使用jconsole:在’Memory’標籤下,有一個’Perform GC’的按鈕。如果應用程式是執行在服務端環境,並且JMX beans被暴露了,MemoryMXBean有一個gc()操作。如果上述的兩種方案都沒辦法滿足你得要求,你就只有等待JVM自己觸發一次垃圾蒐集過程了。如果你有一個很嚴重的記憶體洩露問題,那麼第一次major collection很可能預示著不久後就會OOM。

有兩種方法使用jmap產生的直方圖。其中最有效的方法,適用於長時間執行的程式,可以使用帶live的命令列引數,並且在一段時間內多次使用該命令,檢查哪些物件的數量在不斷增長。但是,根據當前程式的負載,該過程可能會花費1個小時或者更多的時間。

另外一個更加快速的方式是直接比較當前存活的物件數量和總的物件數量。如果有些物件佔據了總物件數量的大部分,那麼這些物件很有可能發生記憶體洩露。這裡有一個例子,這個應用程式已經連續幾周為100多個使用者提供了服務,結果列舉了前12個數量最多的物件。據我所知,這個程式沒有記憶體洩露的問題,但是像其他應用程式一樣做了常規性的記憶體轉儲分析操作。

~, 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  <constMethodKlass>
   8:        181133        8694384  java.lang.ref.WeakReference
   9:         43675        7651848  [Ljava.lang.Object;
  10:         63450        7621520  <methodKlass>
  11:          6729        7040104  <constantPoolKlass>
  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  <constMethodKlass>
   6:        199878        7995120  java.lang.String
   7:         63208        7592480  <methodKlass>
   8:          6608        6920072  <constantPoolKlass>
   9:         93830        5254480  java.lang.ThreadLocal$ThreadLocalMap$Entry
  10:        107128        5142144  java.lang.ref.WeakReference
  11:         93462        5135952  <symbolKlass>
  12:          6608        4880592  <instanceKlassKlass>

當我們要嘗試尋找記憶體洩露問題,可以從消耗記憶體最多的物件著手。這聽上去很明顯,但是往往它們並不是記憶體洩露的根源。但是,它們任然是應該最先下手的地方,在這個例子中,最佔用記憶體的是一些char[]的陣列物件(總大小是60MB,基本上沒有任何問題)。但是很奇怪的是當前存貨(live)的物件竟然佔了歷史分配的總物件大小的三分之二。

一般來說,一個應用程式會分配物件,並且在不久之後就會釋放它們。如果儲存一些物件的應用過長的時間,就很有可能會導致記憶體洩露。但是雖然是這麼說的,實際上還是要具體情況具體分析,主要還是要看這個程式到底在做什麼事情。字元陣列物件(char[])往往和字串物件(String)同時存在,大部分的應用程式都會在整個執行過程中一直保持著一些字串物件的引用。例如,基於JSP的web應用程式在JSP頁面中定義了很多HTML字串表示式。這種特殊的應用程式提供HTML服務,但是它們需要保持字串引用的需求卻不一定那麼清晰:它們提供的是目錄服務,並不是靜態文字。如果我遇到了OOM,我就會嘗試找到這些字串在哪裡被分配,為什麼沒有被釋放。

另一個需要關注的是位元組陣列([B)。在JDK中有很多類都會使用它們(比如BufferedInputStream),但是卻很少在應用程式程式碼中直接看到它們。通常它們會被用作快取(buffer),但是快取的生命週期不會很長。在這個例子中我們看到,有一半的位元組陣列任然保持存活。這個是令人擔憂的,並且它凸顯了直方圖的一個問題:所有的物件都按照它的型別被分組聚合了。對於應用程式物件(非JDK型別或者原始型別,在應用程式程式碼中定義的類),這不是一個問題,因為它們會在程式的一個部分被集中分配。但是位元組陣列有可能會在任何地方被定義,並且在大多數應用程式中都被隱藏在一些庫中。我們是否應當搜尋呼叫了new byte[]或者new ByteArrayOutputStream()的程式碼?

堆轉儲分析:相關的原因和影響分析

為了找到導致記憶體洩露的最終原因,僅僅考慮按照類別(class)的分組的記憶體佔用位元組數是不夠的。你還需要將應用程式分配的物件和記憶體洩露的物件關聯起來考慮。一個方法是更加深入檢視物件的數量,以便將具有關聯性的物件找出來。下面是一個具有嚴重記憶體問題的程式的轉儲資訊:

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個位元組的記憶體空間。一個更好的涉及方案是僅僅在有需要的時候才使用List,這樣對每個例項來說可以節約額外的48個位元組。但是,對於這種問題也可以很輕易的通過加記憶體來解決,因為現在的記憶體非常便宜。

但是對於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顯示了所有載入進系統的class,但是我覺得並不是很有用。相反,我直接使用http://localhost:7000/histo/,這個地址是一個直方圖的視角來進行顯示,並且是按照物件數量和佔用的記憶體空間進行排序了的。

這個直方圖裡的每個class的名稱都是一個連結,點選這個連結可以檢視關於這個型別的詳細資訊。你可以在其中看到這個類的繼承關係,它的成員變數,以及很多指向這個類的實體變數資訊的連結。我不認為這個詳細資訊頁面非常有用,而且實體變數的連結列表很佔用很多的瀏覽器記憶體。

為了能夠跟蹤你的記憶體問題,最有用的頁面是’Reference by Type’。這個頁面含有兩個表格:入引用和出引用,他們都被引用的數量進行排序了。點選一個類的名字可以看到這個引用的資訊。

你可以在類的詳細資訊(class details)頁面中找到這個頁面的連結。

堆轉儲分析:記憶體分配情況

在大多數情況下,知道了是哪些物件消耗了大量的記憶體往往就可以知道它們為什麼會發生記憶體洩露。你可以使用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

這個應用程式沒有分配多種不同型別的物件,也沒有將它們分配到很多不同的地方。一般的轉儲有成百上千行的資訊,顯示了每一種型別的物件被分配到了哪裡。幸運的是,大多數問題都會出現在開頭的幾行。在這個例子中,最突出的是64M的存活著的位元組陣列,並且每一個平均32K。

大多數程式中都不會一直持有這麼大得資料,這就表明這個程式沒有很好的抽取和處理這些資料。你會發現這常常發生在讀取一些大的字串,並且儲存了substring之後的字串:很少有人知道String.substring()後會共享原始字串物件的位元組陣列。如果你按照一行一行地讀取了一個檔案,但是卻使用了每行的前五個字元,實際上你任然儲存的是整個檔案在記憶體中。

轉儲檔案也顯示出這些陣列被分配的數量和現在存活的數量完全相等。這是一種典型的洩露,並且我們可以通過搜尋’trace’號來找到真正的程式碼:

TRACE 300157:
    com.kdgregory.example.memory.Gobbler.main(Gobbler.java:22)

好了,這下就足夠簡單了:當我在程式碼中找到指定的程式碼行時,我發現這些陣列被存放在了ArrayList中,並且它也一直沒有出作用域。但是有時候,堆疊的跟蹤並沒有直接關聯到你寫的程式碼上:

TRACE 300085:
    java.util.zip.InflaterInputStream.<init>(InflaterInputStream.java:71)
    java.util.zip.ZipFile$2.<init>(ZipFile.java:348)

在這個例子中,你需要增加堆疊跟蹤的深度,並且重新執行你的程式。但是這裡有一個需要平衡的地方:當你獲取到了更多的堆疊資訊,你也同時增加了profile的負載。預設地,如果你沒有指定depth引數,那麼預設值就會是4。我發現當堆疊深度為2的時候就可以發現和定位我程式中得大部分問題了,當然我也使用過深度為12的引數來執行程式。

另外一個增大堆疊深度的好處是,最後的報告結果會更加細粒度:你可能會發現你洩露的物件來自兩到三個地方,並且它們都使用了相同的方法。

堆轉儲分析:位置、地點

當很多物件在分配的不久後就被丟棄時,分代垃圾蒐集器就會開始執行。你可以使用同樣的原則來找發現記憶體洩露:使用偵錯程式,在物件被分配的地方打上斷點,並且執行這段程式碼。在大多數時候,當它們被分配不久後就會加入到長時間存活(long-live)的集合中。

永久代

除了JVM中的新生代和老年代外,JVM還管理著一片叫‘永久代’的區域,它儲存了class資訊和字串表示式等物件。通常,你不會觀察到永久代中的垃圾回收;大多數的垃圾回收發生在應用程式堆中。但是不像它的名字,在永久代中的物件不會是永久不變的。舉個例子,被應用程式classloader載入的class,當不再被classloader引用時就會被清理掉。當應用程式服務被頻繁的熱部署時就可能會發生:

Exception in thread "main" java.lang.OutOfMemoryError: PermGen space

這一這個資訊:這個不管應用程式堆的事。當應用程式堆中還有很多空間時,也有可能用完永久代的空間。通常,這發生在重新部署EAR和WAR檔案時,並且永久代還不夠大到可以同時容納新的class資訊和老的class資訊(老的class會一直被儲存著直到所有的請求在使用完它們)。當在執行處於開發狀態的應用時更容易發生。

解決永久代錯誤的第一個方法就是增大永久大的空間,你可以使用-XX:MaxPermSize命令列引數。預設是64M,但是web應用程式或者IDE一般都需要256M。

java -XX:MaxPermSize=256m

但是在通常情況下並不是這麼簡單的。永久代的記憶體洩露一般都和在應用堆中的記憶體洩露原因一樣:在一些地方的物件引用了並不該再引用的物件。以我的經驗,很有可能有些物件直接引用了一些Class物件,或者在java.lang.reflect包下面的物件,而不是某些類的例項物件。正式因為web引用的classloader的組織方式,通常罪魁禍首都出現在服務的配置當中。

例如,你使用了Tomcat,並且有一個目錄裡面有很多共享的jars:shared/lib。如果你在一個容器裡同時執行好幾個web應用,將一些公用的jar放在這個目錄是很有道理的,因為這樣的話這些class僅僅被載入一次,可以減少記憶體的使用量。但是,如果其中的一些庫具有物件快取的話,會發生什麼事情呢?

答案是這些被快取了的物件的類永遠不會被解除安裝,直到快取釋放了這些物件。解決方案就是將這些庫移動到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中往往可以很快速的解決問題。

當堆記憶體還有空間時發生的OutOfMemoryError

就像你剛才看到的關於永久代的訊息,也許應用程式堆中還有空閒空間,但是也任然可能會發生OOM。這裡有幾個例子:

連續的記憶體分配

當我描述分代的堆空間時,我一般會說物件會首先被分配在新生代,然後最終會被移動到老年代。但這不是絕對正確的:如果你的物件足夠大,那麼它就會直接被分配在老年代。一般使用者自己定義的物件是不會(也不應該)達到這個臨界值,但是陣列卻卻有可能:在JDK1.5中,當陣列的物件超過0.5M的時候就會被直接分配到老年代。

在32位機器上,0.5M換算成Object[]陣列的話就可以包含131,072個元素。這已經是很大的了,但是在企業級的應用中這是很有可能的。特別是當使用了HashMap時,它經常需要重新resize自己(裡面的陣列資料結構)。一些應用程式可能還需要更大的陣列。

當沒有連續的堆空間來存放這些陣列物件時(就算在垃圾回收並且對記憶體進行了緊湊之後),問題就產生了。這很少見,但是如果當前的程式已經很接近堆空間的上限時,這就變得很有可能了。增大堆空間上限是最好的解決方案,但是你也許可以試試事先分配好你的容器的大小。(後面的小物件可以不需要連續的記憶體空間)

執行緒

JavaDoc中對OOM的描述是,當垃圾蒐集器不能在釋放更多的記憶體空間時,JVM會丟擲OOM。這裡只對了一半:當JVM的內部程式碼收到來自作業系統的ENOMEM錯誤時,JVM也會丟擲OOM。Unix程式設計師一般都知道,這裡有很多地方可以收到ENOMEN錯誤,建立執行緒的過程是其中之一:

Exception in thread "main" java.lang.OutOfMemoryError: unable to create new native thread

在我的32位Linux系統中,使用JDK1.5,我可以最多開啟5,550個執行緒直到丟擲異常。但是實際上在堆中任然有很多空閒空間,這是怎麼回事呢?

在這個場景的背後,執行緒實際上是被作業系統所管理,而不是JVM,建立執行緒失敗的可能原因有很多很多。在我的例子中,每一個執行緒都需要佔用大概0.5M的虛擬記憶體作為它的棧空間,在5000個執行緒被建立之後,大約就有2G的記憶體空間被佔用。有些作業系統就強制制定了一個程式所能建立的執行緒數的上限。

最後,針對這個問題沒有一個解決方案,除非更換你的應用程式。大多數程式是不需要建立這麼多得執行緒的,它們會將大部分的時間都浪費在等待作業系統排程上。但是有些服務程式需要建立數千個執行緒去處理請求,但是它們中得大多數都是在等待資料。針對這種場景,NIO和selector就是一個不錯的解決方案。

Direct ByteBuffers

從JDK1.4之後Java允許程式程式使用bytebuffers來訪問堆外的記憶體空間(受限)。雖然ByteBuffer物件本身很小,但是堆外的記憶體可不一定很小:

Exception in thread "main" java.lang.OutOfMemoryError: Direct buffer memory

這裡有多個原因會導致bytebuffer分配失敗。通常情況下,你可能超過了最多的虛擬記憶體上限(僅限於32位系統),或者超過了所有實體記憶體和交換區記憶體的上限。除非你是在以很簡單的方式處理超過你的機器記憶體上限的資料,否則你在使用direct buffer產生OOM的原因和你使用堆的原因基本上是一樣的:你保持著一些你不該引用的資料。前面介紹的堆分析技術可以幫助你找到洩露點。

申請的記憶體超過實體記憶體

就像我前面提到的,你在啟動一個JVM時,你需要指定堆的最小值和最大值。這就意味著,JVM會在執行期動態改變它對虛擬記憶體的需求。在一個記憶體受限的機器上,你可以同時執行多個JVM,甚至它們所有指定的最大值之和大於了實體記憶體和交換區的大小。當然,這就有可能會導致OOM,就算你的程式中存活的物件大小小於你指定的堆空間也是一樣的。

這種情況和跑多個C++程式使用完所有的實體記憶體的原因是一樣的。使用JVM可能會讓你產生一種假象,以為不會出現這種問題。唯一的解決方案是購買更多的記憶體,或者不要同時跑那麼多程式。沒有辦法讓JVM可以’快速失敗’;但是在Linux上你可以申請比總記憶體更多的記憶體。

堆外記憶體的使用

最後一個需要注意的問題是:Java中得堆僅僅是所佔用記憶體的一部分。JVM還會為它所建立的執行緒、內部程式碼、工作空間、共享庫、direct buffer、記憶體對映檔案分配記憶體。在32位的JVM中,這所有的記憶體都需要被對映到2G的虛擬記憶體空間中,這是非常有限的(特別是對於服務端或者後端應用程式)。在64位的JVM中,虛擬記憶體基本沒存在什麼限制,但是實際的實體記憶體(含交換區)可能會很稀缺。

一般來說,虛擬記憶體不會造成什麼大問題;作業系統和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中說明了該段不是由檔案載入而來。這裡還有很多被命名的讀寫段,它們和共享庫關聯。我相信這些庫都具有每個程式的地址表。

因為所有的讀寫段都具有相同的名字,一次要找出出問題的部分需要花費一點時間。對於Java堆,有4個相關的大塊記憶體被分配(新生代有2個,老年代1個,永久代1個),他們的大小由GC和堆配置來決定。

其他問題

這部分的內容並不是對所有地方都適用。大部分都是我解決問題的過程中總結的實際經驗。

不要被虛擬記憶體的統計資訊所誤導

有很多抱怨說Java是’memory hog’,經常被top命令的’VIRT’部分和Windows工作管理員的’Mem Usage’列所證實。需要澄清的是,有太多的東西都不會算進這個統計資訊中,有些還是與其他程式共享的(比如說C的庫)。實際上也有很多‘空’的區域在虛擬記憶體對映空間中:如果你適用-Xms1000m來啟動JVM,就算你還沒有開始分配物件,虛擬記憶體的大小也會超過1000m。

一個更好的測量方法是使用駐留集的大小:你的應用程式真正使用的實體記憶體的頁數,不包含共享頁。這就是top命令中得’RES’列。但是,駐留集並不是對你的程式所需使用的總記憶體最好的測量方法。作業系統只有在你的程式真正需要使用它們的時候才會將它們放進程式的記憶體空間中,一般來說是在你的系統處於高負載的情況下才會出現,這會花費一段較長的時間。

最後:始終使用工具來提供所需的詳細資訊來分析Java中的記憶體問題。並且只有當出現OOM的時候才考慮下結論。

OOM的罪魁禍首經常離它的丟擲點很近

記憶體洩露一般在記憶體被分配之後不久發生。一個相似的結論是,OOM的根源一般都離它的丟擲點很近,可以使用堆跟蹤技術來首先進行分析。其基本原理是,記憶體洩露一般和產生大量的記憶體相關聯。這說明了,導致洩露的程式碼具有更高的失敗風險率,不管是因為其記憶體分配程式碼被呼叫的過於頻繁,還是因為每次呼叫都分配的過大的記憶體。因此,可以優先考慮使用棧跟蹤來定位問題。

和快取相關的部分最值得懷疑

我在這篇文章中提到快取了很多次:在我數十年的Java工作經歷中發現,和記憶體洩露相關的類進場都是和快取相關的。實際上快取是很難編寫的。

使用快取有很多很多很好的理由,並且使用自己寫的快取也有很多好的理由。如果你確定要使用快取,請先回答下面的問題:

  • 哪些物件會被放進快取?如果你所要快取的物件都是同一種型別(或者具有繼承關係),那麼相比一個可以容納各種型別的快取來說更好跟蹤問題。
  • 有多少物件會被同時放進快取?如果你像讓ProductCache快取1000個物件,但是在記憶體分析結果中發現了10000個物件,那麼這之間的關係就比較好定位。如果你指定了這個快取最多的容量上限,那麼你就可以很容易的計算出這個快取最多需要多少記憶體。
  • 過期和清除策略是什麼?每一個快取為了控制存在於其中的物件的存貨週期,都需要一個明確的驅逐策略。如果你沒有指定一個明確的驅逐策略,那麼有些物件就很有可能比它真正需要的存活週期要長,佔用更多的記憶體,加重垃圾蒐集器的負載(記住:在標記階段需要的時間和存活物件的數量成正比)。
  • 是否會在快取之外同時持有這些存活物件的引用?快取最好的應用場景是,呼叫頻繁,並且呼叫時間很短,並且所快取的物件的獲取代價很大。如果你需要建立一個物件,並且在整個應用程式的生命週期中都需要引用這個物件,那麼就沒有必要將這個物件放入快取(也許使用池技術可以顯示總得物件數量)。

注意物件的生命週期

一般來說物件可以被劃分為兩類:一類是伴隨著整個程式的生命週期而存活;另外一來是僅僅存活並服務於一個單一的請求。搞清楚這個非常重要,你僅僅需要關心你認為是長時間存活的物件。

一種方法是在程式啟動的時候全部初始化好所有長時間(long-lived)存活的物件,不管他們是否要立刻被用到。另外一個方法是使用依賴注入框架,比如Spring。這不僅僅可以很方便的bean配置檔案中找到所有long-lived的物件(不需要掃描整個classpath),還可以很清楚的知道這些物件在哪裡被使用。

查詢在方法引數中被錯誤使用的物件

在大部分場景中,在一個方法中被分配的物件都會在方法退出的時候被清理掉(除開被返回的物件)。當你都是用區域性變數來儲存這些物件的時候,這個規則很容易被遵守。但是,有時候任然會使用實體變數來儲存這些物件,特別是在方法中會呼叫大量其他方法的時候,主要是為了避免過多和麻煩的方法引數傳遞。

這樣做不是一定會產生洩漏。後續的方法呼叫會重新對這些變數進行賦值,這樣就可以讓之前被建立的物件被回收。但是這樣導致不必要的記憶體開銷,並且讓除錯更加困難。但是從設計的角度出發,當我看到這樣的程式碼時,我就會考慮將這個方法單獨提出來形成一個獨立的類。

J2EE:不要濫用session

session物件是用來在多個請求之間儲存和共享使用者相關的資料,主要是因為HTTP協議是無狀態的。有時候它便成了一個用於快取的臨時性解決方案。

這也不是說一定就會產生洩漏,因為web容器會在一段時間後讓使用者的session失效。但是它卻顯著提高了整個程式的記憶體佔用量,這是很糟糕的。並且它非常難除錯:就像我之前提到的,很難看出物件被哪些其他的物件所持有。

小心過量的垃圾蒐集

雖然OOM很糟糕,但是如果不停的執行垃圾蒐集將會更加糟糕:它會搶走本該屬於你的程式的CPU時間。

有些時候你僅僅是需要更多的記憶體

就像我在開頭的地方所說的,JVM是唯一的一個讓你指定你的資料最大值(記憶體上限)的現代程式設計環境。因此,會有很多時候讓你以為發生了記憶體洩露,但是實際上你僅僅需要增加你的堆大小。解決記憶體問題的第一步最好還是先增加你的記憶體上限。如果你真的遇到了記憶體洩露問題,那麼無論你增加了多少記憶體,你最後都還是會得到OOM的錯誤。

相關文章