三分鐘帶你了解對象的創(chuàng)建過程
一、摘要
在之前的文章中,我們介紹了類加載的過程和 JVM 內(nèi)存布局相關(guān)的知識。本篇我們綜合之前的知識,結(jié)合代碼一起推演一下對象的真實創(chuàng)建過程,以及對象創(chuàng)建完成之后在 JVM 中是如何保存的。
二、對象的創(chuàng)建
在 Java 中,創(chuàng)建對象的方式有很多種,比如最常見的通過new xxx()來創(chuàng)建一個對象,通過反射Class.forName(xxx).newInstance()來創(chuàng)建對象等。其實無論是哪種創(chuàng)建方式,JVM 底層的執(zhí)行過程是一樣的。
對象的創(chuàng)建過程,可以用如下圖來簡要概括。
圖片
創(chuàng)建對象大致分為 5 個步驟:
1.檢查類是否加載,如果沒有就先執(zhí)行類的加載
2.分配內(nèi)存
3.初始化零值
4.設(shè)置頭對象
5.執(zhí)行初始化方法,例如構(gòu)造方法等
下面我們一起來看下每個步驟具體的工作內(nèi)容。
2.1、類加載檢查
當需要創(chuàng)建一個類的實例對象時,比如通過new xxx()方式,虛擬機首先會去檢查這個類是否在常量池中能定位到一個類的符號引用,并且檢查這個符號引用代表的類是否已經(jīng)被加載、解析和初始化,如果沒有,那么必須先執(zhí)行類的加載流程;如果已經(jīng)加載過了,會在堆區(qū)有一個類的 class 對象,方法區(qū)會有類的相關(guān)元數(shù)據(jù)信息。
為什么在對象創(chuàng)建時,需要有這一個檢查判斷?
主要原因在于:類的加載,通常都是懶加載,只有當使用類的時候才會加載,所以先要有這個判斷流程。
關(guān)于類的加載過程,在之前的文章中已經(jīng)有所介紹,有興趣的朋友可以翻看之前的文章。
2.2、分配內(nèi)存
類加載成功后,虛擬機就能夠確定對象的大小了,此時虛擬機會在堆內(nèi)存中劃分一塊對象大小的內(nèi)存空間出來,分配給新生對象。
虛擬機如何在堆中分配內(nèi)存的呢?
主要有兩種方式:
1.指針碰撞法
2.空閑列表法
下面我們一起來看看相關(guān)的內(nèi)存分配方式。
2.2.1、指針碰撞法
如果內(nèi)存是規(guī)整的,那么虛擬機將采用指針碰撞法來為對象分配內(nèi)存。
指針碰撞法,簡單的說就是所有用過的內(nèi)存在一邊,空閑的內(nèi)存在另一邊,中間放著一個指針作為分界點的指示器,分配內(nèi)存時會把指針向空閑一方挪動一段,直到能容納對象大小的位置。
如果垃圾收集器選擇的是 Serial、ParNew 這種基于壓縮算法的,虛擬機會采用這種分配方式。
2.2.2、空閑列表法
如果內(nèi)存不是規(guī)整的,已使用的內(nèi)存和未使用的內(nèi)存相互交錯,那么虛擬機將采用空閑列表法來為對象分配內(nèi)存。
空閑列表法,簡單的說就是在虛擬機內(nèi)部維護了一個列表,會記錄哪些內(nèi)存塊是可用的,在分配的時候會從列表中找到一塊能容納對象大小的空間,劃分給對象實例,并更新列表上的內(nèi)容。
如果垃圾收集器選擇的是 CMS 這種基于標記-清除算法的,虛擬機會采用這種分配方式。
2.2.3、內(nèi)存分配安全問題
我們知道,虛擬機是支持多個線程同時分配內(nèi)存的,是否會有線程安全的問題呢?
答案是:肯定存在的。比如用指針碰撞法時,虛擬機正在給對象 A 分配內(nèi)存,但指針還沒來及修改,此時又有一個線程給對象 B 分配內(nèi)存,同時使用了原來的指針來分配,最后的結(jié)果就是這個區(qū)域只分配來一個對象,另一個對象被覆蓋了。
針對內(nèi)存分配時存在的線程安全問題,虛擬機采用了兩種方式來進行處理:
- CAS+重試機制:通過 CAS 操作移動指針,只有一個線程可以移動成功,移動失敗的線程重試,直到成功為止
- TLAB (thread local Allocation buffer):也稱為本地線程分配緩沖,這個處理方式思想很簡單,就是當線程開啟時,虛擬機會為每個線程分配一塊較大的空間,然后線程內(nèi)部創(chuàng)建對象的時候,就從自己的空間分配,這樣就不會有并發(fā)問題了,當線程自己的空間用完了才會從堆中分配內(nèi)存,之后會轉(zhuǎn)為通過 CAS+重試機制來解決并發(fā)問題
以上就是虛擬機解決對象內(nèi)存分配時存在的線程安全問題的措施。
2.3、初始化零值
初始化零值,顧名思義,就是對分配的這一塊內(nèi)存初始化零值,也就是給實例對象的成員變量賦于零值,比如 int 類型賦值為 0,引用類型為null等操作。這樣對象就可以在沒有賦值情況下使用了,只不過訪問對象的成員變量都是零值。
2.4、設(shè)置頭對象
初始化零值完成之后,虛擬機就會對對象進行必要的設(shè)置,比如這個對象是哪個類的實例、如何才能找到類的元數(shù)據(jù)信息、對象的哈希碼、對象的 GC 分代年齡等信息,這些信息都會存放在對象頭中。這部分數(shù)據(jù),官方稱它為“Mark Word”。
在 HotSpot 虛擬機中,對象在內(nèi)存中存儲的布局可以分為 3 塊區(qū)域:對象頭 (Header)、 實例數(shù)據(jù) (Instance Data) 和對齊填充位 (Padding)。
以 32 位的虛擬機為例,對象的組成可以用如下圖來簡要概括。(64位的虛擬機略有不同)
圖片
各部分區(qū)域功能如下:
- 對象頭:分為 Mark Word 和元數(shù)據(jù)區(qū),如果是數(shù)組對象,還有記錄數(shù)組長度的區(qū)域。這三塊保存著對象的 hashCode 值,鎖的狀態(tài),類元數(shù)據(jù)指針,對象的分代年齡等等信息。
- 實例數(shù)據(jù):顧名思義,用于保存對象成員變量的值,如果變量是引用類型,保存的是內(nèi)存地址
- 對齊填充位:因為 HotSpot 虛擬機要求對象的起止地址必須是 8 字節(jié)的整數(shù)倍,也就是要求對象的大小為 8 字節(jié)的整數(shù)倍,如果不足 8 字節(jié)的整數(shù)倍,那么就需要通過對齊填充進行占位,補夠 8 字節(jié)的整數(shù)倍。
我們重點來看下 Mark Word 的組成,不同的操作系統(tǒng)環(huán)境下占用的空間不同,在 32 位操作系統(tǒng)中占 4 個字節(jié),在 64 位中占 8 個字節(jié)。
以 32 位操作系統(tǒng)為例,Mark Word 內(nèi)部結(jié)構(gòu)如下:
圖片
各部分的含義如下:
- identity_hashcode:25 位的對象標識哈希碼。采用延遲加載技術(shù),調(diào)用System.identityHashCode()方法獲取,并會將結(jié)果寫到該對象頭中。當對象被鎖定時,該值會移動到管程 Monitor 中。
- age:4 位的 Java 對象年齡。在GC中,如果對象在 Survivor 區(qū)復(fù)制一次,年齡增加 1,當對象達到設(shè)定的閾值時,將會晉升到老年代。默認情況下,并行 GC 的年齡閾值為 15,并發(fā) GC 的年齡閾值為 6。由于 age 只有4位,所以最大值為15,這就是為什么-XX:MaxTenuringThreshold選項最大值為 15 的原因。
- lock:2 位的鎖狀態(tài)標記位。對象的加鎖狀態(tài)分為無鎖、偏向鎖、輕量級鎖、重量級鎖等幾種標記,不同的標記值,表示的含義也不同。
- biased_lock:對象是否啟用偏向鎖標記,只占 1 個二進制位。為 1 時表示對象啟用偏向鎖,為 0 時表示對象沒有偏向鎖。偏向鎖是一種鎖的優(yōu)化手段,開啟偏向鎖,某些時候可以省去線程頻繁申請鎖的操作,提升程序執(zhí)行性能。
- thread:持有偏向鎖的線程 ID,如果該線程再次訪問這個鎖的代碼塊,可以直接訪問
- epoch:偏向鎖在 CAS 鎖操作過程中的標識
- ptr_to_lock_record:在輕量級鎖時,指向棧中鎖記錄的指針
- ptr_to_heavyweight_monitor:在重量級鎖時,指向管程 Monitor 的指針
其中l(wèi)ock參數(shù)中不同的標記值,表示的含義如下。
圖片
lock標記位,通常會在使用到synchronized關(guān)鍵字的對象上發(fā)揮作用。隨著線程之間競爭激烈程度,對象鎖會從無鎖狀態(tài)逐漸升級到重量級鎖,其中的變化過程,可以用如下步驟來概括。
1.初期鎖對象剛創(chuàng)建時,還沒有任何線程來競爭,鎖狀態(tài)為 01,偏向鎖標識位是0(無線程競爭它),此時程序執(zhí)行效率最高。
2.當有一個線程來競爭鎖時,先用偏向鎖,表示鎖對象偏愛這個線程,這個線程要執(zhí)行這個鎖關(guān)聯(lián)的任何代碼,不需要再做任何檢查和切換,這種競爭不激烈的情況下,效率也非常高。
3.當有兩個線程開始競爭這個鎖對象時,情況會發(fā)生變化,鎖會升級為輕量級鎖,兩個線程公平競爭,哪個線程先占有鎖對象并執(zhí)行代碼,鎖對象的 Mark Word 就執(zhí)行哪個線程的棧幀中的鎖記錄。輕量級鎖在加鎖過程中,用到了自旋鎖。所謂自旋,就是指當有另外一個線程來競爭鎖時,這個線程會在原地循環(huán)等待,而不是把該線程給阻塞,直到那個獲得鎖的線程釋放鎖之后,這個線程就可以馬上獲得鎖,執(zhí)行效率有所衰減。
4.如果競爭這個鎖對象的線程越來越多,會導(dǎo)致更多的切換和等待,JVM 會把該對象的鎖升級為重量級鎖。這個就是大家常說的同步鎖,此時對象中的 Mark Word 會再次發(fā)生變化,會指向一個監(jiān)視器 (Monitor) 對象,這個監(jiān)視器對象用集合的形式來登記和管理排隊的線程。Monitor 依賴操作系統(tǒng)的 MutexLock(互斥鎖)來實現(xiàn)線程排隊,線程被阻塞后便進入內(nèi)核(Linux)調(diào)度狀態(tài),這個會導(dǎo)致系統(tǒng)在用戶態(tài)與內(nèi)核態(tài)之間來回切換線程,相比其它級別的鎖,此時鎖的性能最差。
關(guān)于synchronized關(guān)鍵字原理分析,我們會在后續(xù)的文章中再次介紹。
2.5、執(zhí)行init方法
執(zhí)行 init 方法是對象創(chuàng)建的最后一步,虛擬機會給對象的成員變量設(shè)置用戶指定的初始值,并且會執(zhí)行構(gòu)造方法等。
2.6、小結(jié)
以上就是對象的創(chuàng)建過程,最后我們通過工具來看下對象創(chuàng)建后的大小。
可以添加第三方j(luò)ol包,使用它來打印對象的內(nèi)存布局情況。
<dependency>
<groupId>org.openjdk.jol</groupId>
<artifactId>jol-core</artifactId>
<version>0.9</version>
</dependency>
編寫一個測試類。
public class ObjectHeaderTest {
public static void main(String[] args) {
System.out.println("=========打印Object對象的大小========");
ClassLayout layout = ClassLayout.parseInstance(new Object());
System.out.println(layout.toPrintable());
System.out.println("========打印數(shù)組對象的大小=========");
ClassLayout layout1 = ClassLayout.parseInstance(new int[]{});
System.out.println(layout1.toPrintable());
System.out.println("========打印有成員變量的對象大小=========");
ClassLayout layout2 = ClassLayout.parseInstance(new ArtisanTest());
System.out.println(layout2.toPrintable());
}
/**
* ‐XX:+UseCompressedOops 表示開啟壓縮普通對象指針
* ‐XX:+UseCompressedClassPointers 表示開啟壓縮類指針
*
*/
public static class ArtisanTest {
int id; //4B
String name; //4B
byte b; //1B
Object o; //4B
}
}
輸出結(jié)果:
=========打印Object對象的大小========
java.lang.Object object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 01 00 00 00 (00000001 00000000 00000000 00000000) (1)
4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0)
8 4 (object header) e5 01 00 f8 (11100101 00000001 00000000 11111000) (-134217243)
12 4 (loss due to the next object alignment)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total
========打印數(shù)組對象的大小=========
[I object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 01 00 00 00 (00000001 00000000 00000000 00000000) (1)
4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0)
8 4 (object header) 6d 01 00 f8 (01101101 00000001 00000000 11111000) (-134217363)
12 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0)
16 0 int [I.<elements> N/A
Instance size: 16 bytes
Space losses: 0 bytes internal + 0 bytes external = 0 bytes total
========打印有成員變量的對象大小=========
com.example.thread.o4.ObjectHeaderTest$ArtisanTest object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 01 00 00 00 (00000001 00000000 00000000 00000000) (1)
4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0)
8 4 (object header) 12 f2 00 f8 (00010010 11110010 00000000 11111000) (-134155758)
12 4 int ArtisanTest.id 0
16 1 byte ArtisanTest.b 0
17 3 (alignment/padding gap)
20 4 java.lang.String ArtisanTest.name null
24 4 java.lang.Object ArtisanTest.o null
28 4 (loss due to the next object alignment)
Instance size: 32 bytes
Space losses: 3 bytes internal + 4 bytes external = 7 bytes total
三、對象的訪問方式
對象創(chuàng)建完之后,剩下的工作就是使用對象。Java 程序主要通過虛擬機棧上的 reference (引用) 數(shù)據(jù)來操作堆上的具體對象。
對象的訪問方式有虛擬機實現(xiàn)而定,不同的虛擬機實現(xiàn)訪問方式不同,目前主流的訪問方式有:
- 句柄訪問
- 直接指針訪問
3.1、句柄訪問
句柄訪問,虛擬機會在 Java 堆中劃分出一塊內(nèi)存來作為句柄池,reference 中存儲的就是對象的句柄地址,句柄中則包含了類數(shù)據(jù)的地址和實例數(shù)據(jù)的地址信息。
使用句柄方式最大的好處就是,reference 中存儲的是穩(wěn)定的句柄地址,在對象被移動時(垃圾收集時移動對象是非常普遍的行為),只會改變句柄中的實例數(shù)據(jù)指針,而 reference 不需要被修改。
圖片
3.2、直接指針訪問
直接指針訪問,reference 中直接存儲的就是對象地址,而對象中存儲了所有的實例數(shù)據(jù)和類數(shù)據(jù)的地址。
使用直接指針方式,最大的好處就是速度更快,它節(jié)省了一次指針定位的時間開銷。
圖片
就 HotSpot 虛擬機而言,它使用的是直接指針訪問方式來定位對象,從其它虛擬機實現(xiàn)來看,使用句柄訪問方式也是十分常見的。
四、對象的內(nèi)存分配原則
在之前的 JVM 內(nèi)存結(jié)構(gòu)布局的文章中,我們介紹到了 Java 堆的內(nèi)存布局,由 年輕代 (Young Generation) 和老年代 (Old Generation) 組成,默認情況下按照1 : 2的比例來分配空間。
其中年輕代又被劃分為三個不同的區(qū)域:Eden 區(qū)、From Survivor 區(qū)、To Survivor 區(qū),默認情況下按照8 : 1 : 1的比例來分配空間。
Java 堆的內(nèi)存布局,可以用如下圖來概括。
圖片
當創(chuàng)建的對象不再被使用了是需要被回收掉的,以便騰出空間給新的對象使用,這就是對象的垃圾回收,也就是對象的 GC,我們會在后續(xù)的文章中再次介紹對象的垃圾回收算法以及垃圾收集器。
本次我們重點介紹下,創(chuàng)建不同大小的對象,在堆空間中發(fā)生的內(nèi)存分配變化,以便后續(xù)更好的理解 GC 調(diào)優(yōu)過程。
4.1、對象優(yōu)先分配在 Eden 區(qū)
默認情況下,創(chuàng)建的對象會優(yōu)先分配在年輕代的 Eden 區(qū),當 Eden 區(qū)不夠用的時候,會觸發(fā)一次 Minor GC。
什么是 Minor GC 呢?
Minor GC 指的是 JVM 發(fā)生在年輕代的垃圾回收動作,效率高、速度快,但是只清除年輕代的垃圾對象。
與之對應(yīng)的還有 Major GC 和 Full GC,Major GC 指的是 JVM 發(fā)生在老年代的垃圾回收動作,Major GC 的速度一般要比 Minor GC 慢 10 倍以上;同時,許多 Major GC 是由 Minor GC 引起的,因此把這個過程也稱之為 Full GC,也就是對整個堆進行垃圾回收。
當 Eden 區(qū)滿了以后,會發(fā)生 Minor GC,存活下來的對象會被移動到 Survivor 區(qū),如果 Survivor 區(qū)裝不下這批對象,此時會直接移動到老年代。
通常年輕代的對象存活時間都很短,在 Minor GC 后,大部分的對象都會被回收掉,但是也不排除個例情況,存活下來的對象的年齡會進行 +1,當年齡達到 15歲時,也會被移動到老年代。
用戶可以通過-XX:MaxTenuringThreshold參數(shù)來調(diào)整年齡的閥值,默認是 15,最大值也是 15。
4.2、大對象直接進入老年代
所謂大對象,顧名思義就是占用內(nèi)存比較多的對象,大對象一般可以直接分配到老年代,這是 JVM 的一種優(yōu)化設(shè)計。
用戶可以手動通過-XX:PretenureSizeThreshold參數(shù)設(shè)置大對象的大小,默認是 0,意味著任何對象都會優(yōu)先在年輕代的 Eden 區(qū)分配內(nèi)存。
試想一下,假如大對象優(yōu)先在 Eden 區(qū)中分配,給其它的對象預(yù)留的空間就會變小,此時很容易觸發(fā) Minor GC,經(jīng)過多次 GC 之后,大對象可能會繼續(xù)存活,最終還是會被轉(zhuǎn)移到老年代。
與其如此,還不如直接分配到老年代。
4.3、對象動態(tài)年齡判斷機制
對象動態(tài)年齡判斷,簡單的說就是對 Survivor 區(qū)的對象年齡從小到大進行累加,當累加到 X 年齡(某個年齡)時占用空間的總和大于 50%,那么比 X 年齡大的對象都會移動到老年代。
這種機制是 JVM 的一個預(yù)測機制,虛擬機并不是完全要求對象年齡必須達到 15 才能移動到老年代。當 survivor 區(qū)快要滿了并且存在一批可能會長期存活的對象,那不如提前進入老年代,減少年輕代的壓力。
用戶可以使用-XX:TargetSurvivorRatio參數(shù)來設(shè)置保留多少空閑空間,默認值是 50。
4.4、逃逸分析
逃逸分析是一項比較前沿的優(yōu)化技術(shù),它并不是直接優(yōu)化代碼的手段,而是為其它優(yōu)化手段提供了分析技術(shù)。
什么是逃逸分析呢?
當一個對象在方法里面被定義后,它可能被外部方法所引用,例如作為調(diào)用參數(shù)傳遞到其他方法中,這種稱為方法逃逸;甚至還有可能被外部線程訪問到,譬如賦值給可以在其他線程中訪問的實例變量,這種稱為線程逃逸。
如果能證明一個對象不會逃移到方法外或者線程之外,換句話說就是別的方法或線程無法通過任何途徑訪問到這個對象,虛擬機可以通過一些途徑為這個變量進行一些不同程度的優(yōu)化。
比如棧上分配、同步消除、標量替換等優(yōu)化操作。
4.4.1、棧上分配
在上文我們提及到,對象會優(yōu)先在堆上分配,垃圾收集器會定期回收堆內(nèi)存中不再使用的對象,但這塊的內(nèi)存回收很耗時。
如果確定一個對象不會逃逸出方法之外,讓這個對象在棧上分配,對象所占用的內(nèi)存空間就可以隨著棧幀出棧而銷毀,這樣垃圾收集器的壓力將會小很多。
4.4.2、同步消除
線程同步本身是一個相對耗時的操作,如果逃逸分析能夠確定一個變量不會逃逸出線程,無法被其他線程訪問,那么這個變量的讀寫肯定就不會有競爭,此時虛擬機會對這個變量,實施的同步措施進行消除,比如去掉同步鎖來運行方法。
4.4.3、標量替換
標量是指一個數(shù)據(jù)已經(jīng)無法再分解成更小的數(shù)據(jù)來表示了,比如 Java 虛擬機中的原始數(shù)據(jù)類型(int,long 等數(shù)值類型以及 reference 類型)等都不能進一步分解,它們可以稱為標量。相對的,如果一個數(shù)據(jù)可以繼續(xù)分解,那它稱為聚合量。
Java 中最典型的聚合量是對象,如果逃逸分析證明一個對象不會被外部訪問,并且這個對象是可分解的,那程序真正執(zhí)行的時候?qū)⒖赡懿粍?chuàng)建這個對象,而改為直接創(chuàng)建它的若干個被這個方法使用到的成員變量來代替,拆散后的變量便可以被單獨分析與優(yōu)化,可以各自分別在棧幀或寄存器上分配空間,原本的對象就無需整體分配空間了。
默認情況下逃逸分析是關(guān)閉的,用戶可以使用-XX:+DoEscapeAnalysis參數(shù)來手動開啟逃逸分析。
4.5、小結(jié)
綜合以上的內(nèi)容,對象的內(nèi)存分配流程,可以用如下圖來概括。
圖片
五、小結(jié)
本文主要從虛擬機層面,對對象的創(chuàng)建過程,訪問方式以及內(nèi)存分配時的空間變化進行了一次知識整合和總結(jié),如果有描述不對的地方,歡迎大家留言指出,不勝感激。
六、參考
1.https://zhuanlan.zhihu.com/p/267223891
2.https://zhuanlan.zhihu.com/p/401057707
3.https://www.cnblogs.com/xrq730/p/4827590.html
4.https://www.jianshu.com/p/3d38cba67f8b
6.https://blog.csdn.net/clover_lily/article/details/80095580
7.https://blog.csdn.net/FIRE_TRAY/article/details/51275788
8.https://blog.csdn.net/yb970521/article/details/108015984