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

2.1 概述

對於從事C、C++程序開發的開發人員來說,在內存管理領域,他們即是擁有最高權力的皇帝又是執行最基礎工作的勞動人民——擁有每一個對象的“所有權”,又擔負著每一個對象生命開始到終結的維護責任。

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

回到頂部

2.2 運行時數據區域

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

《深入理解Java虛擬機》-----第2章 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標記、可偏向)下對象的存儲內容見表

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

對象頭的另外一部分是類型指針,即對象指向它的類元數據的指針,虛擬機通過這個指針來確定這個對象是哪個類的實例。並不是所有的虛擬機實現都必須在對象數據上保留類型指針,換句話說,查找對象的元數據信息並不一定要經過對象本身,這點將在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虛擬機》-----第2章 Java內存區域與內存溢出異常

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

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

這兩種對象訪問方式各有優勢,使用句柄來訪問的最大好處就是reference中存儲的是穩定的句柄地址,在對象被移動(垃圾收集時移動對象是非常普遍的行為)時只會改變句柄中的實例數據指針,而reference本身不需要修改。

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

回到頂部

2.4 實戰:OutOfMemoryError異常

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

最基本的虛擬機參數。

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

下文代碼的開頭都註釋了執行時所需要設置的虛擬機啟動參數(註釋中“VM Args”後面跟著的參數),這些參數對實驗的結果有直接影響,讀者調試代碼的時候千萬不要忽略。如果讀者使用控制檯命令來執行程序,那直接跟在Java命令之後書寫就可以。如果讀者使用Eclipse IDE,則可以參考圖在Debug/Run頁籤中的設置。

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

下文的代碼都是基於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 list = new ArrayList();
 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打開的堆轉儲快照文件。

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

如果是內存洩露,可進一步通過工具查看洩露對象到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 list = new ArrayList();
 // 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,那就可以考慮檢查一下是不是這方面的原因。


分享到:


相關文章: