? ? ? ? 簡(jiǎn)述:上篇文章我們對(duì)JVM運(yùn)行時(shí)數(shù)據(jù)區(qū)(包括方法區(qū)、Java堆、虛擬機(jī)棧、本地方法棧和程序計(jì)數(shù)器等)有了一個(gè)比較清晰的認(rèn)識(shí),那么對(duì)象的創(chuàng)建流程是什么樣的呢?對(duì)象又包含哪些信息呢?程序又是如何去訪問對(duì)象的呢?本章就講帶你去具體探秘Java對(duì)象。
1 對(duì)象的創(chuàng)建
? ? ? ? Java是一門面向?qū)ο蟮木幊陶Z(yǔ)言,在Java程序執(zhí)行過程中時(shí)時(shí)刻刻都在創(chuàng)建對(duì)象,在語(yǔ)言層面上,創(chuàng)建對(duì)象通常通過 new 關(guān)鍵字而已。在Java虛擬機(jī)中,當(dāng)遇到 new 關(guān)鍵字的時(shí)候,首先檢查這個(gè)符號(hào)引用代表的類是否被加載、解析和初始化過,如果沒有,則必須先執(zhí)行相應(yīng)的類加載過程(后續(xù)文章將會(huì)詳細(xì)介紹Java類加載過程)。
? ? ? ? 在類加載檢查通過后,接下來虛擬機(jī)將為新生對(duì)象分配內(nèi)存。對(duì)象所需內(nèi)存大小在類加載完成后已經(jīng)完全確定(稍后會(huì)講到)。為對(duì)象分配內(nèi)存的任務(wù)等同于把一塊確定大小的內(nèi)存從Java堆中劃分出來(幾乎所有的對(duì)象在存放在Java堆中)。假設(shè)內(nèi)存絕對(duì)規(guī)整,把所有用過的內(nèi)存都放在一邊,空閑的放在另一邊,中間放著一個(gè)指針作為分界點(diǎn)的指示器,那分配內(nèi)存時(shí)僅僅就是把指針往空閑的一邊挪動(dòng)一段與對(duì)象大小相等的距離(指針碰撞)。如下圖所示:

如果內(nèi)存不是規(guī)整的,已使用的內(nèi)存和未使用的內(nèi)存互相交錯(cuò),那就沒有辦法簡(jiǎn)單地進(jìn)行“指針碰撞”了,虛擬機(jī)必須維護(hù)一個(gè)列表,記錄那些內(nèi)存塊是可用的,在分配的時(shí)候從列表中找到一塊足夠大的空間劃分給對(duì)象并更新列表上的記錄(空閑列表)。如下圖所示:

選擇哪個(gè)分配方式由Java堆是否規(guī)整決定,而Java堆是否規(guī)整又由所采用的垃圾回收器是否帶有壓縮整理功能有關(guān)。因此,在使用 Serial 、ParNew(這兩個(gè)是新生代垃圾回收器)等帶Compact過程的收集器時(shí),通常采用的是碰撞指針,而 CMS(老年代垃圾回收器)這種基于Mark-Sweep 算法的垃圾回收器時(shí),通常采用的是空閑列表。
? ? ? ? 除劃分空間外,還有另一個(gè)需要考慮的問題是對(duì)象在虛擬機(jī)中創(chuàng)建時(shí)非常頻繁的行為,及時(shí)是僅僅修改一個(gè)指針?biāo)赶虻奈恢?,在并發(fā)情況下也不是現(xiàn)成安全的,可能出現(xiàn)正在給對(duì)象A分配內(nèi)存,指針還沒來得及修改,對(duì)象B又同時(shí)使用了原來的指針?biāo)赶虻膬?nèi)存的情況。解決這個(gè)問題有兩種方案:一是對(duì)分配內(nèi)存的動(dòng)作進(jìn)行同步處理(虛擬機(jī)采用CAS配上失敗重試的方式保證更新操作的原子性);二是把內(nèi)存分配的動(dòng)作按照線程劃分在不同的空間中進(jìn)行,即在每個(gè)線程中預(yù)先分配一小塊內(nèi)存,這個(gè)稱為本地線程分配緩沖(Thread Local Allocation Buffer ,TLAB)。哪個(gè)線程要分配內(nèi)存,就在哪個(gè)線程的TLAB上分配,只有TLAB用完并分配新的TLAB時(shí),才需要同步鎖定。虛擬機(jī)是否使用TLAB,可以通過 -XX:+/-UseTLAB參數(shù)來設(shè)定。
? ? ? ? 內(nèi)存分配完成后,虛擬機(jī)需要將分配到的內(nèi)存空間都初始化為零(不包括對(duì)象頭),如果使用TLAB,這一工作過程也可以提前至TLAB分配時(shí)進(jìn)行。這一步操作保證了對(duì)象的實(shí)例字段在Java代碼中可以不賦初始值就直接使用,程序能訪問到這些字段的數(shù)據(jù)類型所對(duì)應(yīng)的零值。接下來,虛擬機(jī)要對(duì) 對(duì)象進(jìn)行必要的設(shè)置,例如這個(gè)對(duì)象時(shí)哪個(gè)類的實(shí)例、如何才能找到類的元數(shù)據(jù)信息、對(duì)象的哈希碼、對(duì)象的GC分代年齡等信息。這些信息存放在對(duì)象的對(duì)象頭里。根據(jù)虛擬機(jī)當(dāng)前的運(yùn)行狀態(tài)的不同,如是否啟用偏向鎖等,對(duì)象頭會(huì)有不同的設(shè)置方式。關(guān)于對(duì)象頭的具體內(nèi)容,下節(jié)會(huì)詳細(xì)講述。
? ? ? ? 在上面的工作完成后,從虛擬機(jī)的視角來看,一個(gè)新的對(duì)象已經(jīng)產(chǎn)生了,但從Java程序的視角來看,對(duì)象創(chuàng)建在剛剛開始 ?-- <init> 方法還沒執(zhí)行,所有的字段都還為零。所以一般來說,執(zhí)行new指令后會(huì)接著執(zhí)行 <init> 方法(構(gòu)造方法),把對(duì)象按程序員的意愿進(jìn)行初始化,這樣一個(gè)真正可用的對(duì)象才算完全產(chǎn)生出來。
2 對(duì)象的內(nèi)存布局
? ? ? ? 在HotSpot虛擬機(jī)中,對(duì)象在內(nèi)存中存儲(chǔ)的布局可以分為3塊區(qū)域:對(duì)象頭(header)、實(shí)例數(shù)據(jù)(Instance Data)、對(duì)齊填充。?

2.1 對(duì)象頭(Header)
? ? ? ? HotSpot 虛擬機(jī)的對(duì)象頭包含兩部分信息:第一部分是用于存儲(chǔ)對(duì)象自身的運(yùn)行時(shí)數(shù)據(jù),如哈希碼、GC分代年齡信息、鎖狀態(tài)標(biāo)識(shí)、線程持有的鎖、偏向線程ID、偏向時(shí)間戳等,這部分?jǐn)?shù)據(jù)的長(zhǎng)度在32位和64位虛擬機(jī)中分別為32Bit和64Bit,官方稱為 “Mark Word”。對(duì)需要存儲(chǔ)的運(yùn)行時(shí)數(shù)據(jù)很多,其實(shí)已經(jīng)超過了32Bit或者64Bit ?Bitmap結(jié)構(gòu)所能記錄的長(zhǎng)度,但是對(duì)象頭信息是與對(duì)象自身定義的數(shù)據(jù)無關(guān)的額外存儲(chǔ)成本,考慮到虛擬機(jī)的空間效率,Mark Word 被設(shè)計(jì)成一個(gè)非固定的數(shù)據(jù)結(jié)構(gòu)已便在極小的內(nèi)存空間存儲(chǔ)盡量多的信息。它會(huì)根據(jù)對(duì)象的狀態(tài)復(fù)用自己的存儲(chǔ)空間。例如,在32位的HotSpot虛擬機(jī)中,如果對(duì)象處于未被鎖定的狀態(tài)下,那么Mark Word 的32Bit空間中的25bit用于存儲(chǔ)哈希碼,4Bit用于存儲(chǔ)對(duì)象的分代年齡標(biāo)識(shí),2Bit用于存儲(chǔ)鎖標(biāo)示位,1Bit固定為0;第二部分是類型指針,即對(duì)象指向它的類元數(shù)據(jù)的指針,虛擬機(jī)通過這個(gè)指針來確定這個(gè)對(duì)象是哪個(gè)類的實(shí)例。并不是所有的虛擬機(jī)實(shí)現(xiàn)都必須在對(duì)象數(shù)據(jù)上保留類型指針,換句話說,查找對(duì)象的元數(shù)據(jù)信息并不一定要經(jīng)過對(duì)象本身。如果對(duì)象是一個(gè)Java數(shù)組,那在對(duì)象頭中還必須有一塊用于記錄數(shù)組長(zhǎng)度的數(shù)據(jù),因?yàn)樘摂M機(jī)可以通過普通Java對(duì)象的元數(shù)據(jù)信息確定Java對(duì)象的大小,但是從數(shù)組的元數(shù)據(jù)中卻無法確定數(shù)據(jù)的大小。
2.2 實(shí)例數(shù)據(jù)(Instance Data)
? ? ? ? 實(shí)例數(shù)據(jù)部分是對(duì)象真正存儲(chǔ)的有效信息。包含程序代碼中所定義的各種類型的字段內(nèi)容,無論是從父類繼承下來的還是在子類中定義的,都需要記錄下來。這部分的存儲(chǔ)順序會(huì)受到虛擬機(jī)分配策略參數(shù)和字段在Java源碼中定義的順序的影響。HotSpot 虛擬機(jī)默認(rèn)的分配策略為longs/doubles、ints、shorts/charts、bytes/booleans、oops(Ordinary Object Pointers),從分配策略中可以看出,相同寬度的字段總是被分配到一起。在滿足這個(gè)前提條件下,在父類中定義的變量會(huì)出現(xiàn)在子類中定義的變量之前。如果CompactFields參數(shù)值為true(默認(rèn)為false),那么子類中較窄的變量也可能會(huì)插入到父類的變量空隙中。
2.3 對(duì)齊填充(Padding)
? ? ? ?對(duì)齊填充并不是必然存在的,也沒有特別的含義,它僅僅起著占位符的作用,由于HotSpot VM的自動(dòng)內(nèi)存管理系統(tǒng)要求對(duì)象起始地址必須是8字節(jié)的整數(shù)倍,換句話說就是對(duì)象的大小必須是8字節(jié)的整數(shù)倍。而對(duì)象頭正好是8字節(jié)的倍數(shù),因此,當(dāng)對(duì)象實(shí)例數(shù)據(jù)部分沒有對(duì)齊時(shí),就需要對(duì)齊填充來補(bǔ)全。
3 對(duì)象的訪問定位
? ? ? ? 建立對(duì)象時(shí)為了訪問對(duì)象,Java程序需要通過棧上的reference數(shù)據(jù)來操作堆上的具體對(duì)象。由于reference類型在Java虛擬機(jī)規(guī)范中只規(guī)定了一個(gè)指向?qū)ο蟮囊?,并沒有定義這個(gè)引用應(yīng)該通過何種方式去定位、訪問堆中對(duì)象的具體位置,所以對(duì)象訪問方式也是取決于虛擬機(jī)實(shí)現(xiàn)而定的。目前主流的訪問方式由使用句柄和指針兩種。
? ? ? ? 如果使用句柄方式的話,那么Java堆中將會(huì)劃分出一塊內(nèi)存來作為句柄池,reference 中存儲(chǔ)的就是對(duì)象的句柄地址,而句柄中包含了對(duì)象實(shí)例數(shù)據(jù)與類型數(shù)據(jù)各自的具體地址信息,如下圖所示:

? ? ? ? 如果通過指針訪問對(duì)象,那么Java堆對(duì)象中就必須考慮如何放置訪問類型數(shù)據(jù)的相關(guān)信息,而reference中存儲(chǔ)直接就是對(duì)象的地址,如下圖所示:

? ? ? ? 這兩種訪問方式各有優(yōu)勢(shì),使用句柄來訪問的最大好處就是reference中存儲(chǔ)的是穩(wěn)定的句柄地址,在對(duì)象被移動(dòng)(垃圾收集時(shí)移動(dòng)對(duì)象時(shí)非常普遍的行為)時(shí)只會(huì)改變句柄中的實(shí)例數(shù)據(jù)指針,而reference本身不需要修改;使用指針訪問方式的最大好處就是速度更快,它節(jié)省了一次指針定位的時(shí)間開銷,由于對(duì)象的訪問在Java中非常頻繁,因此這類開銷積少成多后也是一項(xiàng)非??捎^的執(zhí)行成本。HotSpot 就是使用指針訪問的方式。
? ? ? ? 總結(jié):通過本篇博文,相信讀者朋友已經(jīng)能夠大致的了解Java對(duì)象在內(nèi)存的創(chuàng)建和訪問流程了,大多數(shù)情況下我們并不需要關(guān)心這個(gè)流程,可是作為向高階程序員邁進(jìn)的過程中,了解底層原理還是有必要。
? ? ? ? 附加:本篇博文內(nèi)容多借鑒《深入理解Java虛擬機(jī)》周志明;