今天,我們就來解決一個問題,一個類實例究竟要經過多少個步驟才能被創建出來,也就是下面這行代碼的背后,JVM 做了哪些事情?
Object obj = new Object();
當虛擬機接受到一條 new 指令時,首先會拿指令后的參數,也就是我們類的符號引用,于方法區中進行檢查,看是否該類已經被加載,如果沒有則需要先進行該類的加載操作。
一旦該類已經被加載,那么虛擬機會根據類型信息在堆中分配該類對象所需要的內存空間,然后返回該對象在堆中的引用地址。
一般而言,虛擬機會在 new 指令執行結束后,顯式調用該類的對象的 <init> 方法,這個方法需要程序員在定義類的時候給出,否則編譯器將在編譯期間添加一個空方法體的 <init> 方法。
以上步驟完成后,基本上一個類的實例對象就算是被創建完成了,才能夠為我們程序中使用,下面我們詳細的了解每個步驟的細節之處。
初始化父類
知乎上看到一個問題:
Java中,創建子類對象時,父類對象會也被一起創建么?
有關這個問題,我還特意去搜了一下,很多人都說,一個子類對象的創建,會對應一個父類對象的創建,并且這個子類對象會保存這個父類對象的引用以便訪問父類對象中各項信息。
這個答案肯定是不對的,如果每一個子類對象的創建都要創建其所有直接或間接的父類對象,那么整個堆空間豈不是充斥著大量重復的對象?這種內存空間的使用效率也會很低。
我猜這樣的誤解來源于 《Thinking In Java》 中的一句話,可能大家誤解了這段話,原話很多很抽象,我簡單總結了下:
虛擬機保證一個類實例初始化之前,其直接父類或間接父類的初始化過程執行結束
看一段代碼:
public class Father {
public Father(){
System.out.println("father's constructor has been called....");
}
}
public class Son extends Father {
public Son(){
System.out.println("son's constructor has been called ...");
}
}
public static void main(String[] args){
Son son = new Son();
}
輸出結果:
father's constructor has been called....
son's constructor has been called ...
這里說的很明白,只是保證父類的初始化動作先執行,并沒有說一定會創建一個父類對象引用。
這里很多人會有疑惑,虛擬機保證子類對象的初始化操作之前,先完成父類的初始化動作,那么如果沒有創建父類對象,父類的初始化動作操作的對象是誰?
這就涉及到對象的內存布局,一個對象在堆中究竟由哪些部分組成?
HotSpot 虛擬機中,一個對象在內存中的布局由三個區域組成:對象頭,實例數據,對齊填充。
對象頭中保存了兩部分內容,其一是自身運行的相關信息,例如:對象哈希碼,分代年齡,鎖信息等。其二是一個指向方法區類型信息的引用。
對象實例數據中存儲的才是一個對象內部數據,程序中定義的所有字段,以及從父類繼承而來的字段都會被記錄保存。
像這樣:
當然,這里父類的成員方法和屬性必須是可以被子類繼承的,無法繼承的屬性和方法自然是不會出現在子類實例對象中了。
粗糙點來說,我們父類的初始化動作指的就是,調用父類的 <init> 方法,以及實例代碼塊,完成對繼承而來的父類成員屬性的初始化過程。
對齊填充其實也沒什么實際的含義,只是起到一個占位符的作用,因為 HotSpot 虛擬機要求對象的大小是 8 的整數倍,如果對象的大小不足 8 的整數倍時,會使用對齊填充進行補全。
所以不存在說,一個子類對象中會包含其所有父類的實例引用,只不過繼承了可繼承的所有屬性及方法,而所謂的「父類初始化」動作,其實就是對父類 <init> 方法的調用而已。
this 與 super 關鍵字
this 關鍵字代表著當前對象,它只能使用在類的內部,通過它可以顯式的調用同一個類下的其他方法,例如:
public class Son {
public void sayHello(){
System.out.println("hello");
}
public void introduce(String name){
System.out.println("my name is:" + name);
this.sayHello();
}
}
因為每一個方法的調用都必須有一個調用者,無論你是類方法,或是一個實例方法,所以理論上,即便在同一個類下,調用另一個方法也是需要指定調用者的,就像這里使用 this 來調用 sayHello 方法一樣。
并且編譯器允許我們在調用同類的其他實例方法時,省略 this。
其實每個實例方法在調用的時候都默認會傳入一個當前實例的引用,這個值最終被傳遞賦值給變量 this。例如我們在主函數中調用一個 sayHello 方法:
public static void main(String[] args){
Son son = new Son();
son.sayHello();
}
我們反編譯主函數所在的類:
字節碼指令第七行,astore_1 將第四行返回的 Son 實例引用存入局部變量表,aload_1 加載該實例引用到操作數棧。
接著,invokevirtual #4 會調用一個虛方法(也就是一個實例方法),該方法的符號引用為常量池第四項,除此之外,編譯器還會將操作數棧頂的當前實例引用作為方法的一個參數傳入。
可以看到,sayHello 方法的局部變量表中的 this 的值 就是方法調用時隱式傳入的。這樣你在一個實例方法中不加 this 的調用其他任意實例方法,其實調用的都是同一個實例的其他方法。
總的來說,對于關鍵字 this 的理解,只需要抓住一個關鍵點就好:它代表的是當前類實例,并且每個非靜態方法的調用都必定會傳入當前的實例對象,而被調用的方法默認會用一個名為 this 的變量進行接收。
這樣做的唯一目的是,實例方法是可以訪問實例屬性的,也就是說實例方法是可以修改實例屬性數據值的,所以任何的實例方法調用都需要給定一個實例對象,否則這些方法將不知道讀寫哪個對象的屬性值。
那么 super 關鍵字又代表著誰,能夠用來做什么呢?
我們說了,一個實例對象的創建是不會創建其父類對象的,而是直接繼承的父類可繼承的字段,大致的對象內存布局如下:
this 關鍵字可以引用到當前實例對象的所有信息,而 super 則只能引用從直接父類那繼承來的成員信息。
看一段代碼:
public class Father {
public String name = "father";
}
public class Son extends Father{
public String name = "son";
public void showName(){
System.out.println(super.name);
System.out.println(this.name);
}
}
主函數中調用這個 showName 方法,輸出結果如下:
father
son
應該不難理解,無論是 this.name 或是 super.name 它們對應的字節碼指令是一樣的,只是參數不同而已。而這個參數,編譯器又是如何確定的呢?
如果是 this,編譯器優先從當前類實例中查找匹配的屬性字段,沒有找到的話將遞歸向父類中繼續查詢。而如果是 super 的話,將直接從父類開始查找匹配的字段屬性,沒有找到的話一樣會遞歸向上繼續查詢。
完整的初始化過程
下面我們以兩道面試題,加深一下對于對象的創建與初始化的相關細節理解。
面試題一:
public class A {
static {
System.out.println("1");
}
public A(){
System.out.println("2");
}
}
public class B extends A {
static{
System.out.println("a");
}
public B(){
System.out.println("b");
}
}
Main 函數調用:
public static void main(String[] args){
A ab = new B();
ab = new B();
}
大家不妨可以思考一下,最終的輸出結果是什么。
輸出結果如下:
1
a
2
b
2
b
我們來解釋一下,第一條語句:
A ab = new B();
首先發現類 A 并沒有被加載,于是進行 A 的類加載過程,類加載的最后階段,初始化階段會調用編譯器生成的 <clinit> 方法,完成類中所有靜態屬性的賦值操作,包括靜態塊的代碼執行。于是打印字符「1」。
緊接著會去加載類 B,同樣的過程,打印了字符「a」。
最后調用 new 指令,于堆上分配內存,并開始實例初始化操作,調用自身構造器之前會首先調用一下父類 A 的構造器保證對 A 的初始化,于是打印了字符「2」,接著調用字節的構造器,打印字符「b」。
至此,第一條語句算是執行結束了。
第二條語句:
ab = new B();
由于類型 B 已經被加載進方法區了,虛擬機不會重復加載,直接進入實例化的過程,同樣的過程,分別打印字符「2」和「b」。
這一道題目應該算簡單的,只要理解了類加載過程中的初始化過程和實例對象的初始化過程,應該是手到擒來。
面試題二:
public class X {
Y y = new Y();
public X(){
System.out.println("X");
}
}
public class Y {
public Y(){
System.out.println("Y");
}
}
public class Z extends X {
Y y = new Y();
public Z(){
System.out.println("Z");
}
}
Main 函數調用:
public static void main(String[] args){
new Z();
}
同樣的,大家可以先自行分析分析運行的結果是什么。
輸出結果如下:
Y
X
Y
Z
我們一起來分析一下,首先這個主函數中的代碼很簡單,就是實例化一個 Z 類型的對象,虛擬機一樣的會先進行 Z 的類加載過程。
發現并沒有靜態語句需要執行,于是直接進入實例化階段。實例化階段主要分為三個部分,實例屬性字段的初始化,實例代碼塊的執行,構造函數的執行。 而實際上,對于實例屬性字段的賦值與實例代碼塊中代碼都會被編譯器放入構造函數中一起運行。
所以,在執行 Z 的構造器之前會先進入 X 的構造器,而 X 中的實例屬性會按序被編譯器放入構造器。也就是說,X 構造器的第一步其實是這條語句的執行:
Y y = new Y();
所以,進行類型 Y 的類加載與實例化過程,結束后會打印字符「Y」。
然后,進入 X 的構造器繼續執行,打印字符「X」。
至此,父類的所有初始化動作完成。
最后,進行 Z 本身的構造器的初始化過程,一樣會先初始化實例屬性,再執行構造函數方法體,輸出字符「Y」和「Z」。
有關類對象的創建與初始化過程,這兩道題目算是很好的檢驗了,其實這些初始化過程并不復雜,只需要你理解清楚各個步驟的初始化順序即可。
文章中的所有代碼、圖片、文件都云存儲在我的 GitHub 上:
(https://github.com/SingleYam/overview_java)
歡迎關注微信公眾號:撲在代碼上的高爾基,所有文章都將同步在公眾號上。