一文掌握虛擬機(jī)創(chuàng)建對象的秘密
勿在流沙筑高臺,出來混遲早要還的。
本文主要內(nèi)容講解HotSpot虛擬機(jī)在Java堆中對象是如何創(chuàng)建、內(nèi)存分配布局和訪問方式。
本文地圖:

一、給你創(chuàng)建一個對象
如果你是一直從第一季看過來的,那一定知道前面有個地方講過類的整個生命周期,之前只是講到了初始化階段,類是如何使用和類是如何被卸載還沒有進(jìn)行講解!那本文就簡單介紹一下類的使用,我們new 一個 “如花” 似玉的girl!
這里再回顧一下,類從被加載到虛擬機(jī)內(nèi)存中開始,到卸載出內(nèi)存為止,它的生命周期包括了七個階段:
- 加載(Loading)
- 驗(yàn)證(Verification)
- 準(zhǔn)備(Preparation)
- 解析(Resolution)
- 初始化(Initialization)
- 使用(Using)
- 卸載(Unloading)
在Java中我們用使用一個類,很多時候是創(chuàng)建這個類的一個實(shí)例,也就是常說的創(chuàng)建一個對象。其實(shí)在Java程序運(yùn)行過程中,無時無刻都有對象被創(chuàng)建出來。創(chuàng)建對象(如克隆、反序列化)通常僅僅是一個new關(guān)鍵字而已。但是在Java虛擬機(jī)中一個對象(只是普通的java對象,不包括數(shù)組和Class對象等)的創(chuàng)建是怎么一個過程呢?
第一:虛擬機(jī)遇到一條new指令時,首先會去檢查這個指令的參數(shù)是否能夠在常量池中定位到一個類的符號引用。然后檢查這個符號引用代表的類是否已經(jīng)被加載、解析和初始化過。如果沒有進(jìn)行類加載則執(zhí)行相應(yīng)的類加載的過程。 記住:要new對象,要先加載類!
第二:類加載檢查通過后,虛擬機(jī)將為新生的對象分配內(nèi)存。對象所需的內(nèi)存大小在類加載的時候便可以完全確定(如何確定對象的下文說明) 。為對象分配內(nèi)存的任務(wù)等同于把一塊確定大小的內(nèi)存從Java堆中劃分出來。分配方式有 “指針碰撞” 和 “空閑列表” 兩種,選擇那種分配方式由 Java 堆是否規(guī)整決定,而Java堆是否規(guī)整又由所采用的垃圾收集器是否帶有壓縮整理功能決定(對象在堆上的劃分,這是個復(fù)雜的問題,后文繼續(xù)探討,這里只要明白是在對象是在堆上分配內(nèi)存即可)。 記住:要new對象,要有先分配內(nèi)存空間!
第三:內(nèi)存分配完成,虛擬機(jī)需要將分配的內(nèi)存空間都初始化為零值(零值這個概念之前文章也介紹過,這里就不再說明),這一步的操作保證了對象的實(shí)例字段在Java代碼中可以不賦初始值就直接使用,因?yàn)槌绦蚰茉L問這些字段的數(shù)據(jù)類型對應(yīng)的零值。 記?。阂猲ew對象,虛擬機(jī)會幫你為對象的實(shí)例字段自動賦予零值!
第四:虛擬機(jī)要對對象進(jìn)行必要的設(shè)置,如這個對象是哪個類的實(shí)例、如何才能找到類的元數(shù)據(jù)信息(JDK7是方法區(qū)保存)、對象的哈希碼、對象的GC分代年齡等信息。這些信息都存放在對象的對象頭(Object Header)中。
上面工作都完成之后,在虛擬機(jī)看來,一個對象就已經(jīng)產(chǎn)生了。但是從Java程序的角度看,對象的創(chuàng)建才剛剛開始,因?yàn)?init> 方法還還沒有執(zhí)行,所有的字段都是為零值。所以一般來說,在new指令之后會接著執(zhí)行
記?。簩ο蟛皇悄阆雗ew,想new就可以new的!
下面用通過圖解的例子簡單說明(版本jdk1.7):
第一: 一個PrettyGirl類!
- public class PrettyGirl {
- /**
- * 姑娘姓字名誰
- */
- String name;
- /**
- * 芳齡幾何
- */
- int age;
- /**
- * 家住何方
- */
- static String address;
- /**
- * 可曾婚配否
- */
- boolean marry;
- void sayHello(){
- System.out.println("Hello...");
- }
- @Override
- public String toString() {
- return "PrettyGirl{" +
- "name='" + name + '\'' +
- ", age=" + age +
- ", marry=" + marry +
- '}';
- }
- }

方法區(qū)除了保存類的結(jié)構(gòu),還保存靜態(tài)屬性與靜態(tài)方法。編寫中小型程序時,一般不會造成方法區(qū)的內(nèi)存溢出!在JDK1.8 沒有方法區(qū)的概念,前面文章中也有提到,這里為了講解使用圖解還是JDK1.7!
第二:實(shí)例化new兩個漂亮女孩!
- public static void main(String[] args) {
- PrettyGirl pg1 = new PrettyGirl();
- pg1.name = "Alice";
- pg1.age = 18;
- pg1.address = "changsha";
- PrettyGirl pg2 = new PrettyGirl();
- pg2.name = "Alexia";
- pg2.age = 28;
- System.out.println(pg1 + " ---" + pg1.address);
- System.out.println(pg2 + "----" + pg2.address);
- }
- ----打印結(jié)果:--------
- PrettyGirl{name='Alice', age=18, marry=false} ---changsha
- PrettyGirl{name='Alexia', age=28, marry=false}----changsha

在棧內(nèi)存為 pg1 變量申請一個空間,在堆內(nèi)存為PrettyGirl對象申請空間,初始化完畢后將其地址值返回給pg1 ,通過pg1 .name和pg1 .age修改其值,靜態(tài)的變量address是類公有的!
堆存放對象持有的數(shù)據(jù),同時保持對原類的引用??梢院唵蔚睦斫鉃閷ο髮傩缘闹当4嬖诙阎校瑢ο笳{(diào)用的方法保存在方法區(qū)。
從上圖也可以看到有一個區(qū)域是棧,在程序運(yùn)行的時候,每當(dāng)遇到方法 調(diào)用時候,Java虛擬機(jī)就會在棧中劃分一塊內(nèi)存稱為棧幀(線程私有,堆和方法區(qū)線程共享的)。就如上面的程序,在調(diào)用main方法的時候,會創(chuàng)建一下棧,棧幀中的內(nèi)存供局部變量(包括基本類型和引用類型)使用,基本類型和引用類型后文會詳情介紹。當(dāng)方法調(diào)用結(jié)束后,虛擬機(jī)會回收次棧幀占用的內(nèi)存。
tips: 回顧
1、堆內(nèi)存溢出會發(fā)生 OutOfMemoryError 錯誤,提示信息“Java heap Space”。
2、在棧中會有兩個異常:
- 如果線程請求的棧的深度大于虛擬機(jī)所允許的最大深度,將拋出StackOverflowError 異常(遞歸可能會導(dǎo)致此異常)!
- 如果虛擬機(jī)在擴(kuò)展棧時候無法申請到足夠的內(nèi)存空間,則拋出OutOfMemoryError異常。
3、如果有方法區(qū) 也會出現(xiàn)OutOfMemoryError 錯誤,提示信息 “PermGen space”。(JDK8 后無此錯誤提示)
每個區(qū)域都有一些參數(shù)可以設(shè)置,參數(shù)學(xué)習(xí)續(xù)持續(xù)更新!
二、對象的內(nèi)存布局
感慨,創(chuàng)建一個對象還是挺不容易的!
在HotSpot虛擬機(jī)中,對象在內(nèi)存中的布局可以分為3塊區(qū)域:對象頭(Header)、實(shí)例數(shù)據(jù)(Instance data)和對象填充(Padding)。
那下面就對這三塊區(qū)域進(jìn)行簡單介紹:
1、對象頭- 還是一個看臉的時代!
對象頭包括兩部分信息。第一部分用于存儲對象自身的運(yùn)行時數(shù)據(jù),如
- 哈希碼(HashCode),一個對象的hashcode是唯一的,如判斷一個對象是不是單例的!
- GC分代年齡(標(biāo)明是新生代還是老年代..)
- 鎖狀態(tài)標(biāo)志、線程持有的鎖、偏向線程ID(多線程,同步的時候用到)
- 其他等等….
注: 上面的幾個點(diǎn),要結(jié)合和關(guān)聯(lián)其他相關(guān)知識,理解會更加深入一點(diǎn)。
如 哈希碼hashCode,對下面兩個問題如果你又自己的一些思考,歡迎留言探討!
- 重寫了equals 必須要重寫hashcode,思考一下,為什么?如果不重寫在使用HashMap的時候會有出現(xiàn)什么問題?
- HashMap中相同key存入數(shù)據(jù)不替換,而是進(jìn)行疊加存儲,怎么實(shí)現(xiàn)?
問題2提示:只要重寫了key的hashCode()和Map的put()方法,其實(shí)就可以實(shí)現(xiàn)對于相同key下疊加存儲不同的value了。
第二部分是類型指針,即對象指向它的類元數(shù)據(jù)的指針,虛擬機(jī)通過指針來確定這個對象是那個類的實(shí)例。(就如我們上圖的箭頭,可以簡單理解為指針!)
說明:
(1)、并不是所有的虛擬機(jī)實(shí)現(xiàn)都是必須在對象數(shù)據(jù)上保留類型指針,也就是查找對象的元數(shù)據(jù)并一定經(jīng)過對象本身!
(2)、如果對象是一個Java數(shù)組,那在對象頭中還必須有一塊用于記錄數(shù)組長度的的數(shù)據(jù),因?yàn)樘摂M機(jī)可以通過普通Java對象的元數(shù)據(jù)確定Java對象的大小,但是從數(shù)組的元數(shù)據(jù)卻無法確定數(shù)組的大小。
2、實(shí)例數(shù)據(jù)-了解了外在美,還要注重內(nèi)在美!
實(shí)例數(shù)據(jù)部分是對象真正存儲的有效信息,也就是程序代碼中定義的各種類型的字段內(nèi)容。
不論是從父類繼承下來的,還是在子類中定義的,都需要記錄起來。記錄的存儲順序會受到虛擬機(jī)分配策略參數(shù)和字段在Java源碼中的定義的順序相關(guān)。
3、對齊填充-對齊填充成為標(biāo)準(zhǔn)網(wǎng)紅!
對象的填充并不是必然存在的,也沒有特別的含義,它僅僅起著占位符的作用!由于HotSpot VM的自動內(nèi)存管理系統(tǒng)要求兌現(xiàn)的起始地址必須是8字節(jié)的整數(shù)倍,也就是說對象的大小必須是8字節(jié)的整數(shù)倍。而對象頭部分正好是8字節(jié)的整數(shù)倍,因此當(dāng)對象實(shí)例數(shù)據(jù)部分沒有對齊時候,就需要填充來補(bǔ)全。
(類比記憶對齊填充,由于審美的標(biāo)準(zhǔn),有一些人天生就是俊俏的臉蛋和好的身材,不需要進(jìn)行其他的填充,有一些人可能有好看的臉蛋,但是某些地方和標(biāo)準(zhǔn)還差點(diǎn)意思,就需要填充來達(dá)到標(biāo)準(zhǔn))
tips:字節(jié)
字節(jié)(byte)計(jì)算機(jī)里用來存儲空間的基本計(jì)量單位。8個二進(jìn)制位(bit)構(gòu)成了一個字節(jié)(byte)即1byte=8bit。
三、如何“約”(定位)一個對象
認(rèn)識了一個對象后,不能總是聊微信,也要約一下吃個飯啥的! 那在Java中建立了一個對象,那肯定是要使用對象的。Java程序是如果找到具體的對象的呢?
在Java程序中需要通過棧上的reference數(shù)據(jù)來操作堆上的具體對象(如開篇的圖示,棧上面的引入指向堆中具體對象)。但是由于Reference類型在Java虛擬機(jī)規(guī)范中只規(guī)定了一個指向?qū)ο蟮囊?,并沒有定義這個引用應(yīng)該通過何種方式去定位、訪問堆中的對象的具體位置,所以對象訪問方式也是取決于虛擬機(jī)實(shí)現(xiàn)而定的。
目前主流的訪問方式有使用句柄和直接指針兩種。
第一:句柄
使用句柄訪問,在Java對中將會劃分出一塊內(nèi)存來作為句柄池,reference中存儲的就是對象的句柄地址,而句柄中包含了對象的實(shí)例數(shù)據(jù)與類型數(shù)據(jù)各自 的具體地址信息,如圖,

第二:直接指針
使用直接指針,在Java堆對象的布局中就必須考慮如果放置訪問類型數(shù)組的相關(guān)信息,而reference中存儲的直接就是對象的地址,如圖:

兩種方式都各自優(yōu)勢,簡單總結(jié):
句柄:最大的好處就是reference中存儲的是穩(wěn)定的句柄地址,在對象被移動(垃圾收集移動對象是非常普通的行為)時只會改變句柄中的實(shí)例數(shù)據(jù)指針,而Reference本身不需要修改。
直接指針:最大的好處就是速度更快,它節(jié)省一次指針定位的開銷,在Java中對象的訪問是非常頻繁的,因此能減少這類開銷對提高性能還是非??陀^的。
虛擬機(jī)Hotspot使用的就是直接指針這種方式。但是其他的語言和框架中使用句柄的情況也很常見!
四、本文總結(jié)
本文主要整理了Java中一個對象的創(chuàng)建,對象的內(nèi)存布局以及如何定位一個對象! 也讓我們知道對象不是你想new就可以new的,new出的對象想要“約”也是有不同方式的。