天天看點

JVM初探 -JVM記憶體模型JVM初探 -JVM記憶體模型

标簽 : JVM

JVM是每個Java開發每天都會接觸到的東西, 其相關知識也應該是每個人都要深入了解的. 但接觸了很多人發現: 或了解片面或知識體系陳舊. 是以最近抽時間研讀了幾本評價較高的JVM入門書籍, 算是總結于此. 本系列部落格的主體來自 深入了解Java虛拟機(第二版) 和 實戰Java虛拟機 兩部書, 部分内容參考 HotSpot實戰 和 深入了解計算機系統 以及網上大量的文章. 若文内有引文未注明出處的, 還請聯系作者修改.

JVM會将Java程序所管理的記憶體劃分為若幹不同的資料區域. 這些區域有各自的用途、建立/銷毀時間:

線程私有資料區域生命周期與線程相同, 依賴使用者線程的啟動/結束而建立/銷毀(在Hotspot VM内, 每個線程都與作業系統的本地線程直接映射, 是以這部分記憶體區域的存/否跟随本地線程的生/死).

一塊較小的記憶體空間, 作用是目前線程所執行位元組碼的行号訓示器(類似于傳統CPU模型中的PC), PC在每次指令執行後自增, 維護下一個将要執行指令的位址. 在JVM模型中, 位元組碼解釋器就是通過改變PC值來選取下一條需要執行的位元組碼指令,分支、循環、跳轉、異常處理、線程恢複等基礎功能都需要依賴PC完成(僅限于Java方法, Native方法該計數器值為<code>undefined</code>).

不同于OS以程序為機關排程, JVM中的并發是通過線程切換并配置設定時間片執行來實作的. 在任何一個時刻, 一個處理器核心隻會執行一條線程中的指令. 是以, 為了線程切換後能恢複到正确的執行位置, 每條線程都需要有一個獨立的程式計數器, 這類記憶體被稱為“線程私有”記憶體.

虛拟機棧描述的是Java方法執行的記憶體模型: 每個方法被執行時會建立一個棧幀(Stack Frame)用于存儲局部變量表、操作數棧、動态連結、方法出口等資訊. 每個方法被調用至傳回的過程, 就對應着一個棧幀在虛拟機棧中從入棧到出棧的過程(VM提供了<code>-Xss</code>來指定線程的最大棧空間, 該參數也直接決定了函數調用的最大深度).

局部變量表(對應我們常說的‘堆棧’中的‘棧’)存放了編譯期可知的各種基本資料類型(如boolean、int、double等) 、對象引用(reference : 不等同于對象本身, 可能是一個指向對象起始位址的指針, 也可能指向一個代表對象的句柄或其他與此對象相關的位置, 見下: HotSpot對象定位方式) 和 returnAddress類型(指向一條位元組碼指令的位址). 其中<code>long</code>和<code>double</code>占用2個局部變量空間(Slot), 其餘隻占用1個. 如下Java方法代碼可以使用javap指令或javassist等位元組碼工具讀到:

注: javap/javassist讀到的其實是靜态資料, 而局部變量表記憶體儲的卻是運作時動态加載的動态資料, 但因為局部變量表所需的記憶體空間在編譯期間即可完成配置設定, 當進入一個方法時, 這個方法需要在幀中配置設定多大的局部變量空間是完全确定的,在方法運作期間大小不會改變, 是以可以在概念上認定這兩部分内容存儲的資料格式相同.

随虛拟機的啟動/關閉而建立/銷毀.

幾乎所有對象執行個體和數組都要在堆上配置設定(棧上配置設定、标量替換除外), 是以是VM管理的最大一塊記憶體, 也是垃圾收集器的主要活動區域. 由于現代VM采用分代收集算法, 是以Java堆從GC的角度還可以細分為: 新生代(Eden區、From Survivor區和To Survivor區)和老年代; 而從記憶體配置設定的角度來看, 線程共享的Java堆還還可以劃分出多個線程私有的配置設定緩沖區(TLAB). 而進一步劃分的目的是為了更好地回收記憶體和更快地配置設定記憶體.

即我們常說的永久代(Permanent Generation), 用于存儲被JVM加載的類資訊、常量、靜态變量、即時編譯器編譯後的代碼等資料. HotSpot VM把GC分代收集擴充至方法區, 即使用Java堆的永久代來實作方法區, 這樣HotSpot的垃圾收集器就可以像管理Java堆一樣管理這部分記憶體, 而不必為方法區開發專門的記憶體管理器(永久帶的記憶體回收的主要目标是針對常量池的回收和類型的解除安裝, 是以收益一般很小)

不過在1.7的HotSpot已經将原本放在永久代的字元串常量池移出: 而在1.8中, 永久區已經被徹底移除, 取而代之的是中繼資料區Metaspace(這一點在檢視GC日志和使用jstat -gcutil檢視GC情況時可以觀察到),與永久代不同, 如果不指定Metaspace大小, 如果方法區持續增長, VM會預設耗盡所有系統記憶體.

運作時常量池

方法區的一部分. Class檔案中除了有類的版本、字段、方法、接口等描述資訊外,還有一項常量池(Constant Pool Table)用于存放編譯期生成的各種字面量和符号引用, 這部分内容會存放到方法區的運作時常量池中(如前面從<code>test</code>方法中讀到的<code>signature</code>資訊). 但Java語言并不要求常量一定隻能在編譯期産生, 即并非預置入Class檔案中常量池的内容才能進入方法區運作時常量池, 運作期間也可能将新的常量放入池中, 如<code>String</code>的<code>intern()</code>方法.

顯然, 本機直接記憶體的配置設定不會受到Java堆大小的限制(即不會遵守-Xms、-Xmx等設定), 但既然是記憶體, 則肯定還是會受到本機總記憶體大小及處理器尋址空間的限制, 是以動态擴充時也會出現<code>OutOfMemoryError</code>異常.

<code>new</code>一個Java Object(包括數組和Class對象), 在JVM會發生如下步驟:

VM遇到<code>new</code>指令: 首先去檢查該指令的參數是否能在常量池中定位到一個類的符号引用, 并檢查這個符号引用代表的類是否已被加載、解析和初始化過. 如果沒有, 必須先執行相應的類加載過程.

類加載檢查通過後: VM将為新生對象配置設定記憶體(對象所需記憶體的大小在類加載完成後便可完全确定), VM采用指針碰撞(記憶體規整: Serial、ParNew等有記憶體壓縮整理功能的收集器)或空閑連結清單(記憶體不規整: CMS這種基于Mark-Sweep算法的收集器)方式将一塊确定大小的記憶體從Java堆中劃分出來.

除了考慮如何劃分可用空間外, 由于在VM上建立對象的行為非常頻繁, 是以需要考慮記憶體配置設定的并發問題. 解決方案有兩個:

對配置設定記憶體空間的動作進行同步 -采用 CAS配上失敗重試 方式保證更新操作的原子性;

把記憶體配置設定的動作按照線程劃分在不同的空間之中進行 -每個線程在Java堆中預先配置設定一小塊記憶體, 稱為本地線程配置設定緩沖TLAB, 各線程首先在TLAB上配置設定, 隻有TLAB用完, 配置設定新的TLAB時才需要同步鎖定(使用<code>-XX:+/-UseTLAB</code>參數設定).

接下來将配置設定到的記憶體空間初始化為零值(不包括對象頭, 且如果使用TLAB這一個工作也可以提前至TLAB配置設定時進行). 這一步保證了對象的執行個體字段可以不賦初始值就直接使用(通路到這些字段的資料類型所對應的零值).

然後要對對象進行必要的設定: 如該對象所屬的類執行個體、如何能通路到類的中繼資料資訊、對象的哈希碼、對象的GC分代年齡等, 這部分息放在對象頭中(詳見下).

上面工作都完成之後, 在虛拟機角度一個新對象已經産生, 但在Java視角對象的建立才剛剛開始(<code>&lt;init&gt;</code>方法尚未執行, 所有字段還都為零). 是以<code>new</code>指令之後一般會(由位元組碼中是否跟随有<code>invokespecial</code>指令所決定-Interface一般不會有, 而Class一般會有)接着執行<code>&lt;init&gt;</code>方法, 把對象按照程式員的意願進行初始化, 這樣一個真正可用的對象才算完全産生出來.

HotSpot VM内, 對象在記憶體中的存儲布局可以分為三塊區域:對象頭、執行個體資料和對齊填充:

對象頭包括兩部分:

一部分是類型指針, 即是對象指向它的類中繼資料的指針: VM通過該指針确定該對象屬于哪個類執行個體. 另外, 如果對象是一個數組, 那在對象頭中還必須有一塊資料用于記錄數組長度.

注意: 并非所有VM實作都必須在對象資料上保留類型指針, 也就是說查找對象的中繼資料并非一定要經過對象本身(詳見下面句柄定位對象方式).

一部分用于存儲對象自身的運作時資料: HashCode、GC分代年齡、鎖狀态标志、線程持有的鎖、偏向線程ID、偏向時間戳等, 這部分資料的長度在32位和64位的VM(暫不考慮開啟壓縮指針)中分别為32bit和64bit, 官方稱之為“Mark Word”; 其存儲格式如下:

狀态

标志位

存儲内容

未鎖定

01

對象哈希碼、對象分代年齡

輕量級鎖定

00

指向鎖記錄的指針

膨脹(重量級鎖定)

10

執行重量級鎖定的指針

GC标記

11

空(不需要記錄資訊)

可偏向

偏向線程ID、偏向時間戳、對象分代年齡

執行個體資料部分是對象真正存儲的有效資訊, 也就是我們在代碼裡所定義的各種類型的字段内容(無論是從父類繼承下來的, 還是在子類中定義的都需要記錄下來). 這部分的存儲順序會受到虛拟機配置設定政策參數和字段在Java源碼中定義順序的影響. HotSpot預設的配置設定政策為<code>longs</code>/<code>doubles</code>、<code>ints</code>、<code>shorts</code>/<code>chars</code>、<code>bytes</code>/<code>booleans</code>、<code>oops</code>(Ordinary Object Pointers), 相同寬度的字段總是被配置設定到一起, 在滿足這個前提條件下, 在父類中定義的變量會出現在子類之前. 如果<code>CompactFields</code>參數值為<code>true</code>(預設), 那子類中較窄的變量也可能會插入到父類變量的空隙中.

對齊填充部分并不是必然存在的, 僅起到占位符的作用, 原因是HotSpot自動記憶體管理系統要求對象起始位址必須是8位元組的整數倍, 即對象的大小必須是8位元組的整數倍.

建立對象是為了使用對象, Java程式需要通過棧上的reference來操作堆上的具體對象. 主流的有句柄和直接指針兩種方式去定位和通路堆上的對象:

句柄: Java堆中将會劃分出一塊記憶體來作為句柄池, reference中存儲對象的句柄位址, 而句柄中包含了對象執行個體資料與類型資料的具體各自的位址資訊:

直接指針(HotSpot使用): 該方式Java堆對象的布局中就必須考慮如何放置通路類型資料的相關資訊, reference中存儲的直接就是對象位址:

這兩種對象通路方式各有優勢: 使用句柄來通路的最大好處是reference中存儲的是穩定句柄位址, 在對象被移動(垃圾收集時移動對象是非常普遍的行為)時隻會改變句柄中的執行個體資料指針,而reference本身不變. 而使用直接指針最大的好處就是速度更快, 它節省了一次指針定位的時間開銷,由于對象通路非常頻繁, 是以這類開銷積小成多也是一項非常可觀的執行成本.

<dl></dl>

<dt>參考 &amp; 拓展</dt>

<dd></dd>

by 翡青