當前位置:
首頁 > 最新 > 關於類的對象創建與初始化

關於類的對象創建與初始化

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

Object obj = new Object();

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

一旦該類已經被載入,那麼虛擬機會根據類型信息在堆中分配該類對象所需要的內存空間,然後返回該對象在堆中的引用地址。

一般而言,虛擬機會在 new 指令執行結束後,顯式調用該類的對象的方法,這個方法需要程序員在定義類的時候給出,否則編譯器將在編譯期間添加一個空方法體的方法。

以上步驟完成後,基本上一個類的實例對象就算是被創建完成了,才能夠為我們程序中使用,下面我們詳細的了解每個步驟的細節之處。

初始化父類

知乎上看到一個問題:

Java中,創建子類對象時,父類對象會也被一起創建么?

有關這個問題,我還特意去搜了一下,很多人都說,一個子類對象的創建,會對應一個父類對象的創建,並且這個子類對象會保存這個父類對象的引用以便訪問父類對象中各項信息。

這個答案肯定是不對的,如果每一個子類對象的創建都要創建其所有直接或間接的父類對象,那麼整個堆空間豈不是充斥著大量重複的對象?這種內存空間的使用效率也會很低。

我猜這樣的誤解來源於 《Thinking In Java》 中的一句話,可能大家誤解了這段話,原話很多很抽象,我簡單總結了下:

虛擬機保證一個類實例初始化之前,其直接父類或間接父類的初始化過程執行結束

看一段代碼:

public class Father {

public Father(){

}

}

public class Son extends Father {

public Son(){

}

}

public static void main(String[] args){

Son son = new Son();

}

輸出結果:

father"s constructor has been called....

son"s constructor has been called ...

這裡說的很明白,只是保證父類的初始化動作先執行,並沒有說一定會創建一個父類對象引用。

這裡很多人會有疑惑,虛擬機保證子類對象的初始化操作之前,先完成父類的初始化動作,那麼如果沒有創建父類對象,父類的初始化動作操作的對象是誰?

這就涉及到對象的內存布局,一個對象在堆中究竟由哪些部分組成?

HotSpot 虛擬機中,一個對象在內存中的布局由三個區域組成:對象頭,實例數據,對齊填充。

對象頭中保存了兩部分內容,其一是自身運行的相關信息,例如:對象哈希碼,分代年齡,鎖信息等。其二是一個指向方法區類型信息的引用。

對象實例數據中存儲的才是一個對象內部數據,程序中定義的所有欄位,以及從父類繼承而來的欄位都會被記錄保存。

像這樣:

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

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

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

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

this 與 super 關鍵字

this 關鍵字代表著當前對象,它只能使用在類的內部,通過它可以顯式的調用同一個類下的其他方法,例如:

public class Son {

public void sayHello(){

}

public void introduce(String 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(){

}

}

主函數中調用這個 showName 方法,輸出結果如下:

father

son

應該不難理解,無論是 this.name 或是 super.name 它們對應的位元組碼指令是一樣的,只是參數不同而已。而這個參數,編譯器又是如何確定的呢?

如果是 this,編譯器優先從當前類實例中查找匹配的屬性欄位,沒有找到的話將遞歸向父類中繼續查詢。而如果是 super 的話,將直接從父類開始查找匹配的欄位屬性,沒有找到的話一樣會遞歸向上繼續查詢。

完整的初始化過程

下面我們以兩道面試題,加深一下對於對象的創建與初始化的相關細節理解。

面試題一:

public class A {

static {

}

public A(){

}

}

public class B extends A {

static{

}

public B(){

}

}

Main 函數調用:

public static void main(String[] args){

A ab = new B();

ab = new 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(){

}

}

public class Y {

public Y(){

}

}

public class Z extends X {

Y y = new Y();

public 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)


喜歡這篇文章嗎?立刻分享出去讓更多人知道吧!

本站內容充實豐富,博大精深,小編精選每日熱門資訊,隨時更新,點擊「搶先收到最新資訊」瀏覽吧!


請您繼續閱讀更多來自 撲在代碼上的高爾基 的精彩文章:

TAG:撲在代碼上的高爾基 |