JVM初探 -JVM內存模型

JVM初探 -JVM內存模型

標籤 : JVM


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



JVM 虛擬機架構(圖片來源: 淺析Java虛擬機結構與機制)


JVM 內存區域

JVM會將Java進程所管理的內存劃分爲若干不同的數據區域. 這些區域有各自的用途、創建/銷燬時間:


(圖片來源: JAVA的內存模型及結構)


一. 線程私有區域

線程私有數據區域生命週期與線程相同, 依賴用戶線程的啓動/結束而創建/銷燬(在Hotspot VM內, 每個線程都與操作系統的本地線程直接映射, 因此這部分內存區域的存/否跟隨本地線程的生/死).

1. Program Counter Register(程序計數器):

一塊較小的內存空間, 作用是當前線程所執行字節碼的行號指示器(類似於傳統CPU模型中的PC), PC在每次指令執行後自增, 維護下一個將要執行指令的地址. 在JVM模型中, 字節碼解釋器就是通過改變PC值來選取下一條需要執行的字節碼指令,分支、循環、跳轉、異常處理、線程恢復等基礎功能都需要依賴PC完成(僅限於Java方法, Native方法該計數器值爲undefined).
不同於OS以進程爲單位調度, JVM中的併發是通過線程切換並分配時間片執行來實現的. 在任何一個時刻, 一個處理器內核只會執行一條線程中的指令. 因此, 爲了線程切換後能恢復到正確的執行位置, 每條線程都需要有一個獨立的程序計數器, 這類內存被稱爲“線程私有”內存.


2. Java Stack(虛擬機棧)

虛擬機棧描述的是Java方法執行的內存模型: 每個方法被執行時會創建一個棧幀(Stack Frame)用於存儲局部變量表操作數棧動態鏈接方法出口等信息. 每個方法被調用至返回的過程, 就對應着一個棧幀在虛擬機棧中從入棧到出棧的過程(VM提供了-Xss來指定線程的最大棧空間, 該參數也直接決定了函數調用的最大深度).

  • 局部變量表(對應我們常說的‘堆棧’中的‘棧’)存放了編譯期可知的各種基本數據類型(如boolean、int、double等) 、對象引用(reference : 不等同於對象本身, 可能是一個指向對象起始地址的指針, 也可能指向一個代表對象的句柄或其他與此對象相關的位置, 見下: HotSpot對象定位方式) 和 returnAddress類型(指向一條字節碼指令的地址). 其中longdouble佔用2個局部變量空間(Slot), 其餘只佔用1個. 如下Java方法代碼可以使用javap命令或javassist等字節碼工具讀到:
public String test(int a, long b, float c, double d, Date date, List<String> list) {
    StringBuilder sb = new StringBuilder().append(a).append(b).append(c).append(d).append(date);

    for (String str : list) {
        sb.append(str);
    }

    return sb.toString();
}

注: javap/javassist讀到的其實是靜態數據, 而局部變量表內存儲的卻是運行時動態加載的動態數據, 但因爲局部變量表所需的內存空間在編譯期間即可完成分配, 當進入一個方法時, 這個方法需要在幀中分配多大的局部變量空間是完全確定的,在方法運行期間大小不會改變, 因此可以在概念上認定這兩部分內容存儲的數據格式相同.


3. Native Method Stack(本地方法棧)

Java Stack作用類似, 區別是Java Stack爲執行Java方法服務, 而本地方法棧則爲Native方法服務, 如果一個VM實現使用C-linkage模型來支持Native調用, 那麼該棧將會是一個C棧(詳見: JVM學習筆記-本地方法棧(Native Method Stacks)), 但HotSpot VM直接就把本地方法棧和虛擬機棧合二爲一.


二. 線程共享區域

隨虛擬機的啓動/關閉而創建/銷燬.


1. Heap(Java堆)

幾乎所有對象實例和數組都要在堆上分配(棧上分配、標量替換除外), 因此是VM管理的最大一塊內存, 也是垃圾收集器的主要活動區域. 由於現代VM採用分代收集算法, 因此Java堆從GC的角度還可以細分爲: 新生代(Eden區From Survivor區To Survivor區)和老年代; 而從內存分配的角度來看, 線程共享的Java堆還還可以劃分出多個線程私有的分配緩衝區(TLAB). 而進一步劃分的目的是爲了更好地回收內存和更快地分配內存.


2. Method Area(方法區)

即我們常說的永久代(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)用於存放編譯期生成的各種字面量和符號引用, 這部分內容會存放到方法區的運行時常量池中(如前面從test方法中讀到的signature信息). 但Java語言並不要求常量一定只能在編譯期產生, 即並非預置入Class文件中常量池的內容才能進入方法區運行時常量池, 運行期間也可能將新的常量放入池中, 如Stringintern()方法.

三. 直接內存

直接內存並不是JVM運行時數據區的一部分, 但也會被頻繁的使用: 在JDK 1.4引入的NIO提供了基於Channel與Buffer的IO方式, 它可以使用Native函數庫直接分配堆外內存, 然後使用DirectByteBuffer對象作爲這塊內存的引用進行操作(詳見: Java I/O 擴展), 這樣就避免了在Java堆和Native堆中來回複製數據, 因此在一些場景中可以顯著提高性能.
顯然, 本機直接內存的分配不會受到Java堆大小的限制(即不會遵守-Xms、-Xmx等設置), 但既然是內存, 則肯定還是會受到本機總內存大小及處理器尋址空間的限制, 因此動態擴展時也會出現OutOfMemoryError異常.


HotSpot對象

對象新建

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

    1. VM遇到new指令: 首先去檢查該指令的參數是否能在常量池中定位到一個類的符號引用, 並檢查這個符號引用代表的類是否已被加載、解析和初始化過. 如果沒有, 必須先執行相應的類加載過程.
    2. 類加載檢查通過後: VM將爲新生對象分配內存(對象所需內存的大小在類加載完成後便可完全確定), VM採用指針碰撞(內存規整: Serial、ParNew等有內存壓縮整理功能的收集器)或空閒鏈表(內存不規整: CMS這種基於Mark-Sweep算法的收集器)方式將一塊確定大小的內存從Java堆中劃分出來.
    3. 除了考慮如何劃分可用空間外, 由於在VM上創建對象的行爲非常頻繁, 因此需要考慮內存分配的併發問題. 解決方案有兩個:
      • 對分配內存空間的動作進行同步 -採用 CAS配上失敗重試 方式保證更新操作的原子性;
      • 把內存分配的動作按照線程劃分在不同的空間之中進行 -每個線程在Java堆中預先分配一小塊內存, 稱爲本地線程分配緩衝TLAB, 各線程首先在TLAB上分配, 只有TLAB用完, 分配新的TLAB時才需要同步鎖定(使用-XX:+/-UseTLAB參數設定).
    4. 接下來將分配到的內存空間初始化爲零值(不包括對象頭, 且如果使用TLAB這一個工作也可以提前至TLAB分配時進行). 這一步保證了對象的實例字段可以不賦初始值就直接使用(訪問到這些字段的數據類型所對應的零值).
    5. 然後要對對象進行必要的設置: 如該對象所屬的類實例如何能訪問到類的元數據信息對象的哈希碼對象的GC分代年齡等, 這部分息放在對象頭中(詳見下).
    6. 上面工作都完成之後, 在虛擬機角度一個新對象已經產生, 但在Java視角對象的創建纔剛剛開始(<init>方法尚未執行, 所有字段還都爲零). 所以new指令之後一般會(由字節碼中是否跟隨有invokespecial指令所決定-Interface一般不會有, 而Class一般會有)接着執行<init>方法, 把對象按照程序員的意願進行初始化, 這樣一個真正可用的對象纔算完全產生出來.

對象存儲佈局

HotSpot VM內, 對象在內存中的存儲佈局可以分爲三塊區域:對象頭、實例數據和對齊填充:

  • 對象頭包括兩部分:
    • 一部分是類型指針, 即是對象指向它的類元數據的指針: VM通過該指針確定該對象屬於哪個類實例. 另外, 如果對象是一個數組, 那在對象頭中還必須有一塊數據用於記錄數組長度.

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

    • 一部分用於存儲對象自身的運行時數據: HashCodeGC分代年齡鎖狀態標誌線程持有的鎖偏向線程ID偏向時間戳等, 這部分數據的長度在32位和64位的VM(暫不考慮開啓壓縮指針)中分別爲32bit和64bit, 官方稱之爲“Mark Word”; 其存儲格式如下:
狀態 標誌位 存儲內容
未鎖定 01 對象哈希碼、對象分代年齡
輕量級鎖定 00 指向鎖記錄的指針
膨脹(重量級鎖定) 10 執行重量級鎖定的指針
GC標記 11 空(不需要記錄信息)
可偏向 01 偏向線程ID、偏向時間戳、對象分代年齡
  • 實例數據部分是對象真正存儲的有效信息, 也就是我們在代碼裏所定義的各種類型的字段內容(無論是從父類繼承下來的, 還是在子類中定義的都需要記錄下來). 這部分的存儲順序會受到虛擬機分配策略參數和字段在Java源碼中定義順序的影響. HotSpot默認的分配策略爲longs/doublesintsshorts/charsbytes/booleansoops(Ordinary Object Pointers), 相同寬度的字段總是被分配到一起, 在滿足這個前提條件下, 在父類中定義的變量會出現在子類之前. 如果CompactFields參數值爲true(默認), 那子類中較窄的變量也可能會插入到父類變量的空隙中.
  • 對齊填充部分並不是必然存在的, 僅起到佔位符的作用, 原因是HotSpot自動內存管理系統要求對象起始地址必須是8字節的整數倍, 即對象的大小必須是8字節的整數倍.

對象定位

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

  • 句柄: Java堆中將會劃分出一塊內存來作爲句柄池, reference中存儲對象的句柄地址, 而句柄中包含了對象實例數據與類型數據的具體各自的地址信息:

  • 直接指針(HotSpot使用): 該方式Java堆對象的佈局中就必須考慮如何放置訪問類型數據的相關信息, reference中存儲的直接就是對象地址:

這兩種對象訪問方式各有優勢: 使用句柄來訪問的最大好處是reference中存儲的是穩定句柄地址, 在對象被移動(垃圾收集時移動對象是非常普遍的行爲)時只會改變句柄中的實例數據指針,而reference本身不變. 而使用直接指針最大的好處就是速度更快, 它節省了一次指針定位的時間開銷,由於對象訪問非常頻繁, 因此這類開銷積小成多也是一項非常可觀的執行成本.


參考 & 拓展
深入理解Java虛擬機
實戰Java虛擬機
HotSpot實戰
深入理解計算機系統
JVM內幕:Java虛擬機詳解 (薦)
Java內存管理:深入Java內存區域
JAVA的內存模型及結構
Memory Management in the Java HotSpot Virtual Machine
Java HotSpot VM Options
JVM實用參數(一)JVM類型以及編譯器模式
HotSpot虛擬機對象探祕

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