JVM 從入門到放棄之 Java 對(duì)象創(chuàng)建過(guò)程
架構(gòu)對(duì)象的創(chuàng)建
Java 是一門面向?qū)ο蟮木幊陶Z(yǔ)言,創(chuàng)建對(duì)象通常只是通過(guò) new關(guān)鍵字創(chuàng)建。
對(duì)象創(chuàng)建過(guò)程
當(dāng)虛擬機(jī)遇到一個(gè)字節(jié)碼 new指令的時(shí)候,首先去檢查這個(gè)指令的參數(shù)是否能夠在常量池中定位到一個(gè)類的符號(hào)引用。并且檢查這個(gè)符號(hào)引用代表的類是否被虛擬機(jī)類加載器加載。如果沒有,必須先執(zhí)行類加載的流程。
在類的檢查通過(guò)過(guò)后,接下來(lái)虛擬機(jī)就會(huì)為新生成對(duì)象分配內(nèi)存。對(duì)象所需要的內(nèi)存大小在類加載的時(shí)候決定。(對(duì)象內(nèi)存分配后面將有獨(dú)立的一小段講解)。
內(nèi)存分配完成后,虛擬機(jī)會(huì)將這塊分配到的內(nèi)存空間(不包括對(duì)象頭)都初始化為零值,就是將這塊內(nèi)存空間進(jìn)清理和初始化。
接下來(lái)虛擬機(jī)還需要進(jìn)行對(duì)象進(jìn)行初始化設(shè)置,比如元數(shù)據(jù)(對(duì)象是那個(gè)類的實(shí)例)、對(duì)象的哈希編碼、對(duì)象的 GC 分代年齡、偏向鎖狀態(tài)等信息這些信息都用于存放到對(duì)象頭(Object Header)中。
完成上述流程,其實(shí)已經(jīng)完成了虛擬機(jī)中內(nèi)存的創(chuàng)建,但是我們?cè)?Java 執(zhí)行 new創(chuàng)建對(duì)象的角度才剛剛開始,我們還需要調(diào)用構(gòu)造方法初始化對(duì)象(可能還需要在此前后調(diào)用父類的構(gòu)造方法、初始化塊等)。進(jìn)行 Java 對(duì)象的初始化。即在 .class 的角度是調(diào)用 ()方法。如果構(gòu)造方法中還有調(diào)用別的方法,那么別的方法也會(huì)被執(zhí)行,當(dāng)構(gòu)造方法內(nèi)的所有關(guān)聯(lián)的方法都執(zhí)行完畢后,才真正算是完成了 Java 對(duì)象的創(chuàng)建。
整體對(duì)象創(chuàng)建流程如下:
對(duì)象內(nèi)存分配
對(duì)象內(nèi)存分配過(guò)程如下圖所示:
為對(duì)象分配空間的任務(wù)實(shí)質(zhì)上是從 Jvm 的內(nèi)存區(qū)域中,指定一塊確定大小的內(nèi)存塊給 Java 對(duì)象。(默認(rèn)是在堆上分配)。
指針碰撞
假設(shè) Java 堆中內(nèi)存是絕對(duì)規(guī)整的,所有使用過(guò)的內(nèi)存都被放在一邊,沒有使用過(guò)的內(nèi)存放在了另外一邊。中間放著一個(gè)指針用來(lái)表示他們的分界點(diǎn)。那所分配的內(nèi)存僅僅是把那個(gè)指針向空閑的方向挪動(dòng)一段與Java對(duì)象大小相等的距離,這種分配方式叫做“指針碰撞”(Dump The Pointer)。
空閑列表
但是如果 Java 堆中內(nèi)存并不是規(guī)整的,已經(jīng)使用的內(nèi)存塊,和空閑的內(nèi)存塊相互交錯(cuò)在一起,那就沒有辦法簡(jiǎn)單的進(jìn)行指針碰撞了,虛擬機(jī)必須維護(hù)一個(gè)可用內(nèi)存區(qū)域列表。記錄哪些內(nèi)存塊是可以使用的。在對(duì)象內(nèi)存分配的時(shí)候就從列表中去找到一塊足夠大的內(nèi)存空間劃分給實(shí)例對(duì)象,并且更新列表上的記錄。這種分配方式叫做“空閑列表”(Free List)。
內(nèi)存分配方式選擇
什么時(shí)候使用指針碰撞,什么時(shí)候才用空閑列表? 選擇哪一種分配方式是由 Java 堆是否規(guī)整決定的,而 Java 堆是否規(guī)整又是由所采用的垃圾回收器是否有空間整理(Compact)的能力決定。
- 當(dāng)使用 Serial 、ParNew 等帶指針壓縮整理過(guò)程的收集器時(shí),系統(tǒng)采用的分配算法是指針碰撞,既簡(jiǎn)單,又高效。
- 當(dāng)采用 CMS 基于清除(Sweep)算法的收集器時(shí),理論上只能采用復(fù)雜的空閑列表來(lái)分配內(nèi)存。
并發(fā)內(nèi)存分配方案
對(duì)象頻繁分配的過(guò)程中,即使只修改一個(gè)指針?biāo)赶虻奈恢茫窃诓l(fā)的情況下也不是線程安全的,可能出現(xiàn)正在給 A 對(duì)象分配內(nèi)存,指針還沒有來(lái)得及修改,對(duì)象 B 又同時(shí)使用原來(lái)的指針進(jìn)行內(nèi)分配的情況。解決這個(gè)問(wèn)題有兩種可選的方案:一種是對(duì)內(nèi)存分配空間的動(dòng)作進(jìn)行同步處理-實(shí)際上虛擬機(jī)是采用CAS + 失敗重試的方式來(lái)保證更新操作的原子性。另外一種就是把內(nèi)存分配的動(dòng)作按照線程劃分在不同的空間中進(jìn)行,即每個(gè)線程在 Java 堆中預(yù)先分配一小塊內(nèi)存,稱為本地線程分配緩沖(Thred Local Allocation Buffer, TLAB), 那個(gè)線程要分配內(nèi)存,就在那個(gè)線程分配內(nèi)存,就在那個(gè)線程的本地緩沖中分配,只有本地緩沖用完了,分配新的緩沖區(qū)時(shí)才需要同步鎖定,虛擬機(jī)是否使用 TLAB,可以通過(guò) -XX:+/-UseTLAB參數(shù)設(shè)置。
對(duì)象的內(nèi)存布局
在HotSpot虛擬機(jī)中,對(duì)象在內(nèi)存中存儲(chǔ)的布局可以分為3塊區(qū)域:對(duì)象頭(Header)、實(shí)例數(shù)據(jù)(Instance Data)和對(duì)齊填充(Padding)。下圖是普通對(duì)象實(shí)例與數(shù)組對(duì)象實(shí)例的數(shù)據(jù)結(jié)構(gòu):
對(duì)象頭結(jié)構(gòu)
Mark Word (64bit)
結(jié)合 openjdk 源碼 markOop.hpp中我們可以看到:
兩個(gè)指針變量說(shuō)明:
ptr_to_lock_record:輕量級(jí)鎖狀態(tài)下,指向棧中鎖記錄的指針。當(dāng)鎖獲取是無(wú)競(jìng)爭(zhēng)時(shí),JVM 使用原子操作而不是 OS 互斥,這種技術(shù)稱為輕量級(jí)鎖定。在輕量級(jí)鎖定的情況下,JVM 通過(guò) CAS 操作在對(duì)象的 Mark Word 中設(shè)置指向鎖記錄的指針。
ptr_to_heavyweight_monitor:重量級(jí)鎖狀態(tài)下,指向?qū)ο蟊O(jiān)視器 Monitor 的指針。如果兩個(gè)不同的線程同時(shí)在同一個(gè)對(duì)象上競(jìng)爭(zhēng),則必須將輕量級(jí)鎖定升級(jí)到 Monitor 以管理等待的線程。在重量級(jí)鎖定的情況下,JVM 在對(duì)象的 ptr_to_heavyweight_monitor 設(shè)置指向 Monitor 的指針。
markOop.hpp中我們可以看到 文件的注釋如下:
// 部分省略
// 32 bits:
// --------
// hash:25 ------------>| age:4 biased_lock:1 lock:2 (normal object)
// JavaThread*:23 epoch:2 age:4 biased_lock:1 lock:2 (biased object)
// size:32 ------------------------------------------>| (CMS free block)
// PromotedObject*:29 ---------->| promo_bits:3 ----->| (CMS promoted object)
//
// 64 bits:
// --------
// unused:25 hash:31 -->| unused:1 age:4 biased_lock:1 lock:2 (normal object)
// JavaThread*:54 epoch:2 unused:1 age:4 biased_lock:1 lock:2 (biased object)
// PromotedObject*:61 --------------------->| promo_bits:3 ----->| (CMS promoted object)
// size:64 ----------------------------------------------------->| (CMS free block)
//
// unused:25 hash:31 -->| cms_free:1 age:4 biased_lock:1 lock:2 (COOPs && normal object)
// JavaThread*:54 epoch:2 cms_free:1 age:4 biased_lock:1 lock:2 (COOPs && biased object)
// narrowOop:32 unused:24 cms_free:1 unused:4 promo_bits:3 ----->| (COOPs && CMS promoted object)
// unused:21 size:35 -->| cms_free:1 unused:7 ------------------>| (COOPs && CMS free block)
// 部分省略
klass
klass 對(duì)應(yīng) Java 的 CLass 類,一個(gè)對(duì)象 jvm 中就會(huì)生成一個(gè) kclass 實(shí)例對(duì)象存儲(chǔ)到 Java 類對(duì)象的元數(shù)據(jù)信息,在 jdk 1.8 中,將這塊存儲(chǔ)到元空間中。在對(duì)象頭中存儲(chǔ)的就是 klass 類型指針,即對(duì)象指向它的類元數(shù)據(jù)的指針,虛擬機(jī)通過(guò)這個(gè)指針來(lái)確定這個(gè)對(duì)象是哪個(gè)類的實(shí)例。
數(shù)組長(zhǎng)度(只有數(shù)組對(duì)象有)
如果對(duì)象是一個(gè)數(shù)組, 那在對(duì)象頭中還必須有一塊數(shù)據(jù)用于記錄數(shù)組長(zhǎng)度。
實(shí)例數(shù)據(jù)
實(shí)例數(shù)據(jù)部分是對(duì)象真正存儲(chǔ)的有效信息,也是在程序代碼中所定義的各種類型的字段、方法內(nèi)容。無(wú)論是從父類繼承下來(lái)的,還是在子類中定義的,都在這里一一記錄。
對(duì)齊填充
第三部分對(duì)齊填充并不是必然存在的,也沒有特別的含義,它僅僅起著占位符的作用。由于HotSpot VM的自動(dòng)內(nèi)存管理系統(tǒng)要求對(duì)象起始地址必須是8字節(jié)的整數(shù)倍,換句話說(shuō),就是對(duì)象的大小必須是8字節(jié)的整數(shù)倍。而對(duì)象頭部分正好是8字節(jié)的倍數(shù)(1倍或者2倍),因此,當(dāng)對(duì)象實(shí)例數(shù)據(jù)部分沒有對(duì)齊時(shí),就需要通過(guò)對(duì)齊填充來(lái)補(bǔ)全。
對(duì)象大小計(jì)算
- 在32位系統(tǒng)下,存放Class指針的空間大小是4字節(jié),MarkWord是4字節(jié),對(duì)象頭為8字節(jié)。
- 在64位系統(tǒng)下,存放Class指針的空間大小是8字節(jié),MarkWord是8字節(jié),對(duì)象頭為16字節(jié)。
- 64位開啟指針壓縮的情況下,存放Class指針的空間大小是4字節(jié),MarkWord是8字節(jié),對(duì)象頭為12字節(jié)。數(shù)組長(zhǎng)度4字節(jié)+數(shù)組對(duì)象頭8字節(jié)(對(duì)象引用4字節(jié)(未開啟指針壓縮的64位為8字節(jié))+數(shù)組markword為4字節(jié)(64位未開啟指針壓縮的為8字節(jié)))+對(duì)齊4=16字節(jié)。
- 靜態(tài)屬性不算在對(duì)象大小內(nèi)。
打印對(duì)象狀態(tài)
JOL(Java Object Layout)一款開源的用于分析 JVM 中對(duì)象布局的一個(gè)小工具。使用 Unsafe、JVMTI 和 Serviceability Agent (SA) 來(lái)解碼實(shí)際的對(duì)象布局、占用空間和引用。這使得 JOL 比其他依賴堆轉(zhuǎn)儲(chǔ)、規(guī)范假設(shè)等的工具更準(zhǔn)確。 maven 倉(cāng)庫(kù)依賴如下:
<dependency>
<groupId>org.openjdk.jol</groupId>
<artifactId>jol-core</artifactId>
<version>0.16</version>
</dependency>
1.查看對(duì)象內(nèi)部信息包括:對(duì)象內(nèi)的字段布局、標(biāo)題信息、字段值、對(duì)齊/填充。 ClassLayout.parseInstance(obj).toPrintable()
2.查看對(duì)象外部信息:包括引用的 :GraphLayout.parseInstance(obj).toPrintable()
3.查看對(duì)象占用內(nèi)存空間的大小:GraphLayout.parseInstance(obj).totalSize()
16
完整代碼:
import org.openjdk.jol.info.ClassLayout;
import org.openjdk.jol.info.GraphLayout;
public class ObjectTest2 {
public static void main(String[] args) {
Object obj = new Object();
System.out.println(ClassLayout.parseInstance(obj).toPrintable());
System.out.println();
System.out.println();
System.out.println(GraphLayout.parseInstance(obj).toPrintable());
System.out.println();
System.out.println();
System.out.println(GraphLayout.parseInstance(obj).totalSize());
}
}
對(duì)象的訪問(wèn)定位
句柄訪問(wèn)
使用句柄訪問(wèn)方式,Java堆中將可能會(huì)劃分出一塊內(nèi)存來(lái)作為句柄池,reference 中存儲(chǔ)的就是對(duì)象的句柄地址,而句柄中包含了對(duì)象實(shí)例數(shù)據(jù)與類型數(shù)據(jù)各自具體的地址信息,其結(jié)構(gòu)如圖所示:
直接訪問(wèn)
直接指針訪問(wèn),Java堆中對(duì)象的內(nèi)存布局就必須考慮如何放置訪問(wèn)類型數(shù)據(jù)的相關(guān)信息,reference 中存儲(chǔ)的直接就是對(duì)象地址,如果只是訪問(wèn)對(duì)象本身的話,就不需要多一次間接訪問(wèn)的開銷,如圖下圖所示:
對(duì)象訪問(wèn)方式對(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本身不需要被修改。使用直接指針來(lái)訪問(wèn)最大的好處就是速度更快,它節(jié)省了一次指針定位的時(shí)間開銷,由于對(duì)象訪問(wèn)在Java中非常頻繁,因此這類開銷積少成多也是一項(xiàng)極為可觀的執(zhí)行成本,就本書討論的主要虛擬機(jī)HotSpot而言,它主要使用第二種方式進(jìn)行對(duì)象訪問(wèn)(有例外情況,如果使用了Shenandoah收集器的話也會(huì)有一次額外的轉(zhuǎn)發(fā)),但從整個(gè)軟件開發(fā)的范圍來(lái)看,在各種語(yǔ)言、框架中使用句柄來(lái)訪問(wèn)的情況也十分常見。
參考資料
《深入理解 JVM 虛擬機(jī) 第三版》周志明
https://www.cnblogs.com/jhxxb/p/10983788.html
https://www.cnblogs.com/maxigang/p/9040088.html
https://www.oracle.com/technetwork/java/javase/tech/biasedlocking-oopsla2006-preso-150106.pdf
https://github.com/openjdk/jol