JVM初探 -JVM內存模型

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


JVM 虛擬機架構(圖片來源: 淺析Java虛擬機結構與機制)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();
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9

注: javap/javassist讀到的實際上是靜態數據, 而局部變量表內存儲的倒是運行時動態加載的動態數據, 但由於局部變量表所需的內存空間在編譯期間便可完成分配, 當進入一個方法時, 這個方法須要在幀中分配多大的局部變量空間是徹底肯定的,在方法運行期間大小不會改變, 所以能夠在概念上認定這兩部份內容存儲的數據格式相同.app

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自己不變. 而使用直接指針最大的好處就是速度更快, 它節省了一次指針定位的時間開銷,因爲對象訪問很是頻繁, 所以這類開銷積小成多也是一項很是可觀的執行成本.

相關文章
相關標籤/搜索