1. 程式人生 > >【深入理解Java虛擬機器】Java記憶體區域模型、物件建立過程、常見OOM

【深入理解Java虛擬機器】Java記憶體區域模型、物件建立過程、常見OOM

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

1、記憶體模型

java虛擬機器在執行java程式的過程中會把它說管理的記憶體劃分為若干個不同的資料區域,如下圖所示:圖片來源於網路
(1)程式計數器(Program Counter Register)    執行緒私有。程式計數器是一塊較小的記憶體空間,可以看作是當前執行緒所執行的位元組碼的行號指示器。位元組碼直譯器工作時就是通過改變這個計數器的值來選取下一條需要執行的位元組碼指令,分支、迴圈、跳轉、異常處理、執行緒恢復登記處功能都需要依賴這個計數器的值來完成。為了執行緒切換後能恢復到正確的執行位置,每個執行緒都需要有一個獨立的程式計數器,各條執行緒之間的計數器互不影響
,獨立儲存。這類記憶體區域稱為“執行緒私有”的記憶體。    程式計數器,是唯一一個在java虛擬機器規範中沒有規定任何OutOfMemoryError的區域(2)Java虛擬機器棧    也是執行緒私有的,生命週期與執行緒相同。虛擬機器棧描述的是Java方法執行的記憶體模型:每個方法在執行的同時,都會建立一個棧幀,用於儲存區域性變量表運算元棧動態連結方法出口等資訊。平常我們把java分為堆記憶體和棧記憶體,其中的“棧”就是現在講的虛擬機器棧,或者說是虛擬機器棧中區域性變量表部分。區域性變量表所需的記憶體空間在編譯期間完成分配,當進入一個方法時,這個方法需要在棧幀中分配多大的區域性變數空間是完全確定的,在方法執行期間不會改變區域性變量表的大小
。    對於java虛擬機器棧,有兩種異常情況:    1)如果執行緒請求的棧深度大於虛擬機器所允許的深度,將丟擲StackOverflowError異常;    2)如果虛擬機器棧在動態擴充套件時,無法申請到足夠的記憶體,就會丟擲OutOfMemoryErrorJava虛擬機器的解釋執行引擎稱為“基於棧的執行引擎”,其中所指的“棧”就是運算元棧。因此我們也稱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具體內容可看《》中的第六部分。記憶體區域模型小結:    (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資料過大(注:文章中圖片來源於網路)