JVM源碼分析-對象的創建過程
在開始MySQL的學習之前,還想寫一篇文章把前面學習的知識點回顧一下,就有了今天的這篇文章。
示例
有類School,這個類中有3個成員變量:引用類型String類型的schoolName,通過顯式代碼塊初始化;基本數據類型int型studentsNum,顯式初始化;引用類型Class類型student,通過School的構造函數初始化。
我們使用main函數創建School的一個對象,那么這個過程發生了哪些事情?在JVM內存中多了什么呢?讓我們一起看下吧!
- public class School {
- private String schoolName;
- private int studentsNum = 10000;
- private Student student;
- {
- schoolName = "清華大學";
- }
- public School(){
- student = new Student();
- }
- }
- class Student{
- }
- class Test{
- public static void main(String[] args) {
- School school = new School();
- }
- }
當我們執行new School()時,進行了對象的創建,大致可以分為以下5步:
在詳細了解這5個步驟之前我們再詳細聊一下對象頭,在synchronized鎖升級過程分析的時候我們已經初步接觸過它。
對象的內存布局
對象在堆空間的內存布局包含了3個部分:對象頭(Header)、實例數據(Intance Data)、對齊填充(Padding)。
對象頭
對象頭包含了兩部分:運行時元數據、指向類元數據的指針kclass,確認這個對象所屬的類型。
運行時元數據(Mark Word)包含:哈希值、GC分代年齡、鎖狀態標志位、偏向線程ID。運行時元數據的信息是變化的,在synchronized鎖的升級過程中,Mark Word在不同的鎖狀態下是不一樣的。
下圖展示展示了無鎖狀態、偏向鎖、輕量級鎖、重量鎖以及對象被GC標記的對象頭中的運行時數據信息:
實例數據
實例數據是對象真正存儲的有效信息,它包含了對象中定義的各種類型的字段。這些字段有對象本身定義的,也有從所有父對象繼承的字段。
父類的構造方法先于子類執行,所以父類變量的定義都在子類前面。
對齊填充
對齊填充不是必須的,也沒有實在的意義,它僅僅是個占位符的作用。HotSpot虛擬機要求對象的起始地址必須是8字節的整數倍,因此當對象沒有滿足的時候,就需要對齊填充來補全。
現在我們已經了解了對象在堆內存的布局,在之前的JVM文章中也學習了虛擬機棧結構和方法區(JDK1.8之后稱為元空間,勾勾之前習慣稱為方法區,但是怕大家混淆后續我們都用元空間表示),那么接下來我們詳細分析school對象創建的整個過程。
對象創建的步驟
對象的創建是在主線程的main()方法中,所以在主線程的虛擬機棧中就會創建main()的棧幀,main()就是當前方法。
我們回顧下棧和棧幀。
JVM內存區域劃分為5個模塊:堆、元空間、虛擬機棧、本地方法棧和程序計數器(也成為pc寄存器)。
虛擬機棧和本地方法棧都屬于棧,本地方法棧中只存放native方法的棧信息。
虛擬機棧的生命周期和線程的生命周期一致,它隨著線程的創建而創建,隨著線程的銷毀而銷毀,所以它是線程私有的內存區域。
虛擬機棧是由棧幀組成的,棧幀中包含了局部變量表、操作數棧、動態鏈接、方法返回地址、附加信息。棧幀是隨著方法的調用而創建的。所以當主線程調用main()方法時,此時在主線程的虛擬機棧中就創建了main()棧幀。
main()棧幀中的局部變量表包含兩個變量:args和school。
主線程的虛擬機棧的棧幀結構如下圖:
main()方法想要將school這個局部變量實例化,就需要執行School這個類的實例化。
那么new School()發生了什么呢?我們接下來詳細分析之前的5個步驟。
判斷對象的類是否已經加載
當虛擬機遇到new這個指令時,會首先檢查這個指令的參數能否在元空間的常量池中定位到一個類的符號引用,并且檢查這個符號引用代表的類是否已經被加載,即判斷元空間中是否包含這個類的類元信息。
我們通過javap -v -p Test.clas查看Test類的字節碼信息:
- Classfile /E:/study/javacodegirl/src/main/java/com/study/test/code/girl/base/jvm/Test.class
- Last modified 2021-2-21; size 352 bytes
- MD5 checksum 2df3d394ac88d2aa4da9d27f848067c5
- Compiled from "School.java"
- class com.study.test.code.girl.base.jvm.Test
- minor version: 0
- major version: 52
- flags: ACC_SUPER
- Constant pool:
- #1 = Methodref #5.#14 // java/lang/Object."<init>":()V
- #2 = Class #15 // com/study/test/code/girl/base/jvm/School
- #3 = Methodref #2.#14 // com/study/test/code/girl/base/jvm/School."<init>":()V
- #4 = Class #16 // com/study/test/code/girl/base/jvm/Test
- #5 = Class #17 // java/lang/Object
- #6 = Utf8 <init>
- #7 = Utf8 ()V
- #8 = Utf8 Code
- #9 = Utf8 LineNumberTable
- #10 = Utf8 main
- #11 = Utf8 ([Ljava/lang/String;)V
- #12 = Utf8 SourceFile
- #13 = Utf8 School.java
- #14 = NameAndType #6:#7 // "<init>":()V
- #15 = Utf8 com/study/test/code/girl/base/jvm/School
- #16 = Utf8 com/study/test/code/girl/base/jvm/Test
- #17 = Utf8 java/lang/Object
- {
- com.study.test.code.girl.base.jvm.Test();
- descriptor: ()V
- flags:
- Code:
- stack=1, locals=1, args_size=1
- 0: aload_0
- 1: invokespecial #1 // Method java/lang/Object."<init>":()V
- 4: return
- LineNumberTable:
- line 28: 0
- public static void main(java.lang.String[]);
- descriptor: ([Ljava/lang/String;)V
- flags: ACC_PUBLIC, ACC_STATIC
- Code:
- stack=2, locals=2, args_size=1
- 0: new #2 // class com/study/test/code/girl/base/jvm/School
- 3: dup
- 4: invokespecial #3 // Method com/study/test/code/girl/base/jvm/School."<init>":()V
- 7: astore_1
- 8: return
- LineNumberTable:
- line 30: 0
- line 31: 8
- }
- SourceFile: "School.java"
在main()中new指令的參數是#2,我們可以在Constant pool中找到#2對應的類信息。
如果沒有這個類的信息,那么就會按照雙親委派模型加載School類。
類的加載過程:加載、連接、初始化,其中連接包括:驗證、準備、解析。
執行類的加載的是類加載器,它分為:啟動類加載器、擴展類加載器、應用類加載器和自定義加載器。
School類是ClassPath下的文件,它的類加載是應用類加載器,當應用類加載器按照ClassLoader+包名+類名查找對應的.class文件時,如果找不到這個文件就會拋出ClassNotFoundException異常,如果找到了則進行類的加載,并生成對應的Class類對象。這個時候在元空間中就有了School的類元數據了。
為對象分配內存空間
接下來就需要計算對象占用的空間大小,基本類型除了long和double是8個字節,byte和boolean是1個字節,char和short是2個字節,其他基本類型都是4個字節,引用類型也是4個字節。
內存大小計算好之后在堆中劃分一塊內存空間給新對象。大部分情況下,對象是在新生代的Eden區中分配,如果此時Eden區沒有足夠的內存空間進行分配,虛擬機將發起一次Minor GC。但是當我們為一個很長的字符串或者數組分配內存時,這種類型的大對象需要連續的內存空間,可以直接在老年代進行分配,這樣做可以避免Eden和兩個S區發生大量的內存復制。但是大對象可能會導致連續空間不足而提前觸發GC,我們開發中也應該盡量避免大對象。
內存分配有兩種方式:指針碰撞和空閑列表分配。
- 指針碰撞:當內存使用的GC算法是標記整理或者復制算法時,內存是規整的,此時我們為對象分配內存只需要移動指針位置就可以。Serial和ParNew使用的GC回收算法是標記復制算法,內存的分配就是指針碰撞的方式。
- 空閑列表分配:當內存使用的GC算法是標記清除算法時,內存是規整的,這個時候維護了內存空閑的列表,在為新對象分配內存時從空閑列表中找到內存就可以。CMS使用的GC回收算法是標記清除算法,內存的分配方式就是空閑列表分配。
看完內存的分配你有沒有疑問?堆內存是所有線程共享的,如果兩個線程同時都想占用這一塊內存空間怎么辦呢?這就涉及到了分配內存空間時的并發安全問題。
JVM提供了兩種處理并發安全的方式:一種是我們常用的CAS失敗重試+區域鎖來保證內存分配的原子性,另外一種是通過開啟-XX:+UseTLAB參數為每個線程預分配一塊TLAB,在JDK1.8中這個參數是默認開啟的。
經過了這一步之后,堆內存中就有了School實例的一塊內存區域了:
初始化分配到的內存空間
屬性的賦值操作分為3個類型,我們在示例中都有舉例:
- 默認值初始化
- 顯式初始化和代碼塊初始化
- 構造方法初始化
初始化分配到的內存空間是默認值初始化,它為類的成員變量設置默認值,保證對象實例字段在不賦值時可以直接使用。基本數據類型的默認值為0,布爾類型的默認值為false,引用類型的默認值為null。
不要把這一步的初始化和類加載過程中的初始化混淆了!
類加載過程中的初始化是對類的靜態變量初始化,不包含類的實例變量。
執行了這一步之后,內存中的情況如下圖:
設置對象的對象頭
將對象的所屬的類、對象的HashCode值、對象的GC信息、鎖信息等數據存放在對象頭中。它取決于JVM實現。對象頭的信息我們前面已經講過,這里不再贅述。
執行了這一步之后內存中的數據變化:
執行init進行初始化
這個時候初始化過程才真正開始。這個過程是對應字節碼invokespecial,執行init方法。
它會執行實例化代碼塊、調用類的構造方法、將堆內對象的首地址賦值給引用變量。這一步之后真正可用的對象才算創建完成。
執行了這一步之后內存中的變化如下圖:
總結
對象的創建過程:類元數據加載->分配內存空間并解決并發問題->初始化分配的內存空間->設置對象頭信息->執行init方法進行初始化。
對象的整個創建過程大家要對JVM的內存區域比較了解,熟悉每個區域存放的數據,并知道在哪個過程存的數據。
類元數據的加載是元空間的數據來源,我們還可以回顧下類加載機制、雙親委派模型、哪些場景下需要打破雙親委派,之前勾勾分析了JDBC的SPI機制,利用線程上下文類加載器打破雙親委派。
對象的創建都是基于堆空間的,我們可以回顧下堆空間的內存分配、GC回收算法和GC回收器。
設置對象頭信息我們需要了解對象頭,還可以按照對象頭的數據變化回顧synchronized鎖的升級過程。
對象創建之后內存的數據變化如下圖: