關於類的物件建立與初始化

YangAM發表於2018-04-10

今天,我們就來解決一個問題,一個類例項究竟要經過多少個步驟才能被建立出來,也就是下面這行程式碼的背後,JVM 做了哪些事情?

Object obj = new Object();

當虛擬機器接受到一條 new 指令時,首先會拿指令後的引數,也就是我們類的符號引用,於方法區中進行檢查,看是否該類已經被載入,如果沒有則需要先進行該類的載入操作。

一旦該類已經被載入,那麼虛擬機器會根據型別資訊在堆中分配該類物件所需要的記憶體空間,然後返回該物件在堆中的引用地址。

一般而言,虛擬機器會在 new 指令執行結束後,顯式呼叫該類的物件的 方法,這個方法需要程式設計師在定義類的時候給出,否則編譯器將在編譯期間新增一個空方法體的 方法。

以上步驟完成後,基本上一個類的例項物件就算是被建立完成了,才能夠為我們程式中使用,下面我們詳細的瞭解每個步驟的細節之處。

初始化父類

知乎上看到一個問題:

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 虛擬機器中,一個物件在記憶體中的佈局由三個區域組成:物件頭,例項資料,對齊填充。

物件頭中儲存了兩部分內容,其一是自身執行的相關資訊,例如:物件雜湊碼,分代年齡,鎖資訊等。其二是一個指向方法區型別資訊的引用。

物件例項資料中儲存的才是一個物件內部資料,程式中定義的所有欄位,以及從父類繼承而來的欄位都會被記錄儲存。

像這樣:

image

當然,這裡父類的成員方法和屬性必須是可以被子類繼承的,無法繼承的屬性和方法自然是不會出現在子類例項物件中了。

粗糙點來說,我們父類的初始化動作指的就是,呼叫父類的 方法,以及例項程式碼塊,完成對繼承而來的父類成員屬性的初始化過程。

對齊填充其實也沒什麼實際的含義,只是起到一個佔位符的作用,因為 HotSpot 虛擬機器要求物件的大小是 8 的整數倍,如果物件的大小不足 8 的整數倍時,會使用對齊填充進行補全。

所以不存在說,一個子類物件中會包含其所有父類的例項引用,只不過繼承了可繼承的所有屬性及方法,而所謂的「父類初始化」動作,其實就是對父類 方法的呼叫而已。

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();
}
複製程式碼

我們反編譯主函式所在的類:

image

位元組碼指令第七行,astore_1 將第四行返回的 Son 例項引用存入區域性變數表,aload_1 載入該例項引用到運算元棧。

接著,invokevirtual #4 會呼叫一個虛方法(也就是一個例項方法),該方法的符號引用為常量池第四項,除此之外,編譯器還會將運算元棧頂的當前例項引用作為方法的一個引數傳入。

image

可以看到,sayHello 方法的區域性變數表中的 this 的值 就是方法呼叫時隱式傳入的。這樣你在一個例項方法中不加 this 的呼叫其他任意例項方法,其實呼叫的都是同一個例項的其他方法。

總的來說,對於關鍵字 this 的理解,只需要抓住一個關鍵點就好:它代表的是當前類例項,並且每個非靜態方法的呼叫都必定會傳入當前的例項物件,而被呼叫的方法預設會用一個名為 this 的變數進行接收。

這樣做的唯一目的是,例項方法是可以訪問例項屬性的,也就是說例項方法是可以修改例項屬性資料值的,所以任何的例項方法呼叫都需要給定一個例項物件,否則這些方法將不知道讀寫哪個物件的屬性值。

那麼 super 關鍵字又代表著誰,能夠用來做什麼呢?

我們說了,一個例項物件的建立是不會建立其父類物件的,而是直接繼承的父類可繼承的欄位,大致的物件記憶體佈局如下:

image

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 的類載入過程,類載入的最後階段,初始化階段會呼叫編譯器生成的 方法,完成類中所有靜態屬性的賦值操作,包括靜態塊的程式碼執行。於是列印字元「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)

歡迎關注微信公眾號:撲在程式碼上的高爾基,所有文章都將同步在公眾號上。

image

相關文章