JVM初探:JVM內(nèi)存模型
JVM是每個(gè)Java開(kāi)發(fā)每天都會(huì)接觸到的東西, 其相關(guān)知識(shí)也應(yīng)該是每個(gè)人都要深入了解的. 但接觸了很多人發(fā)現(xiàn): 或了解片面或知識(shí)體系陳舊. 因此最近抽時(shí)間研讀了幾本評(píng)價(jià)較高的JVM入門(mén)書(shū)籍, 算是總結(jié)于此. 本系列博客的主體來(lái)自 深入理解Java虛擬機(jī)(第二版)和 實(shí)戰(zhàn)Java虛擬機(jī) 兩部書(shū), 部分內(nèi)容參考 HotSpot實(shí)戰(zhàn) 和 深入理解計(jì)算機(jī)系統(tǒng) 以及網(wǎng)上大量的文章.

JVM 內(nèi)存區(qū)域
JVM會(huì)將Java進(jìn)程所管理的內(nèi)存劃分為若干不同的數(shù)據(jù)區(qū)域. 這些區(qū)域有各自的用途、創(chuàng)建/銷(xiāo)毀時(shí)間:

一. 線程私有區(qū)域
線程私有數(shù)據(jù)區(qū)域生命周期與線程相同, 依賴用戶線程的啟動(dòng)/結(jié)束而創(chuàng)建/銷(xiāo)毀(在Hotspot VM內(nèi), 每個(gè)線程都與操作系統(tǒng)的本地線程直接映射, 因此這部分內(nèi)存區(qū)域的存/否跟隨本地線程的生/死).
1. Program Counter Register(程序計(jì)數(shù)器):
一塊較小的內(nèi)存空間, 作用是當(dāng)前線程所執(zhí)行字節(jié)碼的行號(hào)指示器(類似于傳統(tǒng)CPU模型中的PC), PC在每次指令執(zhí)行后自增, 維護(hù)下一個(gè)將要執(zhí)行指令的地址. 在JVM模型中, 字節(jié)碼解釋器就是通過(guò)改變PC值來(lái)選取下一條需要執(zhí)行的字節(jié)碼指令,分支、循環(huán)、跳轉(zhuǎn)、異常處理、線程恢復(fù)等基礎(chǔ)功能都需要依賴PC完成(僅限于Java方法, Native方法該計(jì)數(shù)器值為undefined).
不同于OS以進(jìn)程為單位調(diào)度, JVM中的并發(fā)是通過(guò)線程切換并分配時(shí)間片執(zhí)行來(lái)實(shí)現(xiàn)的. 在任何一個(gè)時(shí)刻, 一個(gè)處理器內(nèi)核只會(huì)執(zhí)行一條線程中的指令. 因此, 為了線程切換后能恢復(fù)到正確的執(zhí)行位置, 每條線程都需要有一個(gè)獨(dú)立的程序計(jì)數(shù)器, 這類內(nèi)存被稱為“線程私有”內(nèi)存.
2. Java Stack(虛擬機(jī)棧)
虛擬機(jī)棧描述的是Java方法執(zhí)行的內(nèi)存模型: 每個(gè)方法被執(zhí)行時(shí)會(huì)創(chuàng)建一個(gè)棧幀(Stack Frame)用于存儲(chǔ)局部變量表、操作數(shù)棧、動(dòng)態(tài)鏈接、方法出口等信息. 每個(gè)方法被調(diào)用至返回的過(guò)程, 就對(duì)應(yīng)著一個(gè)棧幀在虛擬機(jī)棧中從入棧到出棧的過(guò)程(VM提供了-Xss來(lái)指定線程的最大棧空間, 該參數(shù)也直接決定了函數(shù)調(diào)用的最大深度).
局部變量表(對(duì)應(yīng)我們常說(shuō)的‘堆?!?/em>中的‘?!?/em>)存放了編譯期可知的各種基本數(shù)據(jù)類型(如boolean、int、double等) 、對(duì)象引用(reference : 不等同于對(duì)象本身, 可能是一個(gè)指向?qū)ο笃鹗嫉刂返闹羔? 也可能指向一個(gè)代表對(duì)象的句柄或其他與此對(duì)象相關(guān)的位置, 見(jiàn)下: HotSpot對(duì)象定位方式) 和 returnAddress類型(指向一條字節(jié)碼指令的地址). 其中long和double占用2個(gè)局部變量空間(Slot), 其余只占用1個(gè). 如下Java方法代碼可以使用javap命令或javassist等字節(jié)碼工具讀到:
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讀到的其實(shí)是靜態(tài)數(shù)據(jù), 而局部變量表內(nèi)存儲(chǔ)的卻是運(yùn)行時(shí)動(dòng)態(tài)加載的動(dòng)態(tài)數(shù)據(jù), 但因?yàn)榫植孔兞勘硭璧膬?nèi)存空間在編譯期間即可完成分配, 當(dāng)進(jìn)入一個(gè)方法時(shí), 這個(gè)方法需要在幀中分配多大的局部變量空間是完全確定的,在方法運(yùn)行期間大小不會(huì)改變, 因此可以在概念上認(rèn)定這兩部分內(nèi)容存儲(chǔ)的數(shù)據(jù)格式相同.
3. Native Method Stack(本地方法棧)
與Java Stack作用類似, 區(qū)別是Java Stack為執(zhí)行Java方法服務(wù), 而本地方法棧則為Native方法服務(wù), 如果一個(gè)VM實(shí)現(xiàn)使用C-linkage模型來(lái)支持Native調(diào)用, 那么該棧將會(huì)是一個(gè)C棧(詳見(jiàn): JVM學(xué)習(xí)筆記-本地方法棧(Native Method Stacks)), 但HotSpot VM直接就把本地方法棧和虛擬機(jī)棧合二為一.
二. 線程共享區(qū)域
隨虛擬機(jī)的啟動(dòng)/關(guān)閉而創(chuàng)建/銷(xiāo)毀.
1. Heap(Java堆)
幾乎所有對(duì)象實(shí)例和數(shù)組都要在堆上分配(棧上分配、標(biāo)量替換除外), 因此是VM管理的最大一塊內(nèi)存, 也是垃圾收集器的主要活動(dòng)區(qū)域. 由于現(xiàn)代VM采用分代收集算法, 因此Java堆從GC的角度還可以細(xì)分為: 新生代(Eden區(qū)、From Survivor區(qū)和To Survivor區(qū))和老年代; 而從內(nèi)存分配的角度來(lái)看, 線程共享的Java堆還還可以劃分出多個(gè)線程私有的分配緩沖區(qū)(TLAB). 而進(jìn)一步劃分的目的是為了更好地回收內(nèi)存和更快地分配內(nèi)存.
2. Method Area(方法區(qū))
即我們常說(shuō)的永久代(Permanent Generation), 用于存儲(chǔ)被JVM加載的類信息、常量、靜態(tài)變量、即時(shí)編譯器編譯后的代碼等數(shù)據(jù). HotSpot VM把GC分代收集擴(kuò)展至方法區(qū), 即使用Java堆的永久代來(lái)實(shí)現(xiàn)方法區(qū), 這樣HotSpot的垃圾收集器就可以像管理Java堆一樣管理這部分內(nèi)存, 而不必為方法區(qū)開(kāi)發(fā)專門(mén)的內(nèi)存管理器(永久帶的內(nèi)存回收的主要目標(biāo)是針對(duì)常量池的回收和類型的卸載, 因此收益一般很小)

不過(guò)在1.7的HotSpot已經(jīng)將原本放在永久代的字符串常量池移出:
而在1.8中, 永久區(qū)已經(jīng)被徹底移除, 取而代之的是元數(shù)據(jù)區(qū)Metaspace(這一點(diǎn)在查看GC日志和使用jstat -gcutil查看GC情況時(shí)可以觀察到),與永久代不同, 如果不指定Metaspace大小, 如果方法區(qū)持續(xù)增長(zhǎng), VM會(huì)默認(rèn)耗盡所有系統(tǒng)內(nèi)存.
運(yùn)行時(shí)常量池
方法區(qū)的一部分. Class文件中除了有類的版本、字段、方法、接口等描述信息外,還有一項(xiàng)常量池(Constant Pool Table)用于存放編譯期生成的各種字面量和符號(hào)引用, 這部分內(nèi)容會(huì)存放到方法區(qū)的運(yùn)行時(shí)常量池中(如前面從test方法中讀到的signature信息). 但Java語(yǔ)言并不要求常量一定只能在編譯期產(chǎn)生, 即并非預(yù)置入Class文件中常量池的內(nèi)容才能進(jìn)入方法區(qū)運(yùn)行時(shí)常量池, 運(yùn)行期間也可能將新的常量放入池中, 如String的intern()方法.
三. 直接內(nèi)存
直接內(nèi)存并不是JVM運(yùn)行時(shí)數(shù)據(jù)區(qū)的一部分, 但也會(huì)被頻繁的使用: 在JDK 1.4引入的NIO提供了基于Channel與Buffer的IO方式, 它可以使用Native函數(shù)庫(kù)直接分配堆外內(nèi)存, 然后使用DirectByteBuffer對(duì)象作為這塊內(nèi)存的引用進(jìn)行操作(詳見(jiàn): Java I/O 擴(kuò)展), 這樣就避免了在Java堆和Native堆中來(lái)回復(fù)制數(shù)據(jù), 因此在一些場(chǎng)景中可以顯著提高性能.
顯然, 本機(jī)直接內(nèi)存的分配不會(huì)受到Java堆大小的限制(即不會(huì)遵守-Xms、-Xmx等設(shè)置), 但既然是內(nèi)存, 則肯定還是會(huì)受到本機(jī)總內(nèi)存大小及處理器尋址空間的限制, 因此動(dòng)態(tài)擴(kuò)展時(shí)也會(huì)出現(xiàn)OutOfMemoryError異常.
HotSpot對(duì)象
對(duì)象新建
new一個(gè)Java Object(包括數(shù)組和Class對(duì)象), 在JVM會(huì)發(fā)生如下步驟:
1.對(duì)分配內(nèi)存空間的動(dòng)作進(jìn)行同步 -采用 CAS配上失敗重試 方式保證更新操作的原子性;把內(nèi)存分配的動(dòng)作按照線程劃分在不同的空間之中進(jìn)行 -每個(gè)線程在Java堆中預(yù)先分配一小塊內(nèi)存, 稱為本地線程分配緩沖TLAB, 各線程首先在TLAB上分配, 只有TLAB用完, 分配新的TLAB時(shí)才需要同步鎖定(使用-XX:+/-UseTLAB參數(shù)設(shè)定).JVM遇到new指令: 首先去檢查該指令的參數(shù)是否能在常量池中定位到一個(gè)類的符號(hào)引用, 并檢查這個(gè)符號(hào)引用代表的類是否已被加載、解析和初始化過(guò). 如果沒(méi)有, 必須先執(zhí)行相應(yīng)的類加載過(guò)程.
2.類加載檢查通過(guò)后: VM將為新生對(duì)象分配內(nèi)存(對(duì)象所需內(nèi)存的大小在類加載完成后便可完全確定), VM采用指針碰撞(內(nèi)存規(guī)整: Serial、ParNew等有內(nèi)存壓縮整理功能的收集器)或空閑鏈表(內(nèi)存不規(guī)整: CMS這種基于Mark-Sweep算法的收集器)方式將一塊確定大小的內(nèi)存從Java堆中劃分出來(lái).
微信搜索公眾號(hào):Java項(xiàng)目精選,回復(fù):java 領(lǐng)取資料 。
3.除了考慮如何劃分可用空間外, 由于在VM上創(chuàng)建對(duì)象的行為非常頻繁, 因此需要考慮內(nèi)存分配的并發(fā)問(wèn)題. 解決方案有兩個(gè):
對(duì)分配內(nèi)存空間的動(dòng)作進(jìn)行同步 -采用 CAS配上失敗重試 方式保證更新操作的原子性;
把內(nèi)存分配的動(dòng)作按照線程劃分在不同的空間之中進(jìn)行 -每個(gè)線程在Java堆中預(yù)先分配一小塊內(nèi)存, 稱為本地線程分配緩沖TLAB, 各線程首先在TLAB上分配, 只有TLAB用完, 分配新的TLAB時(shí)才需要同步鎖定(使用-XX:+/-UseTLAB參數(shù)設(shè)定).
4.接下來(lái)將分配到的內(nèi)存空間初始化為零值(不包括對(duì)象頭, 且如果使用TLAB這一個(gè)工作也可以提前至TLAB分配時(shí)進(jìn)行). 這一步保證了對(duì)象的實(shí)例字段可以不賦初始值就直接使用(訪問(wèn)到這些字段的數(shù)據(jù)類型所對(duì)應(yīng)的零值).
5.然后要對(duì)對(duì)象進(jìn)行必要的設(shè)置: 如該對(duì)象所屬的類實(shí)例、如何能訪問(wèn)到類的元數(shù)據(jù)信息、對(duì)象的哈希碼、對(duì)象的GC分代年齡等, 這部分息放在對(duì)象頭中(詳見(jiàn)下).
6.上面工作都完成之后, 在虛擬機(jī)角度一個(gè)新對(duì)象已經(jīng)產(chǎn)生, 但在Java視角對(duì)象的創(chuàng)建才剛剛開(kāi)始(<init>方法尚未執(zhí)行, 所有字段還都為零). 所以new指令之后一般會(huì)(由字節(jié)碼中是否跟隨有invokespecial指令所決定-Interface一般不會(huì)有, 而Class一般會(huì)有)接著執(zhí)行<init>方法, 把對(duì)象按照程序員的意愿進(jìn)行初始化, 這樣一個(gè)真正可用的對(duì)象才算完全產(chǎn)生出來(lái).
對(duì)象存儲(chǔ)布局
HotSpot VM內(nèi), 對(duì)象在內(nèi)存中的存儲(chǔ)布局可以分為三塊區(qū)域:對(duì)象頭、實(shí)例數(shù)據(jù)和對(duì)齊填充:
對(duì)象頭包括兩部分:
一部分是類型指針, 即是對(duì)象指向它的類元數(shù)據(jù)的指針: VM通過(guò)該指針確定該對(duì)象屬于哪個(gè)類實(shí)例. 另外, 如果對(duì)象是一個(gè)數(shù)組, 那在對(duì)象頭中還必須有一塊數(shù)據(jù)用于記錄數(shù)組長(zhǎng)度.
注意: 并非所有VM實(shí)現(xiàn)都必須在對(duì)象數(shù)據(jù)上保留類型指針, 也就是說(shuō)查找對(duì)象的元數(shù)據(jù)并非一定要經(jīng)過(guò)對(duì)象本身(詳見(jiàn)下面句柄定位對(duì)象方式).
一部分用于存儲(chǔ)對(duì)象自身的運(yùn)行時(shí)數(shù)據(jù): HashCode、GC分代年齡、鎖狀態(tài)標(biāo)志、線程持有的鎖、偏向線程ID、偏向時(shí)間戳等, 這部分?jǐn)?shù)據(jù)的長(zhǎng)度在32位和64位的VM(暫不考慮開(kāi)啟壓縮指針)中分別為32bit和64bit, 官方稱之為“Mark Word”; 其存儲(chǔ)格式如下:
| 狀態(tài) | 標(biāo)志位 | 存儲(chǔ)內(nèi)容 |
|---|---|---|
| 未鎖定 | 01 | 對(duì)象哈希碼、對(duì)象分代年齡 |
| 輕量級(jí)鎖定 | 00 | 指向鎖記錄的指針 |
| 膨脹(重量級(jí)鎖定) | 10 | 執(zhí)行重量級(jí)鎖定的指針 |
| GC標(biāo)記 | 11 | 空(不需要記錄信息) |
| 可偏向 | 01 | 偏向線程ID、偏向時(shí)間戳、對(duì)象分代年齡 |
實(shí)例數(shù)據(jù)部分是對(duì)象真正存儲(chǔ)的有效信息, 也就是我們?cè)诖a里所定義的各種類型的字段內(nèi)容(無(wú)論是從父類繼承下來(lái)的, 還是在子類中定義的都需要記錄下來(lái)). 這部分的存儲(chǔ)順序會(huì)受到虛擬機(jī)分配策略參數(shù)和字段在Java源碼中定義順序的影響. HotSpot默認(rèn)的分配策略為longs/doubles、ints、shorts/chars、bytes/booleans、oops(Ordinary Object Pointers), 相同寬度的字段總是被分配到一起, 在滿足這個(gè)前提條件下, 在父類中定義的變量會(huì)出現(xiàn)在子類之前. 如果CompactFields參數(shù)值為true(默認(rèn)), 那子類中較窄的變量也可能會(huì)插入到父類變量的空隙中.
對(duì)齊填充部分并不是必然存在的, 僅起到占位符的作用, 原因是HotSpot自動(dòng)內(nèi)存管理系統(tǒng)要求對(duì)象起始地址必須是8字節(jié)的整數(shù)倍, 即對(duì)象的大小必須是8字節(jié)的整數(shù)倍.
對(duì)象定位
建立對(duì)象是為了使用對(duì)象, Java程序需要通過(guò)棧上的reference來(lái)操作堆上的具體對(duì)象. 主流的有句柄和直接指針兩種方式去定位和訪問(wèn)堆上的對(duì)象:
句柄: Java堆中將會(huì)劃分出一塊內(nèi)存來(lái)作為句柄池, reference中存儲(chǔ)對(duì)象的句柄地址, 而句柄中包含了對(duì)象實(shí)例數(shù)據(jù)與類型數(shù)據(jù)的具體各自的地址信息: 
直接指針(HotSpot使用): 該方式Java堆對(duì)象的布局中就必須考慮如何放置訪問(wèn)類型數(shù)據(jù)的相關(guān)信息, reference中存儲(chǔ)的直接就是對(duì)象地址: 
這兩種對(duì)象訪問(wèn)方式各有優(yōu)勢(shì): 使用句柄來(lái)訪問(wèn)的最大好處是reference中存儲(chǔ)的是穩(wěn)定句柄地址, 在對(duì)象被移動(dòng)(垃圾收集時(shí)移動(dòng)對(duì)象是非常普遍的行為)時(shí)只會(huì)改變句柄中的實(shí)例數(shù)據(jù)指針,而reference本身不變. 而使用直接指針最大的好處就是速度更快, 它節(jié)省了一次指針定位的時(shí)間開(kāi)銷(xiāo),由于對(duì)象訪問(wèn)非常頻繁, 因此這類開(kāi)銷(xiāo)積小成多也是一項(xiàng)非常可觀的執(zhí)行成本.
作者:菜鳥(niǎo)-翡青
鏈接:https://blog.csdn.net/zjf280441589/article/details/53437703
如有文章對(duì)你有幫助,
“在看”和轉(zhuǎn)發(fā)是對(duì)我最大的支持!

