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

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

Object obj = new Object();git

當虛擬機接受到一條 new 指令時,首先會拿指令後的參數,也就是咱們類的符號引用,於方法區中進行檢查,看是否該類已經被加載,若是沒有則須要先進行該類的加載操做。程序員

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

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

以上步驟完成後,基本上一個類的實例對象就算是被建立完成了,纔可以爲咱們程序中使用,下面咱們詳細的瞭解每一個步驟的細節之處。bash

初始化父類

知乎上看到一個問題:微信

Java中,建立子類對象時,父類對象會也被一塊兒建立麼?函數

有關這個問題,我還特地去搜了一下,不少人都說,一個子類對象的建立,會對應一個父類對象的建立,而且這個子類對象會保存這個父類對象的引用以便訪問父類對象中各項信息佈局

這個答案確定是不對的,若是每個子類對象的建立都要建立其全部直接或間接的父類對象,那麼整個堆空間豈不是充斥着大量重複的對象?這種內存空間的使用效率也會很低。this

我猜這樣的誤解來源於 《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
相關文章
相關標籤/搜索