JVM 是如何創建對象的?你瞭解嗎?
JDK 版本:1.8
1、對象的創建的方式
Java 語言中,對象創建的方式有六種:
1.1、new 關鍵字:
最常見的形式、Xxx 的靜態方法、XxxBuilder、XxxFactory 的靜態方法。
1.2、Class 類的 newInstance() 方法:
通過反射的方式創建對象,調用類的無參構造器進行對象的創建,且其訪問權限爲 public。
1.3、Constructor 的 newInstance() 方法:
通過反射的方式創建對象,調用類的無參、有參構造器進行對象的創建,對構造器訪問權限沒有要求。
1.4、使用 clone() 方法:
不調用任何構造器,但是要求當前類實現 Cloneable 接口,重寫 clone() 方法。
1.5、使用序列化:
從文件、網絡中獲取一個對象的二進制流。
1.6、使用第三方庫
Objenesis。
2、對象創建的具體過程
這裏討論的是普通的 Java 對象,不包括數組和 Class 對象等。
2.1、對象類加載檢查
當 Java 虛擬機遇到一條字節碼 new 指令時,首先將去檢查這個指令的參數是否能在 Metaspace 的常量池中定位到一個類的符號引用,並且檢查這個符號引用代表的類是否已經被加載、解析、和初始化,即判斷該類型的元信息是否存在。
如果沒有定位到該類的符號引用,JVM 會使用雙親委派模式使用當前類的類加載器以 ClassLoader + 包名 + 類名 作爲 key 進行查找對應的. class 文件。如果找到了. class 文件則進行類加載,並生成對應的 Class 對象。如果沒有找到則拋出 ClassNotFoundException 異常。
2.2、爲對象分配堆內存
在類加載檢查通過後,接下來 JVM 虛擬機將爲新生對象分配堆內存。對象所需的內存大小在類加載完成後便可完全確定。爲對象分配內存空間的任務實際上便等同於將一塊確定大小的內存區域從 JVM 堆中劃分出來,這裏的對內劃分分爲兩種情況:
假設 JVM 堆中內存是絕對規整的,所有被使用過的內存都被放在一邊,空閒的內存放在另一邊,已使用與未使用內存之間使用一個指針作爲內存分界點的指示器,在這種情況下爲新生對象分配內存只需要將指針朝着空閒內存的方向移動一段空間與新生對象內存大小相等的距離即可,這種分配方式稱爲指針碰撞 (Bump The Pointer)。
如果 JVM 堆中內存不是絕對規整的,已使用的內存與空閒內存相互交錯在一起,那就沒有辦法進行簡單的指針碰撞分配內存了,JVM 必須維護一個堆內存的空閒列表,在爲新生對象分配內存時必須在空閒列表中尋找一塊符合要求的內存空間劃分給新生對象。分配成功之後並將列表進行更新,這種分配方式稱爲空閒列表 (Free List)。
選擇哪種分配方式是由 JVM 堆內存是否規整決定的,而 JVM 堆內存是否規則是由 JVM 所採用的垃圾收集器是否帶有空間壓縮整理 (Compact) 能力決定的。所以當使用 Serial、ParNew 等帶壓縮整理過程的垃圾收集器時,系統採用的對象內存分配算法是指針碰撞,既簡單又高效。而當使用 CMS 這種基於清除 (Sweep) 算法收集垃圾時,理論上就只能採用較爲複雜的空閒列表算法來分配內存。
2.3、內存分配併發問題
對象的創建在 JVM 中是非常頻繁的行爲,即使僅僅是修改一個指針所指向的位置,在併發場景下也是非線程安全的。解決這個問題有兩種可選方案:
對爲對象分配內存的動作進行同步處理,採用 CAS 以及失敗重試的方式保證更新操作的原子性。
將爲對象分配內存的動作按照線程劃分在不同的區域中進行,即每個線程在堆中預先分配一塊小內存,稱爲本地線程分配緩衝區 (Thread Local Allocation Buffer TLAB),這部分內存空間是線程隔離的,在爲新生對象分配內存時先嚐試在 TLAB 中進行分配,如果本地線程分配緩衝區用完了,再嘗試使用 CAS 以及失敗重試的方式進行分配。虛擬機是否使用 TLAB 可以通過 - XX:+UseTLAB 選項進行配置。
2.4、初始化分配到的內存
在內存分配完成之後,虛擬機必須將分配到的內存空間進行初始化,初始化過程不包括對象頭。內存初始化的作用是對類中的所有屬性設置默認值,如果使用了 TLAB 這一項工作也可以提前至 TLAB 分配內存成功後一同進行。這步操作保證了對象的實例字段在 Java 代碼中可以不賦初始值就直接使用,使程序都能訪問到這些實例字段的數據類型所對應的零值。
2.5、設置對象頭
初始化內存之後,JVM 還會將一些信息設置到對象頭中。JVM 會將對象所屬的類 (即類的元信息數據)、如何才能尋找到類的元數據信息、對象的哈希碼(對象的哈希碼會延遲到正真調用 Object#hashCode() 方法時才進行計算)、對象的 GC 分代年齡等信息存放在對象的對象頭 (Object Header) 中。
2.6、執行 ()
當上述所有步驟執行完成之後,從 JVM 的角度看一個新生對象已經創建完成了,但是從 Java 程序的角度看,對象創建纔剛剛開始–構造函數。即 Class 文件中的 () 方法還未執行,此時對象中的所有實例字段都還爲默認的零值,對象需要的其它資源和狀態信息也還未按照預定的意圖構建好。
一般來說執行初始化動作由字節碼流中 new 指令後面是否跟隨 invokespecial 指令所決定,Java 編譯器會在遇到 new 關鍵字地方同生成這兩條字節碼指令,但是通過其它方式並不一定會如此。new 指令之後會直接執行 () 方法,按照開發者的意願對對象進行初始化動作,至此一個真正可用的 Java 對象纔算完全被創建出來。
綜上所述,爲對象屬性賦值的操作:
1、屬性的默認初始化
2、顯示初始化
3、代碼塊中初始化
4、構造器中初始化
對象實例化的過程:
1、加載類元信息
2、爲對象分配內存,同時處理併發問題
3、屬性的默認初始化
4、設置對象頭信息
5、屬性的顯示初始化、代碼塊中初始化、構造器中初始化
3、對象的內存佈局
在 Hotsopt VM 中,對象在堆內存中存儲佈局可以劃分爲三個部分_:對象頭 (Object Header)、實例數據(Instance Data) 和對齊填充(Padding)_。
3.1、對象頭 (Object Header)
Hotsopt VM 中對象的對象頭中包含兩類信息:
第一類是用於村粗對象自身的運行時數據,如哈希碼 (hashCode)、GC 分代年齡、鎖狀態標誌、線程持有的鎖、偏向線程 ID、偏向時間戳等,這部分數據的長度在 32 位和 64 位的虛擬機(未開啓壓縮指針) 中分別爲 32 個比特和 64 個比特,這類數據官方稱爲 Mark Word。
對象頭中另外一部分是類型指針,即實例對象指向它的類型元數據的指針,Java 虛擬機通過這個指針來確定該對象是哪個類的實例。
此外如果對象是一個 Java 數組,對象頭中必須有一塊用於記錄數組長度的數據,因爲 JVM 可以通過普通 Java 對象的元數據信息確定 Java 對象的大小,如果數組長度是不確定的,JVM 將無法通過元數據中的信息推斷出數組的大小。
3.2、實例數據 (Instance Data)
實例數據部分是對象真正存儲的有效信息,即在 Java 代碼中定義的各種類型的字段內容,無論是父類中繼承下來的還是在子類中定義的字段都必須在此進行記錄。
3.3、對齊填充 (Padding)
對齊填充並不是比如存在的,也沒有特殊的含義,它僅僅是起着佔位符的作用。由於 Hotspot 虛擬機的自動內存管理系統要求對象地址的起始地址必須是 8 字節的整數倍,換而言之就是所有 Java 對象的大小都必須是 8 字節的整數倍。其中對象頭部分已被精心設計成正好是 8 字節的整數倍,因此如果在實例數據部分沒有對齊的話,就需要對齊填充部分進行補全。
創建一個 Account 類:
public class Account {
public Account() {
System.out.println("Account‘s No Arg Construt");
}
}
創建一個 Customer 類:
public class Customer {
private Integer id = 100001;
private String name;
private Account account;
{
name = "kapcd";
}
public Customer() {
System.out.println("Customer's No Arg Construct");
account = new Account();
}
public static void main(String[] args) {
Customer customer = new Customer();
}
}
其對象內存佈局如下圖所示:
3.4、對象的訪問定位
創建對象自然是爲了後續使用該對象,Java 應用程序會通過棧上的 reference 數據來操作堆上的具體對象。由於 reference 類型在《Java 虛擬機規範》中只規定了它是一個指向對象的引用,並沒有具體規定這個引用應該通過什麼方式去定位、訪問到堆中具體位置,所以對象訪問方式也是由虛擬機而實現的,主流的訪問方式有使用句柄和直接指針兩種:
-
如果使用句柄訪問的方式,Java 堆中可能會劃分出一塊內存空間來作爲句柄池,reference 中存儲的就是對象的句柄地址,而句柄中包含了對象實例數據與類型數據各自具體的地址信息。
-
如果使用直接指針訪問的方式,Java 堆中對象的內存佈局就必須考慮如何放置訪問類型數據相關的信息,reference 中存儲的直接就是對象地址,如果只是訪問對象本身的話,就不需要多一次間接訪問的開銷。
下圖爲句柄訪問方式:
下圖爲直接指針訪問方式:
以上兩種對象訪問方式各有千秋,使用句柄來訪問的最大好處就是 reference 中存儲的是穩定的句柄地址,當對象應該垃圾收集而被移動時只會改變句柄中的實例數據指針,而 reference 本身不需要被修改。
使用直接指針訪問對象最大的好處就是速度快,它節省了一次指針定位的時間開銷,由於對象訪問在 Java 中是一種非常頻繁的行爲,因此這類開銷積少成多也是一項極爲可觀的執行成本。
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/ZruvGUo13D-PmXuQz-U3_g