《深刻理解Java虛擬機》-----第2章 Java內存區域與內存溢出異常

2.1 概述

對於從事C、C++程序開發的開發人員來講,在內存管理領域,他們便是擁有最高權力的皇帝又是執行最基礎工做的勞動人民——擁有每個對象的「全部權」,又擔負着每個對象生命開始到終結的維護責任。java

對於Java程序員來講,不須要在爲每個new操做去寫配對的delete/free,不容易出現內容泄漏和內存溢出錯誤,看起來由JVM管理內存一切都很美好。不過,也正是由於Java程序員把內存控制的權力交給了JVM,一旦出現泄漏和溢出,若是不瞭解JVM是怎樣使用內存的,那排查錯誤將會是一件很是困難的事情。程序員

2.2 運行時數據區域

Java虛擬機在執行Java程序的過程當中會把它所管理的內存劃分爲若干個不一樣的數據區域。這些區域都有各自的用途,以及建立和銷燬的時間,有的區域隨着虛擬機進程的啓動而存在,有些區域則依賴用戶線程的啓動和結束而創建和銷燬。根據《Java虛擬機規範(Java SE 7版)》的規定,Java虛擬機所管理的內存將會包括如下幾個運行時數據區域 
算法

2.2.1 程序計數器

程序計數器(Program Counter Register)是一塊較小的內存空間,它能夠看做是當前線程所執行的字節碼的行號指示器。在虛擬機的概念模型裏(僅是概念模型,各類虛擬機可能會經過一些更高效的方式去實現),字節碼解釋器工做時就是經過改變這個計數器的值來選取下一條須要執行的字節碼指令,分支、循環、跳轉、異常處理、線程恢復等基礎功能都須要依賴這個計數器來完成。編程

因爲Java虛擬機的多線程是經過線程輪流切換並分配處理器執行時間的方式來實現的,在任何一個肯定的時刻,一個處理器(對於多核處理器來講是一個內核)都只會執行一條線程中的指令。所以,爲了線程切換後能恢復到正確的執行位置,每條線程都須要有一個獨立的程序計數器,各條線程之間計數器互不影響,獨立存儲,咱們稱這類內存區域爲「線程私有」的內存。數組

若是線程正在執行的是一個Java方法,這個計數器記錄的是正在執行的虛擬機字節碼指令的地址;若是正在執行的是Native方法,這個計數器值則爲空(Undefined)。此內存區域是惟一一個在Java虛擬機規範中沒有規定任何OutOfMemoryError狀況的區域。安全

2.2.2 Java虛擬機棧

與程序計數器同樣,Java虛擬機棧(Java Virtual Machine Stacks)也是線程私有的,它的生命週期與線程相同。虛擬機棧描述的是Java方法執行的內存模型:每一個方法在執行的同時都會建立一個棧幀(Stack Frame)用於存儲局部變量表、操做數棧、動態連接、方法出口等信息。每個方法從調用直至執行完成的過程,就對應着一個棧幀在虛擬機棧中入棧到出棧的過程。服務器

常常有人把Java內存區分爲堆內存(Heap)和棧內存(Stack),這種分法比較粗糙,Java內存區域的劃分實際上遠比這複雜。這種劃分方式的流行只能說明大多數程序員最關注的、與對象內存分配關係最密切的內存區域是這兩塊。其中所指的「堆」筆者在後面會專門講述,而所指的「棧」就是如今講的虛擬機棧,或者說是虛擬機棧中局部變量表部分。數據結構

局部變量表存放了編譯期可知的各類基本數據類型(boolean、byte、char、short、int、float、long、double)、對象引用(reference類型,它不等同於對象自己,多是一個指向對象起始地址的引用指針,也多是指向一個表明對象的句柄或其餘與此對象相關的位置)和returnAddress類型(指向了一條字節碼指令的地址)。多線程

其中64位長度的long和double類型的數據會佔用2個局部變量空間(Slot),其他的數據類型只佔用1個。局部變量表所需的內存空間在編譯期間完成分配,當進入一個方法時,這個方法須要在幀中分配多大的局部變量空間是徹底肯定的,在方法運行期間不會改變局部變量表的大小。併發

在Java虛擬機規範中,對這個區域規定了兩種異常情況:若是線程請求的棧深度大於虛擬機所容許的深度,將拋出StackOverflowError異常;若是虛擬機棧能夠動態擴展(當前大部分的Java虛擬機均可動態擴展,只不過Java虛擬機規範中也容許固定長度的虛擬機棧),若是擴展時沒法申請到足夠的內存,就會拋出OutOfMemoryError異常。

2.2.3 本地方法棧

本地方法棧(Native Method Stack)與虛擬機棧所發揮的做用是很是類似的,它們之間的區別不過是虛擬機棧爲虛擬機執行Java方法(也就是字節碼)服務,而本地方法棧則爲虛擬機使用到的Native方法服務。在虛擬機規範中對本地方法棧中方法使用的語言、使用方式與數據結構並無強制規定,所以具體的虛擬機能夠自由實現它。甚至有的虛擬機(譬如Sun HotSpot虛擬機)直接就把本地方法棧和虛擬機棧合二爲一。與虛擬機棧同樣,本地方法棧區域也會拋出StackOverflowError和OutOfMemoryError異常。

2.2.4 Java堆

對於大多數應用來講,Java堆(Java Heap)是Java虛擬機所管理的內存中最大的一塊。Java堆是被全部線程共享的一塊內存區域,在虛擬機啓動時建立。此內存區域的惟一目的就是存放對象實例,幾乎全部的對象實例都在這裏分配內存。這一點在Java虛擬機規範中的描述是:全部的對象實例以及數組都要在堆上分配,可是隨着JIT編譯器的發展與逃逸分析技術逐漸成熟,棧上分配、標量替換優化技術將會致使一些微妙的變化發生,全部的對象都分配在堆上也漸漸變得不是那麼「絕對」了。

Java堆是垃圾收集器管理的主要區域,所以不少時候也被稱作「GC堆」(Garbage Collected Heap,幸虧國內沒翻譯成「垃圾堆」)。從內存回收的角度來看,因爲如今收集器基本都採用分代收集算法,因此Java堆中還能夠細分爲:新生代和老年代;再細緻一點的有Eden空間、From Survivor空間、To Survivor空間等。從內存分配的角度來看,線程共享的Java堆中可能劃分出多個線程私有的分配緩衝區(Thread Local Allocation Buffer,TLAB)。不過不管如何劃分,都與存放內容無關,不管哪一個區域,存儲的都仍然是對象實例,進一步劃分的目的是爲了更好地回收內存,或者更快地分配內存。在本章中,咱們僅僅針對內存區域的做用進行討論,Java堆中的上述各個區域的分配、回收等細節將是第3章的主題。

根據Java虛擬機規範的規定,Java堆能夠處於物理上不連續的內存空間中,只要邏輯上是連續的便可,就像咱們的磁盤空間同樣。在實現時,既能夠實現成固定大小的,也能夠是可擴展的,不過當前主流的虛擬機都是按照可擴展來實現的(經過-Xmx和-Xms控制)。若是在堆中沒有內存完成實例分配,而且堆也沒法再擴展時,將會拋出OutOfMemoryError異常。

2.2.5 方法區

方法區(Method Area)與Java堆同樣,是各個線程共享的內存區域,它用於存儲已被虛擬機加載的類信息、常量、靜態變量、方法、即時編譯器編譯後的代碼等數據。雖然Java虛擬機規範把方法區描述爲堆的一個邏輯部分,可是它卻有一個別名叫作Non-Heap(非堆),目的應該是與Java堆區分開來。

對於習慣在HotSpot虛擬機上開發、部署程序的開發者來講,不少人都更願意把方法區稱爲「永久代」(Permanent Generation),本質上二者並不等價,僅僅是由於HotSpot虛擬機的設計團隊選擇把GC分代收集擴展至方法區,或者說使用永久代來實現方法區而已,這樣HotSpot的垃圾收集器能夠像管理Java堆同樣管理這部份內存,可以省去專門爲方法區編寫內存管理代碼的工做。對於其餘虛擬機(如BEA JRockit、IBM J9等)來講是不存在永久代的概念的。原則上,如何實現方法區屬於虛擬機實現細節,不受虛擬機規範約束,但使用永久代來實現方法區,如今看來並非一個好主意,由於這樣更容易遇到內存溢出問題(永久代有-XX:MaxPermSize的上限,J9和JRockit只要沒有觸碰到進程可用內存的上限,例如32位系統中的4GB,就不會出現問題),並且有極少數方法(例如String.intern())會因這個緣由致使不一樣虛擬機下有不一樣的表現。所以,對於HotSpot虛擬機,根據官方發佈的路線圖信息,如今也有放棄永久代並逐步改成採用Native Memory來實現方法區的規劃了,在目前已經發布的JDK 1.7的HotSpot中,已經把本來放在永久代的字符串常量池移出。

Java虛擬機規範對方法區的限制很是寬鬆,除了和Java堆同樣不須要連續的內存和能夠選擇固定大小或者可擴展外,還能夠選擇不實現垃圾收集。相對而言,垃圾收集行爲在這個區域是比較少出現的,但並不是數據進入了方法區就如永久代的名字同樣「永久」存在了。這區域的內存回收目標主要是針對常量池的回收和對類型的卸載,通常來講,這個區域的回收「成績」比較難以使人滿意,尤爲是類型的卸載,條件至關苛刻,可是這部分區域的回收確實是必要的。在Sun公司的BUG列表中,曾出現過的若干個嚴重的BUG就是因爲低版本的HotSpot虛擬機對此區域未徹底回收而致使內存泄漏。根據Java虛擬機規範的規定,當方法區沒法知足內存分配需求時,將拋出OutOfMemoryError異常。

2.2.6 運行時常量池

運行時常量池(Runtime Constant Pool)是方法區的一部分。Class文件中除了有類的版本、字段、方法、接口等描述信息外,還有一項信息是常量池(Constant Pool Table),用於存放編譯期生成的各類字面量和符號引用,這部份內容將在類加載後進入方法區的運行時常量池中存放。

Java虛擬機對Class文件每一部分(天然也包括常量池)的格式都有嚴格規定,每個字節用於存儲哪一種數據都必須符合規範上的要求才會被虛擬機承認、裝載和執行,但對於運行時常量池,Java虛擬機規範沒有作任何細節的要求,不一樣的提供商實現的虛擬機能夠按照本身的須要來實現這個內存區域。不過,通常來講,除了保存Class文件中描述的符號引用外,還會把翻譯出來的直接引用也存儲在運行時常量池中。

運行時常量池相對於Class文件常量池的另一個重要特徵是具有動態性,Java語言並不要求常量必定只有編譯期才能產生,也就是並不是預置入Class文件中常量池的內容才能進入方法區運行時常量池,運行期間也可能將新的常量放入池中,這種特性被開發人員利用得比較多的即是String類的intern()方法。

既然運行時常量池是方法區的一部分,天然受到方法區內存的限制,當常量池沒法再申請到內存時會拋出OutOfMemoryError異常。

2.2.7 直接內存

直接內存(Direct Memory)並非虛擬機運行時數據區的一部分,也不是Java虛擬機規範中定義的內存區域。可是這部份內存也被頻繁地使用,並且也可能致使OutOfMemoryError異常出現,因此咱們放到這裏一塊兒講解。

在JDK 1.4中新加入了NIO(New Input/Output)類,引入了一種基於通道(Channel)與緩衝區(Buffer)的I/O方式,它可使用Native函數庫直接分配堆外內存,而後經過一個存儲在Java堆中的DirectByteBuffer對象做爲這塊內存的引用進行操做。這樣能在一些場景中顯著提升性能,由於避免了在Java堆和Native堆中來回複製數據。

顯然,本機直接內存的分配不會受到Java堆大小的限制,可是,既然是內存,確定仍是會受到本機總內存(包括RAM以及SWAP區或者分頁文件)大小以及處理器尋址空間的限制。服務器管理員在配置虛擬機參數時,會根據實際內存設置-Xmx等參數信息,但常常忽略直接內存,使得各個內存區域總和大於物理內存限制(包括物理的和操做系統級的限制),從而致使動態擴展時出現OutOfMemoryError異常。

2.3 HotSpot虛擬機對象探祕

介紹完Java虛擬機的運行時數據區以後,咱們大體知道了虛擬機內存的概況,讀者瞭解了內存中放了些什麼後,也許就會想更進一步瞭解這些虛擬機內存中的數據的其餘細節,譬如它們是如何建立、如何佈局以及如何訪問的。對於這樣涉及細節的問題,必須把討論範圍限定在具體的虛擬機和集中在某一個內存區域上纔有意義。基於實用優先的原則,筆者以經常使用的虛擬機HotSpot和經常使用的內存區域Java堆爲例,深刻探討HotSpot虛擬機在Java堆中對象分配、佈局和訪問的全過程。

2.3.1 對象的建立

Java是一門面向對象的編程語言,在Java程序運行過程當中無時無刻都有對象被建立出來。在語言層面上,建立對象(例如克隆、反序列化)一般僅僅是一個new關鍵字而已,而在虛擬機中,對象(文中討論的對象限於普通Java對象,不包括數組和Class對象等)的建立又是怎樣一個過程呢?

虛擬機遇到一條new指令時,首先將去檢查這個指令的參數是否能在常量池中定位到一個類的符號引用,而且檢查這個符號引用表明的類是否已被加載、解析和初始化過。若是沒有,那必須先執行相應的類加載過程,本書第7章將探討這部份內容的細節。

在類加載檢查經過後,接下來虛擬機將爲新生對象分配內存。對象所需內存的大小在類加載完成後即可徹底肯定(如何肯定將在2.3.2節中介紹),爲對象分配空間的任務等同於把一塊肯定大小的內存從Java堆中劃分出來。假設Java堆中內存是絕對規整的,全部用過的內存都放在一邊,空閒的內存放在另外一邊,中間放着一個指針做爲分界點的指示器,那所分配內存就僅僅是把那個指針向空閒空間那邊挪動一段與對象大小相等的距離,這種分配方式稱爲「指針碰撞」(Bump the Pointer)。若是Java堆中的內存並非規整的,已使用的內存和空閒的內存相互交錯,那就沒有辦法簡單地進行指針碰撞了,虛擬機就必須維護一個列表,記錄上哪些內存塊是可用的,在分配的時候從列表中找到一塊足夠大的空間劃分給對象實例,並更新列表上的記錄,這種分配方式稱爲「空閒列表」(Free List)。選擇哪一種分配方式由Java堆是否規整決定,而Java堆是否規整又由所採用的垃圾收集器是否帶有壓縮整理功能決定。所以,在使用Serial、ParNew等帶Compact過程的收集器時,系統採用的分配算法是指針碰撞,而使用CMS這種基於Mark-Sweep算法的收集器時,一般採用空閒列表。

除如何劃分可用空間以外,還有另一個須要考慮的問題是對象建立在虛擬機中是很是頻繁的行爲,即便是僅僅修改一個指針所指向的位置,在併發狀況下也並非線程安全的,可能出現正在給對象A分配內存,指針還沒來得及修改,對象B又同時使用了原來的指針來分配內存的狀況。解決這個問題有兩種方案,一種是對分配內存空間的動做進行同步處理——實際上虛擬機採用CAS配上失敗重試的方式保證更新操做的原子性;另外一種是把內存分配的動做按照線程劃分在不一樣的空間之中進行,即每一個線程在Java堆中預先分配一小塊內存,稱爲本地線程分配緩衝(Thread Local Allocation Buffer,TLAB)。哪一個線程要分配內存,就在哪一個線程的TLAB上分配,只有TLAB用完並分配新的TLAB時,才須要同步鎖定。虛擬機是否使用TLAB,能夠經過-XX:+/-UseTLAB參數來設定。

內存分配完成後,虛擬機須要將分配到的內存空間都初始化爲零值(不包括對象頭),若是使用TLAB,這一工做過程也能夠提早至TLAB分配時進行。這一步操做保證了對象的實例字段在Java代碼中能夠不賦初始值就直接使用,程序能訪問到這些字段的數據類型所對應的零值。

接下來,虛擬機要對對象進行必要的設置,例如這個對象是哪一個類的實例、如何才能找到類的元數據信息、對象的哈希碼、對象的GC分代年齡等信息。這些信息存放在對象的對象頭(Object Header)之中。根據虛擬機當前的運行狀態的不一樣,如是否啓用偏向鎖等,對象頭會有不一樣的設置方式。關於對象頭的具體內容,稍後再作詳細介紹。

在上面工做都完成以後,從虛擬機的視角來看,一個新的對象已經產生了,但從Java程序的視角來看,對象建立纔剛剛開始——<init>方法尚未執行,全部的字段都還爲零。因此,通常來講(由字節碼中是否跟隨invokespecial指令所決定),執行new指令以後會接着執行<init>方法,把對象按照程序員的意願進行初始化,這樣一個真正可用的對象纔算徹底產生出來。

下面的代碼清單是HotSpot虛擬機bytecodeInterpreter.cpp中的代碼片斷(這個解釋器實現不多有機會實際使用,由於大部分平臺上都使用模板解釋器;當代碼經過JIT編譯器執行時差別就更大了。不過,這段代碼用於瞭解HotSpot的運做過程是沒有什麼問題的)。

//確保常量池中存放的是已解釋的類
if(!constants->tag_at(index).is_unresolved_klass()){
//斷言確保是klassOop和instanceKlassOop(這部分下一節介紹)
oop entry=(klassOop)*constants->obj_at_addr(index);
assert(entry->is_klass(),"Should be resolved klass");
klassOop k_entry=(klassOop)entry;
assert(k_entry->klass_part()->oop_is_instance(),"Should be instanceKlass");
instanceKlass * ik=(instanceKlass*)k_entry->klass_part();
//確保對象所屬類型已經通過初始化階段
if(ik->is_initialized()&&ik->can_be_fastpath_allocated())
{
//取對象長度
size_t obj_size=ik->size_helper();
oop result=NULL;
//記錄是否須要將對象全部字段置零值
bool need_zero=!ZeroTLAB;
//是否在TLAB中分配對象
if(UseTLAB){
result=(oop)THREAD->tlab().allocate(obj_size);
}
if(result==NULL){
need_zero=true//直接在eden中分配對象
retry:
HeapWord * compare_to=*Universe:heap()->top_addr();
HeapWord * new_top=compare_to+obj_size;
/*cmpxchg是x86中的CAS指令,這裏是一個C++方法,經過CAS方式分配空間,若是併發失敗,
轉到retry中重試,直至成功分配爲止*/
if(new_top<=*Universe:heap()->end_addr()){
if(Atomic:cmpxchg_ptr(new_top,Universe:heap()->top_addr(),compare_to)!=compare_to){
goto retry;
}
result=(oop)compare_to;
}
}
if(result!=NULL){
//若是須要,則爲對象初始化零值
if(need_zero){
HeapWord * to_zero=(HeapWord*)result+sizeof(oopDesc)/oopSize;
obj_size-=sizeof(oopDesc)/oopSize;
if(obj_size>0){
memset(to_zero,0,obj_size * HeapWordSize);
}
}
//根據是否啓用偏向鎖來設置對象頭信息
if(UseBiasedLocking){
result->set_mark(ik->prototype_header());
}else{
result->set_mark(markOopDesc:prototype());
}r
esult->set_klass_gap(0);
result->set_klass(k_entry);
//將對象引用入棧,繼續執行下一條指令
SET_STACK_OBJECT(result,0);
UPDATE_PC_AND_TOS_AND_CONTINUE(3,1);
}
}
}

2.3.2 對象的內存佈局

在HotSpot虛擬機中,對象在內存中存儲的佈局能夠分爲3塊區域:對象頭(Header)、實例數據(Instance Data)和對齊填充(Padding)。

HotSpot虛擬機的對象頭包括兩部分信息,第一部分用於存儲對象自身的運行時數據,如哈希碼(HashCode)、GC分代年齡、鎖狀態標誌、線程持有的鎖、偏向線程ID、偏向時間戳等,這部分數據的長度在32位和64位的虛擬機(未開啓壓縮指針)中分別爲32bit和64bit,官方稱它爲「Mark Word」。對象須要存儲的運行時數據不少,其實已經超出了32位、64位Bitmap結構所能記錄的限度,可是對象頭信息是與對象自身定義的數據無關的額外存儲成本,考慮到虛擬機的空間效率,Mark Word被設計成一個非固定的數據結構以便在極小的空間內存儲儘可能多的信息,它會根據對象的狀態複用本身的存儲空間。例如,在32位的HotSpot虛擬機中,若是對象處於未被鎖定的狀態下,那麼Mark Word的32bit空間中的25bit用於存儲對象哈希碼,4bit用於存儲對象分代年齡,2bit用於存儲鎖標誌位,1bit固定爲0,而在其餘狀態(輕量級鎖定、重量級鎖定、GC標記、可偏向)下對象的存儲內容見表 
這裏寫圖片描述

對象頭的另一部分是類型指針,即對象指向它的類元數據的指針,虛擬機經過這個指針來肯定這個對象是哪一個類的實例。並非全部的虛擬機實現都必須在對象數據上保留類型指針,換句話說,查找對象的元數據信息並不必定要通過對象自己,這點將在2.3.3節討論。

另外,若是對象是一個Java數組,那在對象頭中還必須有一塊用於記錄數組長度的數據,由於虛擬機能夠經過普通Java對象的元數據信息肯定Java對象的大小,可是從數組的元數據中卻沒法肯定數組的大小。 
代碼清單爲HotSpot虛擬機markOop.cpp中的代碼(註釋)片斷,它描述了32bit下MarkWord的存儲狀態。

//Bit-format of an object header(most significant first,big endian layout below):
//32 bits:
//--------
//hash:25------------>|age:4 biased_lock:1 lock:2(normal object)
//JavaThread*:23 epoch:2 age:4 biased_lock:1 lock:2(biased object)
//size:32------------------------------------------>|(CMS free block)
//PromotedObject*:29---------->|promo_bits:3----->|(CMS promoted object)

接下來的實例數據部分是對象真正存儲的有效信息,也是在程序代碼中所定義的各類類型的字段內容。不管是從父類繼承下來的,仍是在子類中定義的,都須要記錄起來。這部分的存儲順序會受到虛擬機分配策略參數(FieldsAllocationStyle)和字段在Java源碼中定義順序的影響。HotSpot虛擬機默認的分配策略爲longs/doubles、ints、shorts/chars、bytes/booleans、oops(Ordinary Object Pointers),從分配策略中能夠看出,相同寬度的字段老是被分配到一塊兒。在知足這個前提條件的狀況下,在父類中定義的變量會出如今子類以前。若是CompactFields參數值爲true(默認爲true),那麼子類之中較窄的變量也可能會插入到父類變量的空隙之中。

第三部分對齊填充並非必然存在的,也沒有特別的含義,它僅僅起着佔位符的做用。因爲HotSpot VM的自動內存管理系統要求對象起始地址必須是8字節的整數倍,換句話說,就是對象的大小必須是8字節的整數倍。而對象頭部分正好是8字節的倍數(1倍或者2倍),所以,當對象實例數據部分沒有對齊時,就須要經過對齊填充來補全。

2.3.3 對象的訪問定位

創建對象是爲了使用對象,咱們的Java程序須要經過棧上的reference數據來操做堆上的具體對象。因爲reference類型在Java虛擬機規範中只規定了一個指向對象的引用,並無定義這個引用應該經過何種方式去定位、訪問堆中的對象的具體位置,因此對象訪問方式也是取決於虛擬機實現而定的。目前主流的訪問方式有使用句柄直接指針兩種。

若是使用句柄訪問的話,那麼Java堆中將會劃分出一塊內存來做爲句柄池,reference中存儲的就是對象的句柄地址,而句柄中包含了對象實例數據與類型數據各自的具體地址信息,如圖。 
這裏寫圖片描述

若是使用直接指針訪問,那麼Java堆對象的佈局中就必須考慮如何放置訪問類型數據的相關信息,而reference中存儲的直接就是對象地址,如圖所示。 
這裏寫圖片描述

這兩種對象訪問方式各有優點,使用句柄來訪問的最大好處就是reference中存儲的是穩定的句柄地址,在對象被移動(垃圾收集時移動對象是很是廣泛的行爲)時只會改變句柄中的實例數據指針,而reference自己不須要修改。

使用直接指針訪問方式的最大好處就是速度更快,它節省了一次指針定位的時間開銷,因爲對象的訪問在Java中很是頻繁,所以這類開銷聚沙成塔後也是一項很是可觀的執行成本。就本書討論的主要虛擬機Sun HotSpot而言,它是使用第二種方式進行對象訪問的,但從整個軟件開發的範圍來看,各類語言和框架使用句柄來訪問的狀況也十分常見。

2.4 實戰:OutOfMemoryError異常

在Java虛擬機規範的描述中,除了程序計數器外,虛擬機內存的其餘幾個運行時區域都有發生OutOfMemoryError(下文稱OOM)異常的可能,本節將經過若干實例來驗證異常發生的場景(代碼清單2-3~代碼清單2-9的幾段簡單代碼),而且會初步介紹幾個與內存相關的 
最基本的虛擬機參數。

本節內容的目的有兩個:第一,經過代碼驗證Java虛擬機規範中描述的各個運行時區域存儲的內容;第二,但願讀者在工做中遇到實際的內存溢出異常時,能根據異常的信息快速判斷是哪一個區域的內存溢出,知道什麼樣的代碼可能會致使這些區域內存溢出,以及出現這些異常後該如何處理。

下文代碼的開頭都註釋了執行時所須要設置的虛擬機啓動參數(註釋中「VM Args」後面跟着的參數),這些參數對實驗的結果有直接影響,讀者調試代碼的時候千萬不要忽略。若是讀者使用控制檯命令來執行程序,那直接跟在Java命令以後書寫就能夠。若是讀者使用Eclipse IDE,則能夠參考圖在Debug/Run頁籤中的設置。 
這裏寫圖片描述

下文的代碼都是基於Sun公司的HotSpot虛擬機運行的,對於不一樣公司的不一樣版本的虛擬機,參數和程序運行的結果可能會有所差異。

2.4.1 Java堆溢出

Java堆用於存儲對象實例,只要不斷地建立對象,而且保證GC Roots到對象之間有可達路徑來避免垃圾回收機制清除這些對象,那麼在對象數量到達最大堆的容量限制後就會產生內存溢出異常。

代碼清單2-3中代碼限制Java堆的大小爲20MB,不可擴展(將堆的最小值-Xms參數與最大值-Xmx參數設置爲同樣便可避免堆自動擴展),經過參數-XX:+HeapDumpOnOutOfMemoryError可讓虛擬機在出現內存溢出異常時Dump出當前的內存堆轉儲快照以便過後進行分析。

代碼清單2-3 Java堆內存溢出異常測試

/**
 * VM Args:-Xms20m -Xmx20m -XX:+HeapDumpOnOutOfMemoryError
 * @author zzm
 */
public class HeapOOM {

    static class OOMObject {
    }

    public static void main(String[] args) {
        List<OOMObject> list = new ArrayList<OOMObject>();

        while (true) {
            list.add(new OOMObject());
        }
    }
}

運行結果:

java.lang.OutOfMemoryError :Java heap space
Dumping heap to java_pid3404.hprof.
Heap dump file created[22045981 bytes in 0.663 secs]

Java堆內存的OOM異常是實際應用中常見的內存溢出異常狀況。當出現Java堆內存溢出時,異常堆棧信息「java.lang.OutOfMemoryError」會跟着進一步提示「Java heap space」。

要解決這個區域的異常,通常的手段是先經過內存映像分析工具(如Eclipse Memory Analyzer)對Dump出來的堆轉儲快照進行分析,重點是確認內存中的對象是不是必要的,也就是要先分清楚究竟是出現了內存泄漏(Memory Leak)仍是內存溢出(Memory Overflow)。下圖顯示了使用Eclipse Memory Analyzer打開的堆轉儲快照文件。 
這裏寫圖片描述

若是是內存泄露,可進一步經過工具查看泄露對象到GC Roots的引用鏈。因而就能找到泄露對象是經過怎樣的路徑與GC Roots相關聯並致使垃圾收集器沒法自動回收它們的。掌握了泄露對象的類型信息及GC Roots引用鏈的信息,就能夠比較準確地定位出泄露代碼的位置。

若是不存在泄露,換句話說,就是內存中的對象確實都還必須存活着,那就應當檢查虛擬機的堆參數(-Xmx與-Xms),與機器物理內存對比看是否還能夠調大,從代碼上檢查是否存在某些對象生命週期過長、持有狀態時間過長的狀況,嘗試減小程序運行期的內存消耗。

以上是處理Java堆內存問題的簡單思路,處理這些問題所須要的知識、工具與經驗是後面3章的主題。

2.4.2 虛擬機棧和本地方法棧溢出

因爲在HotSpot虛擬機中並不區分虛擬機棧和本地方法棧,所以,對於HotSpot來講,雖然-Xoss參數(設置本地方法棧大小)存在,但其實是無效的,棧容量只由-Xss參數設定。

關於虛擬機棧和本地方法棧,在Java虛擬機規範中描述了兩種異常:

  • 若是線程請求的棧深度大於虛擬機所容許的最大深度,將拋出StackOverflowError異常。
  • 若是虛擬機在擴展棧時沒法申請到足夠的內存空間,則拋出OutOfMemoryError異常。

這裏把異常分紅兩種狀況,看似更加嚴謹,但卻存在着一些互相重疊的地方:當棧空間沒法繼續分配時,究竟是內存過小,仍是已使用的棧空間太大,其本質上只是對同一件事情的兩種描述而已。

在筆者的實驗中,將實驗範圍限制於單線程中的操做,嘗試了下面兩種方法均沒法讓虛擬機產生OutOfMemoryError異常,嘗試的結果都是得到StackOverflowError異常,測試代碼如代碼清單2-4所示。

  • 使用-Xss參數減小棧內存容量。結果:拋出StackOverflowError異常,異常出現時輸出的堆棧深度相應縮小。
  • 定義了大量的本地變量,增大此方法幀中本地變量表的長度。結果:拋出StackOverflowError異常時輸出的堆棧深度相應縮小。

代碼清單2-4 虛擬機棧和本地方法棧OOM測試(僅做爲第1點測試程序)

/**
 * VM Args:-Xss128k
 * @author zzm
 */
public class JavaVMStackSOF {

    private int stackLength = 1;

    public void stackLeak() {
        stackLength++;
        stackLeak();
    }

    public static void main(String[] args) throws Throwable {
        JavaVMStackSOF oom = new JavaVMStackSOF();
        try {
            oom.stackLeak();
        } catch (Throwable e) {
            System.out.println("stack length:" + oom.stackLength);
            throw e;
        }
    }
}

運行結果:

stack length :2402
Exception in thread"main"java.lang.StackOverflowError
at org.fenixsoft.oom.VMStackSOF.leak (WIStackSOF.java :20 ) at org.fenixsoft.oom.VMStackSOF.leak (WIStackSOF.java :21 ) at org.fenixsoft.oom.VMStackSOF.leak (WIStackSOF.iava :21 ) 
.....後續異常堆棧信息省略

實驗結果代表:在單個線程下,不管是因爲棧幀太大仍是虛擬機棧容量過小,當內存沒法分配的時候,虛擬機拋出的都是StackOverflowError異常。

若是測試時不限於單線程,經過不斷地創建線程的方式卻是能夠產生內存溢出異常,如代碼清單2-5所示。可是這樣產生的內存溢出異常與棧空間是否足夠大並不存在任何聯繫,或者準確地說,在這種狀況下,爲每一個線程的棧分配的內存越大,反而越容易產生內存溢出異常。

其實緣由不難理解,操做系統分配給每一個進程的內存是有限制的,譬如32位的Windows限制爲2GB。虛擬機提供了參數來控制Java堆和方法區的這兩部份內存的最大值。剩餘的內存爲2GB(操做系統限制)減去Xmx(最大堆容量),再減去MaxPermSize(最大方法區容量),程序計數器消耗內存很小,能夠忽略掉。若是虛擬機進程自己耗費的內存不計算在內,剩下的內存就由虛擬機棧和本地方法棧「瓜分」了。每一個線程分配到的棧容量越大,能夠 
創建的線程數量天然就越少,創建線程時就越容易把剩下的內存耗盡。

這一點讀者須要在開發多線程的應用時特別注意,出現StackOverflowError異常時有錯誤堆棧能夠閱讀,相對來講,比較容易找到問題的所在。並且,若是使用虛擬機默認參數,棧深度在大多數狀況下(由於每一個方法壓入棧的幀大小並非同樣的,因此只能說在大多數狀況下)達到1000~2000徹底沒有問題,對於正常的方法調用(包括遞歸),這個深度應該徹底夠用了。可是,若是是創建過多線程致使的內存溢出,在不能減小線程數或者更換64位虛擬機的狀況下,就只能經過減小最大堆和減小棧容量來換取更多的線程。若是沒有這方面的處理經驗,這種經過「減小內存」的手段來解決內存溢出的方式會比較難以想到。 
代碼清單2-5 建立線程致使內存溢出異常

/**
 * VM Args:-Xss2M (這時候不妨設大些)
 * @author zzm
 */
public class JavaVMStackOOM {

       private void dontStop() {
              while (true) {
              }
       }

       public void stackLeakByThread() {
              while (true) {
                     Thread thread = new Thread(new Runnable() {
                            @Override
                            public void run() {
                                   dontStop();
                            }
                     });
                     thread.start();
              }
       }

       public static void main(String[] args) throws Throwable {
              JavaVMStackOOM oom = new JavaVMStackOOM();
              oom.stackLeakByThread();
       }
}

注意,特別提示一下,若是讀者要嘗試運行上面這段代碼,記得要先保存當前的工做。因爲在Windows平臺的虛擬機中,Java的線程是映射到操做系統的內核線程上的,所以上述代碼執行時有較大的風險,可能會致使操做系統假死。 
運行結果:

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

2.4.3 方法區和運行時常量池溢出

因爲運行時常量池是方法區的一部分,所以這兩個區域的溢出測試就放在一塊兒進行。前面提到JDK 1.7開始逐步「去永久代」的事情,在此就以測試代碼觀察一下這件事對程序的實際影響。

String.intern()是一個Native方法,它的做用是:若是字符串常量池中已經包含一個等於此String對象的字符串,則返回表明池中這個字符串的String對象;不然,將此String對象包含的字符串添加到常量池中,而且返回此String對象的引用。在JDK 1.6及以前的版本中,因爲常量池分配在永久代內,咱們能夠經過-XX:PermSize和-XX:MaxPermSize限制方法區大小,從而間接限制其中常量池的容量,如代碼清單2-6所示。

代碼清單2-6 運行時常量池致使的內存溢出異常

/**
 * VM Args:-XX:PermSize=10M -XX:MaxPermSize=10M
 * @author zzm
 */
public class RuntimeConstantPoolOOM {

    public static void main(String[] args) {
        // 使用List保持着常量池引用,避免Full GC回收常量池行爲
        List<String> list = new ArrayList<String>();
        // 10MB的PermSize在integer範圍內足夠產生OOM了
        int i = 0; 
        while (true) {
            list.add(String.valueOf(i++).intern());
        }
    }
}

運行結果:

Exception in thread"main"java.lang.OutOfMemoryError :PermGen space
at java.lang.String, intern (Native Method )
at org.fenixsoft.oom.RuntimeConstantPoolOOM.main(RuntimeConstantPoolOOM.java:18)

從運行結果中能夠看到,運行時常量池溢出,在OutOfMemoryError後面跟隨的提示信息是「PermGen space」,說明運行時常量池屬於方法區(HotSpot虛擬機中的永久代)的一部分。

而使用JDK 1.7運行這段程序就不會獲得相同的結果,while循環將一直進行下去。關於這個字符串常量池的實現問題,還能夠引伸出一個更有意思的影響,如代碼清單2-7所示。

代碼清單2-7 String.intern()返回引用的測試

public class RuntimeConstantPoolOOM {

    public static void main(String[] args) {
        public static void main(String[] args) {
        String str1 = new StringBuilder("中國").append("釣魚島").toString();
        System.out.println(str1.intern() == str1);

        String str2 = new StringBuilder("ja").append("va").toString();
        System.out.println(str2.intern() == str2);
    }   }
}

這段代碼在JDK 1.6中運行,會獲得兩個false,而在JDK 1.7中運行,會獲得一個true和一個false。產生差別的緣由是:在JDK 1.6中,intern()方法會把首次遇到的字符串實例複製到永久代中,返回的也是永久代中這個字符串實例的引用,而由StringBuilder建立的字符串實例在Java堆上,因此必然不是同一個引用,將返回false。而JDK 1.7(以及部分其餘虛擬機,例如JRockit)的intern()實現不會再複製實例,只是在常量池中記錄首次出現的實例引用,所以intern()返回的引用和由StringBuilder建立的那個字符串實例是同一個。對str2比較返回false是由於「java」這個字符串在執行StringBuilder.toString()以前已經出現過,字符串常量池中已經有它的引用了,不符合「首次出現」的原則,而「計算機軟件」這個字符串則是首次出現的,所以返回true。

方法區用於存放Class的相關信息,如類名、訪問修飾符、常量池、字段描述、方法描述等。對於這些區域的測試,基本的思路是運行時產生大量的類去填滿方法區,直到溢出。雖然直接使用Java SE API也能夠動態產生類(如反射時的GeneratedConstructorAccessor和動態代理等),但在本次實驗中操做起來比較麻煩。在代碼清單2-8中,筆者藉助CGLib直接操做字節碼運行時生成了大量的動態類。

值得特別注意的是,咱們在這個例子中模擬的場景並不是純粹是一個實驗,這樣的應用常常會出如今實際應用中:當前的不少主流框架,如Spring、Hibernate,在對類進行加強時,都會使用到CGLib這類字節碼技術,加強的類越多,就須要越大的方法區來保證動態生成的Class能夠加載入內存。另外,JVM上的動態語言(例如Groovy等)一般都會持續建立類來實現語言的動態性,隨着這類語言的流行,也愈來愈容易遇到與代碼清單2-8類似的溢出場景。

代碼清單2-8 藉助CGLib使方法區出現內存溢出異常

/**
 * VM Args: -XX:PermSize=10M -XX:MaxPermSize=10M
 * @author zzm
 */
public class JavaMethodAreaOOM {

    public static void main(String[] args) {
        while (true) {
            Enhancer enhancer = new Enhancer();
            enhancer.setSuperclass(OOMObject.class);
            enhancer.setUseCache(false);
            enhancer.setCallback(new MethodInterceptor() {
                public Object intercept(Object obj, Method method, Object[] args, MethodProxy proxy) throws Throwable {
                    return proxy.invokeSuper(obj, args);
                }
            });
            enhancer.create();
        }
    }

    static class OOMObject {

    }
}

運行結果:

Caused by :java.lang.OutOfMemoryError :PermGen space
at java.lang.ClassLoader.defineClassl (Native Method)
at java.lang.ClassLoader.defineClassCond (ClassLoader. java :632 ) at java.lang.ClassLoader.defineClass (ClassLoader.java :616 )
— 8 more

方法區溢出也是一種常見的內存溢出異常,一個類要被垃圾收集器回收掉,斷定條件是比較苛刻的。在常常動態生成大量Class的應用中,須要特別注意類的回收情況。這類場景除了上面提到的程序使用了CGLib字節碼加強和動態語言以外,常見的還有:大量JSP或動態產生JSP文件的應用(JSP第一次運行時須要編譯爲Java類)、基於OSGi的應用(即便是同一個類文件,被不一樣的加載器加載也會視爲不一樣的類)等。

2.4.4 本機直接內存溢出

DirectMemory容量可經過-XX:MaxDirectMemorySize指定,若是不指定,則默認與Java堆最大值(-Xmx指定)同樣,代碼清單2-9越過了DirectByteBuffer類,直接經過反射獲取Unsafe實例進行內存分配(Unsafe類的getUnsafe()方法限制了只有引導類加載器纔會返回實例,也就是設計者但願只有rt.jar中的類才能使用Unsafe的功能)。由於,雖然使用DirectByteBuffer分配內存也會拋出內存溢出異常,但它拋出異常時並無真正向操做系統申請分配內存,而是經過計算得知內存沒法分配,因而手動拋出異常,真正申請分配內存的方法是unsafe.allocateMemory()。

代碼清單2-9 使用unsafe分配本機內存

/**
 * VM Args:-Xmx20M -XX:MaxDirectMemorySize=10M
 * @author zzm
 */
public class DirectMemoryOOM {

    private static final int _1MB = 1024 * 1024;

    public static void main(String[] args) throws Exception {
        Field unsafeField = Unsafe.class.getDeclaredFields()[0];
        unsafeField.setAccessible(true);
        Unsafe unsafe = (Unsafe) unsafeField.get(null);
        while (true) {
            unsafe.allocateMemory(_1MB);
        }
    }
}

運行結果:

Exception in thread"main"java.lang.OutOfMemoryError at sun.misc.Unsafe .allocateMemory (Native Method ) at org. fenixsoft. oom.DMOOM.main (DMOOM.java :20 )

由DirectMemory致使的內存溢出,一個明顯的特徵是在Heap Dump文件中不會看見明顯的異常,若是讀者發現OOM以後Dump文件很小,而程序中又直接或間接使用了NIO,那就能夠考慮檢查一下是否是這方面的緣由。

2.5 本章小結

經過本章的學習,咱們明白了虛擬機中的內存是如何劃分的,哪部分區域、什麼樣的代碼和操做可能致使內存溢出異常。雖然Java有垃圾收集機制,但內存溢出異常離咱們仍然並不遙遠,本章只是講解了各個區域出現內存溢出異常的緣由,第3章將詳細講解Java垃圾收集機制爲了不內存溢出異常的出現都作了哪些努力。

相關文章
相關標籤/搜索