深刻解析OutOfMemoryError

在Java中,全部對象都存儲在堆中。他們經過new關鍵字來進行分配,JVM會檢查是否全部線程都沒法在訪問他們了,而且會將他們進行回收。在大多數時候程序員都不會有一絲一毫的察覺,這些工做都被靜悄悄的執行。可是,有時候在發佈前的最後一天,程序掛了。php

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

OutOfMemoryError是一個讓人很鬱悶的異常。它一般說明你幹了寫錯誤的事情:不必的長時間保存一些不必的數據,或者同一時間處理了過多的數據。有些時候,這些問題並不必定受你的控制,好比說一些第三方的庫對一些字符串作了緩存,或者一些應用服務器在部署的時候並無進行清理。而且,對於堆中已經存在的對象,咱們每每拿他們沒辦法。html

這篇文章分析了致使OutOfMemoryError的不一樣緣由,以及你該怎樣應對這種緣由的方法。如下分析僅限於Sun Hotspot虛擬機,可是大多數結論都適用於其餘任何的JVM實現。它們大多數基於網上的文章以及我本身的經驗。我沒有直接作JVM開發的工做,所以結論並不表明JVM的做者。可是我確實曾經遇到過並解決了不少內存相關的問題。java

垃圾回收介紹

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

Java的垃圾回收算法過程意味着若是出現了OOM,那麼說明你在不停的往對象圖中添加對象而且沒有移除它們。這一般是由於你在往一個集合類中添加了不少對象,好比Map,而且這個集合對象是static的。或者,這個集合類被保存在了ThreadLocal對象中,而這個對應的Thread卻又長時間的運行,一直不退出。程序員

這與C和C++的內存泄露徹底不同。在這些語言中,若是一些方法調用了malloc()或者new,而且在方法退出的時候沒有調用相應的free()或者delete,那麼內存就會產生泄露。這些是真正意義上得泄露,你在這個進程範圍內不可能再恢復這些內存,除非使用一些特定的工具來保證每個內存分配方法都有其對應的內存釋放操做相對應。web

在java中,「泄露」這個詞每每被誤用了。由於從JVM的角度來講,全部的內存都是被良好管理的。問題僅僅是做爲程序員的你不知道這些內存是被哪些對象佔用了。可是幸運的是,你仍是有辦法去找到和定位它們。算法

在深刻探討以前,你還有最後一件關於垃圾收集的知識須要瞭解:JVM會盡最大的能力去釋放內存,直到發生OOM。這就意味着OOM不能經過簡單的調用System.gc()來解決,你須要找到這些「泄露」點,並本身處理它們。apache

設置堆大小

學院派的人很是喜歡說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的錯誤。

相關文章
相關標籤/搜索