天天看點

記憶體溢出OutOfMemoryError科普系列一

在Java中,所有對象都存儲在堆中。它們由新的操作符配置設定,當JVM确定沒有程式線程可以通路它們時,它們将被丢棄。大多數時候,這種情況都是悄無聲息地發生的,程式員也不會再想一想。然後,通常在截止日期前一天左右,程式就會終止。

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

OutOfMemoryError是一個令人沮喪的例外。這通常意味着你做錯了事情:要麼抓住對象太久,要麼一次處理太多資料。有時,它表示您無法控制的問題,例如緩存字元串的第三方庫,或者部署後不清理的應用程式伺服器。有時,它與堆上的對象無關。

本文将研究OutOfMemoryError的不同原因,以及您可以采取的措施。這裡以Oracle的HotSpotVM為準,這是我使用的一個JVM。然而,大部分内容适用于任何JVM實作。它是根據網上的文獻和我自己的經驗寫成的。我不研究JVM的内部結構,是以不能站在權威的立場上說話。但我已經面對并解決了很多記憶體問題。

垃圾收集概述

我已經在别處較長的描述了垃圾收集過程。總而言之,mark-sweep收集器從“GC ROOT 垃圾收集根”開始,周遊整個對象圖,标記它所觸及的每個對象。未被觸及的對象是垃圾,将被收集。

Java的垃圾收集方法意味着,記憶體耗盡的唯一方法是不斷地向圖中添加對象,但不删除它們。通常,這是因為您将對象添加到從靜态變量引用的集合(通常是映射)中。或者,由ThreadLocal或長壽命線程的Runnable儲存的集合的情況較少。

這與C和C++程式中的記憶體洩漏非常不同。在這些語言中,當方法調用malloc()或new,然後傳回而不調用相應的free()或delete時,就會發生洩漏。這些是真正的洩漏:如果不使用工具将每個配置設定與其相應的釋放相比對,您将永遠無法恢複記憶體。

在Java中,“洩漏”的記憶體隻是放錯了地方。就JVM而言,它已經被考慮在内了。問題是你,程式員,不知道在哪裡。幸運的是,有辦法找到它。

在深入研究這些技術之前,關于垃圾收集器還有最後一件事要知道:它将盡最大努力在JVM抛出OutOfMemoryError之前釋放記憶體。也就是說垃圾回收System.gc()解決不了你的問題。你得找到漏洞自己堵住。

設定堆大小

正如學究們喜歡指出的那樣,Java語言規範并沒有提到垃圾收集:您可以實作一個永遠不會釋放記憶體的JVM(并不是說它會非常有用)。Java虛拟機規範注意到堆是由垃圾收集器管理的,但是顯式地保留了實作細節。關于垃圾收集的唯一保證是我上面提到的:收集器(如果存在)将在JVM抛出OutOfMemoryError之前運作。

實際上,JVM使用一個固定大小的堆,允許在最小和最大界限之間根據需要增長。如果不指定這些邊界,“client客戶機”JVM的預設值最小為2Mb,最大為64Mb;“server伺服器”JVM根據可用記憶體使用預設值。當64Mb在2000年成為預設值時(之前的預設值是16Mb),它看起來一定很大,但現代應用程式很容易耗盡它。

這意味着您通常必須使用-Xms和-Xmx指令行參數顯式地調整堆的大小:

java -Xms256m -Xmx512m MyClass           

設定最小和最大堆大小有許多經驗法則。顯然,最大值必須足夠高,以容納程式所需的所有對象。但是,将其設定為“剛好足夠大”不是一個好主意,因為這樣會增加垃圾收集器的工作負載。相反,對于長時間運作的應用程式,您應該計劃保持20–25%的堆為空(盡管您的特定應用程式可能需要不同的設定;GC調優是一門超出本文範圍的技術)。

令人驚訝的是,最小堆大小通常比最大值更重要。垃圾收集器将嘗試保持目前堆的大小,而不是增加堆的大小。這可能導緻程式建立和丢棄大量對象,但所需記憶體永遠不會超過初始(最小)堆大小。堆将保持在該大小,但垃圾收集器将不斷運作以使其保持在該大小。在生産環境中,我認為将最小和最大界限設定為相同的值是有意義的。

您可能想知道我為什麼要費心限制最大大小:畢竟,除非實際使用了實體記憶體,否則作業系統不會配置設定實體記憶體。部分原因是虛拟位址空間必須容納的不僅僅是Java堆。如果您在32位系統上運作,那麼較大的最大堆大小可能會限制類路徑上jar的數量,或者限制您可以建立的線程的數量。

限制最大堆大小的另一個原因是它可以幫助您發現代碼中的任何記憶體洩漏。開發環境往往不會給應用程式帶來壓力。如果您在開發過程中使用了一個巨大的最大堆大小,您可能永遠不會意識到記憶體洩漏,直到您進入生産環境。

觀察垃圾收集器工作

所有JVM都提供-verbose: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]
...           

Oracle 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啟動後的秒數,我們每幾百分之一秒就會看到一次集合。事實上,将集合的開始時間添加到其執行時間(顯示在行尾)之後,似乎收集器一直在運作。

在實際的應用程式中,這将是一個問題,因為收集器會占用程式的CPU周期。正如我上面提到的,這可能意味着初始堆大小太小,日志證明了這一點:每當堆達到1.1 Mb時,它就會被收集起來。如果看到這種情況,請在更改應用程式之前增加-Xms值。

這個日志還有一個有趣的地方:除了第一個集合之外,年輕代“DefNew”中沒有存儲任何對象。這表明程式正在配置設定大的數組,而其他什麼都沒有——這是任何真實世界的程式都不應該做的。如果我在實際的應用程式中看到這一點,我的第一個問題是“這些數組是用來做什麼的?

heap dump 堆轉儲

堆轉儲向您顯示應用程式正在使用的對象。在最基本的情況下,它隻是按類計算執行個體數和位元組數。您還可以擷取顯示配置設定記憶體的代碼的轉儲,并将曆史計數與活動計數進行比較。但是,收集的資訊越多,給運作中的JVM增加的開銷就越大,是以其中一些技術隻适用于開發環境。

如何獲得堆轉儲dump檔案

-XX:+HeapDumpOnOutOfMemoryError指令行參數是生成堆轉儲dump檔案的最簡單方法。顧名思義,隻有當程式記憶體不足時,它才會生成轉儲,這使得它适合于生産使用。但是,因為它是一個事後轉儲,是以它隻能提供對象的直方圖。此外,它建立了一個二進制檔案,您必須使用jhat工具來檢查該檔案(該工具是jdk1.6發行版的一部分,但将讀取jdk1.5 jvms生成的檔案)。

jmap指令(從1.5開始提供)允許您從運作的JVM生成堆轉儲dump檔案,jhat的dump檔案或簡單的文本直方圖。直方圖是一個很好的第一行分析,特别是當您在一段較長的時間内多次運作它時,或者當您将活動對象計數與曆史配置設定計數進行比較時。

在規模的最末端,無論是在資訊方面還是在開銷方面,都是探查器。探查器使用JVM的調試接口來收集有關對象配置設定的詳細資訊,包括代碼行和調用堆棧。這是非常有用的:您可以看到您在一個位置配置設定了950MB,而不是僅僅知道您已經配置設定了1GB的陣列,并且可以忽略其他位置。當然,這些資訊是有代價的,包括CPU消耗和存儲原始資料的記憶體。不允許您在生産環境中運作探查器。

堆轉儲分析:活動對象

Java記憶體洩漏的定義是,配置設定對象而不清除對它們的所有引用,這意味着垃圾收集器無法回收它們。堆直方圖是開始查找此類洩漏的一種簡單方法:它不僅顯示堆中的對象,還顯示它們消耗的記憶體量。簡單直方圖的主要缺點是,同一類的所有對象都被分組在一起,是以您必須做一些檢測工作來找出它們的配置設定位置。

使用-histo選項調用jmap會得到一個直方圖,顯示自程式啟動以來建立的所有對象(包括已經收集的對象)的計數和記憶體消耗。使用-histo:live仍然在堆上的對象的計數,無論它們是否符合收集條件。

這意味着,要獲得準确的計數,需要在調用jmap之前強制運作垃圾收集器。如果您在本地運作應用程式,最簡單的方法是使用jconsole:在“記憶體”頁籤的頂部,有一個标記為“執行GC”的按鈕。如果您在伺服器環境中運作,并且暴露了JMX bean,則在java.lang組有一個gc()操作。如果這兩個選項都不可用,則可以一直等待正常的垃圾回收。然而,如果您有一個嚴重的洩漏,那麼第一個主要的收集很可能是OutOfMemoryError的直接前兆。

顯示所有對象(甚至是已收集的對象)的直方圖對于查找“熱門”對象也很有用:那些經常被配置設定和丢棄的對象。如果用O(N2)算法建立臨時對象,它将立即從直方圖中顯現出來。

有兩種方法可以使用jmap生成的直方圖。最有用的技術,特别是對于長時間運作的伺服器應用程式,是在一個較長的時間段内多次調用“live”選項,并調查那些計數不斷增加的對象。但是,根據伺服器負載的不同,可能需要一個小時或更長時間才能獲得好的資訊。

一種更快的方法是将活動對象與總對象進行比較。那些帶電計數占總數很大一部分的物體可能存在洩漏。下面的示例顯示了一個存儲庫管理器的前十幾個條目(近2500個條目中),該存儲庫管理器已經為100多個使用者提供了幾個星期的服務。據我所知,這個程式并沒有記憶體洩漏,但是它的正常操作會導緻堆轉儲,這與那些有記憶體洩漏的程式類似。

~, 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,這不是什麼問題)。令人擔憂的是,“實時”計數幾乎是“配置設定”計數的三分之二。

一個普通的程式配置設定對象,然後釋放它們;如果它長時間持有對象,那就是一個可能的洩漏。但話說回來,一個特定的程式所經曆的“攪動”的數量取決于程式在做什麼。字元數組幾乎總是與字元串相關聯,有些程式隻是在程式的生命周期中保留了大量字元串。例如,基于JSP的應用伺服器為JSP中的每個HTML塊定義字元串。這個特殊的程式确實提供HTML,但是它對字元串的需求并不是那麼明确:它提供目錄清單,而不是大量的靜态文本。如果記憶體不足,我會嘗試找出這些字元串被配置設定到哪裡,以及為什麼沒有丢棄它們。

另一個值得關注的領域是位元組數組(“[B”)。JDK中有很多類使用它們(例如,BufferedInputStream),但在應用程式代碼中很少看到它們。通常它們被用作緩沖區,但緩沖區應該是短期的。在這裡,我們看到有一半以上的位元組數組仍然被認為是活動對象。這是令人擔憂的,它突出了一個簡單直方圖的問題:單個類的所有對象都被分組在一起。對于應用程式對象,這不一定是個問題,因為它們通常配置設定在程式的一個部分中。但是位元組數組是到處配置設定的,而且大多數配置設定都隐藏在庫中。我們應該搜尋調用new byte[]的代碼還是調用new ByteArrayOutputStream()的代碼?

堆轉儲輸出使用類名的“内部形式”。大多數情況下,名稱都是您所期望的,除了JVM内部的數組和類。後者以“<”開頭,前者以“[”開頭。基本數組在括号後面加一個大寫字元(您可以在JavaDoc中找到Class.getName()). 對象數組在這個括号後面有一個大寫的“L”、元件類名和一個分号。多元數組用多個括号表示。

堆轉儲分析:關聯因果關系

要找到洩漏的最終原因,按類計算位元組數可能不夠。相反,您必須将正在洩漏的對象與您的程式正在配置設定的對象相關聯。一種方法是更仔細地檢視執行個體計數,以便将配置設定在一起的對象關聯起來。下面是一個(匿名的)堆轉儲的摘錄,它來自一個記憶體有問題的程式:

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執行個體,每個ArrayList執行個體又配置設定了一個Object[],而後者使用byte[]儲存從網絡檢索到的資料。

第一個問題,配置設定太大的數組,實際上不是洩漏。預設的ArrayList構造函數配置設定一個包含10個元素的數組,而程式隻使用了一個或兩個元素;在64位JVM上,每個執行個體浪費了62個位元組。一個更聰明的類設計将隻在需要時使用一個清單,為每個執行個體節省額外的48位元組。然而,這樣的改變需要付出努力,增加記憶體通常更便宜。

datagram洩漏更麻煩(也更難修複):它表明接收到的資料處理速度不夠快。

為了追蹤這些因果鍊,您需要知道應用程式如何使用對象。沒有多少程式配置設定一個Object[]:如果它們使用數組,它們通常會使用類型化數組。另一方面,ArrayList在内部使用對象數組。但是知道記憶體被ArrayList執行個體占用是不夠的。你需要在鍊的上面移動一步,找到儲存這些清單的對象。

一種方法是查找相關的執行個體計數。在上面的例子中,byte[]與DatagramPacket的關系很明顯:一個幾乎是另一個的兩倍。然而,ArrayList和ItemDetails之間的關系并不是很明顯(事實上,每個ItemDetails執行個體都有幾個清單)。

這種情況下的技巧是将注意力集中在具有高執行個體計數的任何其他類上。我們有一百萬個ArrayList執行個體;它們要麼分布在不同的類中,要麼集中在少數幾個類中。無論如何,一百萬個參考資料是很難隐藏的。即使有十幾個類擁有一個ArrayList,這些類仍然有100000個執行個體。

從柱狀圖中追蹤這樣的鍊是一項艱巨的工作。幸運的是,jmap不僅限于直方圖,它還将生成可浏覽的堆轉儲。

堆轉儲分析:遵循引用鍊

浏覽堆轉儲有兩個步驟:首先,使用-dump選項調用jmap,然後對結果檔案調用jhat。但是,如果您需要走這條路,請確定有足夠的可用記憶體:轉儲檔案很容易就有數百兆位元組,jhat可能需要幾千兆位元組來處理該檔案。

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為您提供了系統中加載的所有類的清單,我很少發現這些類有用。相反,我從http://localhost:7000/histo/,這是一個直方圖視圖,可按執行個體計數或總位元組進行排序(單擊右側的圖像可檢視完整版本)。

這個柱狀圖中的每個類名都是一個連結,可以将您帶到該類的詳細資訊頁面。在這裡,您将看到類在類層次結構中的位置、其成員變量以及對類執行個體的每個引用的連結。我也不覺得這個頁面非常有用,引用清單會增加浏覽器的記憶體使用。

對于跟蹤記憶體問題,最有用的頁面是參考摘要。此頁有兩個表:referers(incoming傳入)和referers(outgoing傳出),這兩個表都是按引用計數預先排序的。單擊一個類名将帶您進入該類的引用摘要。使用上一節中的ArrayList示例,隻需單擊六次,就可以從[Ljava.lang.Object對象至com.example.ItemDetails項目詳細資訊.

您可以從“類詳細資訊”頁通路“引用摘要”頁。但我發現直接建構連結更容易:從直方圖頁面擷取類連結(例如:http://localhost:7000/class/0xa5408348),并将“class”替換為“refsByType”(例如:http://localhost:7000/refsByType/0xa5408228)。

記憶體溢出OutOfMemoryError科普系列一

堆轉儲分析:配置設定站點

大多數時候,知道哪些對象正在消耗記憶體就足以找出它們被洩露的原因。您可以使用jhat查找對這些對象的所有引用,很可能您會看到儲存對象太長的代碼。但有時這還不夠。

例如,如果您的代碼正在洩漏字元串,那麼您可能需要幾天的時間來周遊所有字元串操作代碼。要解決這樣的問題,您需要一個堆轉儲來實際顯示配置設定記憶體的位置。但是,請注意,這種類型的分析為應用程式增加了極大的開銷,因為分析代理必須記錄對新操作符的每次調用。

大多數互動式概要檔案都可以生成這種級别的資料,但我發現最簡單的方法是使用内置的hprof代理啟動JVM:

java -Xrunhprof:heap=sites,depth=2 com.kdgregory.example.memory.Gobbler           

hprof有很多選擇:它不僅可以以各種方式分析記憶體使用情況,還可以跟蹤CPU消耗。您将在下面找到一個Sun技術文章的連結,該文章描述了這些選項。對于這次運作,我指定了一個已配置設定對象及其配置設定位置的事後轉儲,分辨率為兩個堆棧幀。輸出将寫入檔案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           

這個特殊的程式不會配置設定很多不同的對象類型,也不會在許多不同的地方配置設定它們。一個普通的轉儲有數百或數千行長,顯示配置設定了特定對象類型的每個站點。幸運的是,大多數問題出現在轉儲的前幾行。在本例中,跳出的是64MB的活動位元組數組,特别是因為它們平均每個約32k。

大多數程式不需要儲存那麼多資料,是以這表明程式沒有正确地提取和彙總它處理的資料。在讀取大字元串然後保留子字元串的程式中,您經常會看到這種情況:這是一個鮮為人知的實作細節String.substring() 表示它與原始字元串共享一個字元數組。如果您逐行讀取一個檔案,但隻使用每行的前五個字元,則仍将整個檔案儲存在記憶體中。

此轉儲還顯示這些數組的配置設定計數等于活動對象的計數。這是一個典型的洩漏,我們可以通過搜尋“跟蹤”号找到實際代碼:

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)           

在這種情況下,需要增加堆棧跟蹤的深度并重新運作應用程式。這裡有一個折衷方法:當捕獲更多堆棧幀時,會增加分析的開銷。如果未指定深度值,則預設值為4。我發現我的代碼中的大多數問題都可以在深度為2的情況下被發現,盡管我運作的深度高達12(在一台實體記憶體為5千兆位元組的機器上;這是一個颠簸,分析運作花了将近一個小時,但我發現了問題)。

增加堆棧深度的另一個好處是,報告将更加精細:您可能會發現您從兩個或三個地方洩漏對象,所有這些地方都使用公共方法。

堆轉儲分析:位置

分代垃圾收集器之是以能工作,是因為大多數對象在配置設定後不久就會被丢棄。您可以使用相同的原則來查找記憶體洩漏:使用調試器,在配置設定站點設定斷點,然後周遊代碼。在幾乎所有情況下,您都會看到它在配置設定後不久添加到一個長壽命的集合中。

繼續閱讀