【深入理解Java虛擬機】Java內存區域模型、對象創建過程、常見OOM

本文內容來源於《深入理解Java虛擬機》一書,非常推薦大家去看一下這本書。最近開始看這本書,打算再開一個相關係列,來總結一下這本書中的重要知識點。呃呃呃,說好的那個圖片請求框架呢奮鬥奮鬥奮鬥~  不要急哈,因爲這個請求框架設計的內容還是比較廣的,目前業餘時間正在編寫當中,弄好了之後就會放上來。在完成之前,咱還是先來學習一下其他知識。微笑微笑微笑

1、內存模型

java虛擬機在執行java程序的過程中會把它說管理的內存劃分爲若干個不同的數據區域,如下圖所示:
圖片來源於網絡
(1)程序計數器(Program Counter Register)
    線程私有。程序計數器是一塊較小的內存空間,可以看作是當前線程所執行的字節碼的行號指示器。字節碼解釋器工作時就是通過改變這個計數器的值來選取下一條需要執行的字節碼指令,分支、循環、跳轉、異常處理、線程恢復登記處功能都需要依賴這個計數器的值來完成。爲了線程切換後能恢復到正確的執行位置,每個線程都需要有一個獨立的程序計數器,各條線程之間的計數器互不影響,獨立存儲。這類內存區域稱爲“線程私有”的內存
    程序計數器,是唯一一個在java虛擬機規範中沒有規定任何OutOfMemoryError的區域

(2)Java虛擬機棧
    也是線程私有的,生命週期與線程相同。虛擬機棧描述的是Java方法執行的內存模型:每個方法在執行的同時,都會創建一個棧幀,用於存儲局部變量表操作數棧動態鏈接方法出口等信息。平常我們把java分爲堆內存和棧內存,其中的“棧”就是現在講的虛擬機棧,或者說是虛擬機棧中局部變量表部分。局部變量表所需的內存空間在編譯期間完成分配,當進入一個方法時,這個方法需要在棧幀中分配多大的局部變量空間是完全確定的,在方法運行期間不會改變局部變量表的大小
    對於java虛擬機棧,有兩種異常情況:
    1)如果線程請求的棧深度大於虛擬機所允許的深度,將拋出StackOverflowError異常;
    2)如果虛擬機棧在動態擴展時,無法申請到足夠的內存,就會拋出OutOfMemoryError
    
     Java虛擬機的解釋執行引擎稱爲“基於棧的執行引擎”,其中所指的“棧”就是操作數棧。因此我們也稱Java虛擬機是基於棧的,這點不同於Android虛擬機,Android虛擬機是基於寄存器的。

(3)本地方法棧(Native Method Stack)
    線程私有。本地方法棧和虛擬機棧所發揮的作用非常相似,它們之間的區別主要是,虛擬機棧是爲虛擬機執行Java方法(也就是字節碼)服務的,而本地方法棧則爲虛擬機使用到的Native方法服務
    與虛擬機棧類似,本地方法棧也會拋出StackOverflowErrorOutOfMemoryError異常。

(4)Java堆(Java Heap)
    所有線程共享。Java堆在虛擬機啓動時創建,是Java虛擬機所管理的內存中最大的一塊。Java堆的唯一目的就是存放對象實例數組
    Java堆是垃圾收集器管理的主要區域,因此也成爲“GC堆”。從內存回收的角度來看,由於現在收集器大都採用分代收集算法,所以Java堆可以細分爲:新生代老年代;再細分一點:Eden空間From Survivor空間 To Survivor空間等。從內存分配角度來看,線程共享的Java堆可以劃分出多個線程私有的分配緩衝區(Thread Local Allocation Buffer,TLAB)。但是不管怎麼劃分,哪個區域,存儲的都是對象實例。
    Java堆物理上不需要連續的內存,只要邏輯上連續即可。如果堆中沒有內存完成實例分配,並且也無法再擴展時,將會拋出OutOfMemoryError異常。

(5)方法區(Method Area)
    所有線程共享。用於存儲已被虛擬機加載的類信息常量靜態變量即時編譯器編譯後的代碼等數據。方法區也有一個別名叫做Non-Heap(非堆),用於與Java堆區分。對於HotSpot虛擬機來說,方法區又習慣稱爲“永久代”(Permancent Generation),但這只是對於HotSpot虛擬機來說的,其他虛擬機的實現上並沒有這個概念。相對而言,垃圾收集行爲在這個區域比較少出現,但也並非不會來收集,這個區域的內存回收目標主要是針對常量池的回收和對類型的卸載上
    
    運行時常量池:
    運行時常量池屬於方法區。Class文件中除了有類的版本、字段、方法、接口等描述信息外,還有一項信息是常量池,用於存放編譯期生成的各種字面常量和符號引用,這部分內容將在類加載後進入方法區的運行時常量池中存放。也就是說,這部分內容,在編譯時只是放入到了常量池信息中,到了加載時,纔會放到運行時常量池中去。運行時常量池縣歸於Class文件常量池的另外一個重要特徵是具備動態性,Java語言並不要求常量一定只有編譯期才能產生,也就是並非預置入Class文件中常量池的內容才能進入方法區的運行時常量池運行期間也可能將新的常量放入池中,這種特性被開發人員利用的比較多的是String類的intern()方法。

    當方法區無法滿足內存分配需求時,將拋出OutOfMemoryError異常,常量池屬於方法區,同樣可能拋出OutOfMemoryError異常。

(6)直接內存(Direct Memory)
    直接內存並不是虛擬機運行時數據區的一部分,也不是Java虛擬機規範中定義的內存區域。但是這部分內存也被頻繁的使用,也可能會導致OutOfMemoryError異常。直接內存的分配不會受到Java堆大小的限制,但需要收到本機總內存的大小以及處理器尋址空間的限制。典型的一個使用直接內存的例子,就是JDK1.4中加入的NIO。關於NIO具體內容可看《Java筆試面試題整理第七波》中的第六部分。

內存區域模型小結:
    (1)線程私有的區域:程序計數器、虛擬機棧、本地方法棧;
    (2)所有線程共享的區域:Java堆、方法區;(注:直接內存不屬於虛擬機內存模型的部分)
    (3)沒有異常的區域:程序計數器;
    (4)StackOverflowError異常:Java虛擬機棧、本地方法棧;
    (5)OutOfMemoryError異常:除程序計數器外的其他四個區域,Java虛擬機棧、本地方法棧、Java堆、方法區;直接內存也會出現OutOfMemoryError。

2、對象的創建、對象內存佈局、對象的訪問定位

2.1 對象的創建過程    

Java在語言層面,通過一個關鍵字new來創建對象。在虛擬機中,當遇到一條new指令後,將開始如下創建過程:
    (1)判斷類是否加載、解析、初始化
    虛擬機遇到一條new指令時,先去檢查這個指定的參數是否能在常量池中定位到一個類的符號引用,並且檢查這個符號引用代表的類是否已被加載、解析和初始化過。如果沒有,那先執行相應的類加載過程。
    (2)爲新對象分配內存
    前面說到,對象的內存分配是在Java堆中的。爲對象分配空間的任務等同於把一塊確定大小的內存從Java堆中劃分出來,此時Java堆中的情況有兩種可能,一種是Java堆中內存是絕對規整的,一種是Java堆中的內存並不是規整的。因此有兩種分配方式:
    1)Java堆內存是規整的,即所有用過的內存都放在一邊,空閒的內存放在另一邊,中間放着一個指針作爲分界點的指示器,此時,分配內存僅需要把這個指針向空閒空間那邊挪動一段與對象大小相等的距離,這種方式也稱爲“指針碰撞”(Bump the Pointer);
    2)Java堆內存不是規整的,即已使用的內存和空閒的內存相互交錯,就沒有辦法簡單地進行指針的移動,此時的分配方案是,虛擬機必須維護一個列表,記錄上哪些內存塊是可用的,在分配的時候從列表中找到一塊足夠大的控件劃分給對象實例,並更新列表上的記錄,這種方式也稱爲“空閒列表”(Free List);

    選擇哪種分配方式由Java堆是否規整決定,而Java堆是否規整又由所採用的垃圾收集器是否帶有壓縮整理功能決定。因此,對於Serial、ParNew等帶Compact過程的垃圾收集器,系統採用的是指針碰撞算法;對於CMS這種基於Mark-Sweep算法的收集器,通常採用空閒列表算法。

    (3)解決併發安全問題
    確定瞭如何劃分內存空間之後,還有一個問題就是,對象的創建在虛擬機中是非常頻繁的行爲,比如,可能出現正在給對象A分配內存,指針還沒來得及修改,對象B又同時使用了原來的指針來分配內存的情況,解決這種併發問題,一般有兩種方案:
    1)對分配內存空間的動作進行同步處理,比如,虛擬機採用CAS配上失敗重試的方式保證更新操作的原子性;
    2)另一種方式是,把內存分配的動作按照線程劃分在不同的空間之中進行,即每個線程在Java堆中預先分配一小塊內存,稱爲本地線程分配緩衝(Thread Local Allocation Buffer,TLAB),哪個線程要分配內存,就在哪個線程的TLAB上分配。只有TLAB用完並分配新的TLAB時,才需要同步鎖定,虛擬機是否使用TLAB,可以通過-XX:+/-UserTLAB參數來設定。

    (4)初始化分配到的內存空間
    內存分配完成後,虛擬機將分配到的內存空間都初始化爲零值(不包括對象頭),如果使用TLAB,這一工作也可以提前至TLAB分配時進行。也正是這一步操作,才保證了我們對象的實例字段在Java代碼中可以不賦初值就直接使用。注意,此時對象的實例字段全部爲零值,並沒有按照程序中的初值進行初始化

    (5)設置對象實例的對象頭
    上面工作完成後,虛擬機對對象進行必要的設置,主要是設置對象的對象頭信息,比如,這個對象是哪個類的實例、如何才能找到類的元數據信息、對象的哈希碼、對象的GC分代年齡等...
    
    (6)初始化對象<init>方法
    其實,上面工作完成後,從虛擬機角度來看,一個新的對象已經產生了,但從Java程序的角度來看,對象創建纔剛剛開始,對象實例中的字段僅僅都爲零值,還需要通過<init>方法進行初始化,把對象按照程序員的意願進行初始化。此時,一個真正可用的對象纔算完全產生出來。

2.2 對象的內存佈局  

經過前面的創建工作,一個對象已經成功產生,也已經在Java堆中分配好了內存。那這個對象在Java堆內存中到底是什麼形態呢?又包括哪些部分呢?這就涉及到了對象的內存佈局了。
    不同的虛擬機實現中,對象的內存佈局有差別,以最常用的HotSpot虛擬機爲例,對象在內存中存儲的佈局分爲3塊區域:對象頭(Header)實例數據(Instance Data)對齊填充(Padding)
    1)對象頭:包含兩部分信息,一部分是用於存儲對象自身的運行時數據,如哈希碼、GC分代年齡、鎖狀態標誌等;另一部分是類型指針,即對象指向它的類元數據的指針,虛擬機通過這個指針來確定這個對象是哪個類的實例。如果對象是一個Java數組,對象頭中還有一塊用於記錄數組長度的數據,因爲虛擬機可以通過普通Java對象的元數據信息確定Java對象的大小,但是從數組的元數據中卻無法確定數組大小。
    2)實例數據:真正存儲對象有效信息的部分。也就是在程序中定義的各種類型的字段內容,包括從父類繼承下來的,以及子類中定義的,都會在實例數據中記錄。
    3)對齊填充:不是必然存在的,僅起着佔位符的作用,對於HotSpot來說,虛擬機的自動內存管理系統要求對象其實地址必須是8字節的整數倍,因此,如果對象實例數據部分沒有對齊時,就需要通過對齊填充的方式來補全。

2.3 對象的訪問定位  

建立了對象是爲了使用對象,我們對數據的使用是通過棧上的reference數據來操作堆上的具體對象,對於不同的虛擬機實現,reference數據類型有不同的定義,主要是如下兩種訪問方式:
    1)使用句柄訪問。此時,Java堆中將會劃出一塊內存來作爲句柄池,reference中存儲的就是對象的句柄地址,而句柄中包含了對象實例數據類型數據各自的具體地址信息,如下圖:    

    2)使用直接指針訪問。此時reference中存儲的就是對象的地址。如下圖:


上面所說的,所謂對象類型,其實就是指,對象所屬的哪個類。
    
上面兩種對象訪問方式各有優勢,使用句柄訪問的最大好處就是reference中存儲的是穩定的句柄地址,在對象被移動(垃圾收集時移動對象是非常普遍的行爲)時,只會改變句柄中的實例數據指針,而reference本身不需要修改;使用直接指針訪問方式的最大好處就是速度更快,它節省了一次指針定位的時間開銷(根據上圖,節省的是對象實例數據的指針定位),由於對象的訪問在Java中非常頻繁,因此,這類開銷積少成多後也是一項非常可觀的執行成本。對於HotSpot而言,選擇的是第二種方式。

3、常見的OOM和SOF

OOM分爲兩種情況:內存溢出(Memory Overflow)內存泄漏(Memory Leak)
OOMOutOfMemoryError異常
    即內存溢出,是指程序在申請內存時,沒有足夠的空間供其使用,出現了Out Of Memory,也就是要求分配的內存超出了系統能給你的,系統不能滿足需求,於是產生溢出。
    內存溢出分爲上溢下溢比方說棧,棧滿時再做進棧必定產生空間溢出,叫上溢,棧空時再做退棧也產生空間溢出,稱爲下溢。
    
    有時候內存泄露會導致內存溢出,所謂內存泄露(memory leak),是指程序在申請內存後,無法釋放已申請的內存空間,一次內存泄露危害可以忽略,但內存泄露堆積後果很嚴重,無論多少內存,遲早會被佔光,舉個例子,就是說系統的籃子(內存)是有限的,而你申請了一個籃子,拿到之後沒有歸還(忘記還了或是丟了),於是造成一次內存泄漏。在你需要用籃子的時候,又去申請,如此反覆,最終系統的籃子無法滿足你的需求,最終會由內存泄漏造成內存溢出

    遇到的OOM:
    (1)Java Heap 溢出
    Java堆用於存儲對象實例,我們只要不斷的創建對象,而又沒有及時回收這些對象(即內存泄漏),就會在對象數量達到最大堆容量限制後產生內存溢出異常。
    (2)方法區溢出
   方法區用於存放Class的相關信息,如類名、訪問修飾符、常量池、字段描述、方法描述等。

異常信息:java.lang.OutOfMemoryError:PermGen space

方法區溢出也是一種常見的內存溢出異常,一個類如果要被垃圾收集器回收,判定條件是很苛刻的。在經常動態生成大量Class的應用中,要特別注意這點。


SOF:StackOverflow(堆棧溢出
    當應用程序遞歸太深而發生堆棧溢出時,拋出該錯誤。因爲棧一般默認爲1-2m,一旦出現死循環或者是大量的遞歸調用,在不斷的壓棧過程中,造成棧容量超過1m而導致溢出。
    棧溢出的原因:
    (1)遞歸調用
    (2)大量循環或死循環
    (3)全局變量是否過多
    (4)數組、List、Map數據過大

(注:文章中圖片來源於網絡)


發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章