1. 程式人生 > >深入理解Java物件的建立過程:類的初始化與例項化

深入理解Java物件的建立過程:類的初始化與例項化

摘要:

  在Java中,一個物件在可以被使用之前必須要被正確地初始化,這一點是Java規範規定的。在例項化一個物件時,JVM首先會檢查相關型別是否已經載入並初始化,如果沒有,則JVM立即進行載入並呼叫類構造器完成類的初始化。在類初始化過程中或初始化完畢後,根據具體情況才會去對類進行例項化。本文試圖對JVM執行類初始化和例項化的過程做一個詳細深入地介紹,以便從Java虛擬機器的角度清晰解剖一個Java物件的建立過程。

友情提示:

  一個Java物件的建立過程往往包括 類初始化 和 類例項化 兩個階段。本文的姊妹篇《 JVM類載入機制概述:載入時機與載入過程》主要介紹了類的初始化時機和初始化過程,本文在此基礎上,進一步闡述了一個Java物件建立的真實過程。

一、Java物件建立時機

  我們知道,一個物件在可以被使用之前必須要被正確地例項化。在Java程式碼中,有很多行為可以引起物件的建立,最為直觀的一種就是使用new關鍵字來呼叫一個類的建構函式顯式地建立物件,這種方式在Java規範中被稱為 : 由執行類例項建立表示式而引起的物件建立。除此之外,我們還可以使用反射機制(Class類的newInstance方法、使用Constructor類的newInstance方法)、使用Clone方法、使用反序列化等方式建立物件。下面筆者分別對此進行一一介紹:

1). 使用new關鍵字建立物件

  這是我們最常見的也是最簡單的建立物件的方式,通過這種方式我們可以呼叫任意的建構函式(無參的和有參的)去建立物件。比如:

  Student student = new Student();

2). 使用Class類的newInstance方法(反射機制)

  我們也可以通過Java的反射機制使用Class類的newInstance方法來建立物件,事實上,這個newInstance方法呼叫無參的構造器建立物件,比如:

 Student student2 = (Student)Class.forName("Student類全限定名").newInstance(); 
或者:
  Student stu = Student.class.newInstance();

3). 使用Constructor類的newInstance方法(反射機制)

  java.lang.relect.Constructor類裡也有一個newInstance方法可以建立物件,該方法和Class類中的newInstance方法很像,但是相比之下,Constructor類的newInstance方法更加強大些,我們可以通過這個newInstance方法呼叫有引數的和私有的建構函式,比如:

public class Student {

    private int id;

    public Student(Integer id) {
        this.id = id;
    }

    public static void main(String[] args) throws Exception {

        Constructor<Student> constructor = Student.class
                .getConstructor(Integer.class);
        Student stu3 = constructor.newInstance(123);
    }
}

  使用newInstance方法的這兩種方式建立物件使用的就是Java的反射機制,事實上Class的newInstance方法內部呼叫的也是Constructor的newInstance方法。

4). 使用Clone方法建立物件

  無論何時我們呼叫一個物件的clone方法,JVM都會幫我們建立一個新的、一樣的物件,特別需要說明的是,用clone方法建立物件的過程中並不會呼叫任何建構函式。關於如何使用clone方法以及淺克隆/深克隆機制,筆者已經在博文《 Java String 綜述(下篇)》做了詳細的說明。簡單而言,要想使用clone方法,我們就必須先實現Cloneable介面並實現其定義的clone方法,這也是原型模式的應用。比如:

public class Student implements Cloneable{

    private int id;

    public Student(Integer id) {
        this.id = id;
    }

    @Override
    protected Object clone() throws CloneNotSupportedException {
        // TODO Auto-generated method stub
        return super.clone();
    }

    public static void main(String[] args) throws Exception {

        Constructor<Student> constructor = Student.class
                .getConstructor(Integer.class);
        Student stu3 = constructor.newInstance(123);
        Student stu4 = (Student) stu3.clone();
    }
}

5). 使用(反)序列化機制建立物件

  當我們反序列化一個物件時,JVM會給我們建立一個單獨的物件,在此過程中,JVM並不會呼叫任何建構函式。為了反序列化一個物件,我們需要讓我們的類實現Serializable介面,比如:

public class Student implements Cloneable, Serializable {

    private int id;

    public Student(Integer id) {
        this.id = id;
    }

    @Override
    public String toString() {
        return "Student [id=" + id + "]";
    }

    public static void main(String[] args) throws Exception {

        Constructor<Student> constructor = Student.class
                .getConstructor(Integer.class);
        Student stu3 = constructor.newInstance(123);

        // 寫物件
        ObjectOutputStream output = new ObjectOutputStream(
                new FileOutputStream("student.bin"));
        output.writeObject(stu3);
        output.close();

        // 讀物件
        ObjectInputStream input = new ObjectInputStream(new FileInputStream(
                "student.bin"));
        Student stu5 = (Student) input.readObject();
        System.out.println(stu5);
    }
}

6). 完整例項

public class Student implements Cloneable, Serializable {

    private int id;

    public Student() {

    }

    public Student(Integer id) {
        this.id = id;
    }

    @Override
    protected Object clone() throws CloneNotSupportedException {
        // TODO Auto-generated method stub
        return super.clone();
    }

    @Override
    public String toString() {
        return "Student [id=" + id + "]";
    }

    public static void main(String[] args) throws Exception {

        System.out.println("使用new關鍵字建立物件:");
        Student stu1 = new Student(123);
        System.out.println(stu1);
        System.out.println("\n---------------------------\n");


        System.out.println("使用Class類的newInstance方法建立物件:");
        Student stu2 = Student.class.newInstance();    //對應類必須具有無參構造方法,且只有這一種建立方式
        System.out.println(stu2);
        System.out.println("\n---------------------------\n");

        System.out.println("使用Constructor類的newInstance方法建立物件:");
        Constructor<Student> constructor = Student.class
                .getConstructor(Integer.class);   // 呼叫有參構造方法
        Student stu3 = constructor.newInstance(123);   
        System.out.println(stu3);
        System.out.println("\n---------------------------\n");

        System.out.println("使用Clone方法建立物件:");
        Student stu4 = (Student) stu3.clone();
        System.out.println(stu4);
        System.out.println("\n---------------------------\n");

        System.out.println("使用(反)序列化機制建立物件:");
        // 寫物件
        ObjectOutputStream output = new ObjectOutputStream(
                new FileOutputStream("student.bin"));
        output.writeObject(stu4);
        output.close();

        // 讀取物件
        ObjectInputStream input = new ObjectInputStream(new FileInputStream(
                "student.bin"));
        Student stu5 = (Student) input.readObject();
        System.out.println(stu5);

    }
}/* Output: 
        使用new關鍵字建立物件:
        Student [id=123]

        ---------------------------

        使用Class類的newInstance方法建立物件:
        Student [id=0]

        ---------------------------

        使用Constructor類的newInstance方法建立物件:
        Student [id=123]

        ---------------------------

        使用Clone方法建立物件:
        Student [id=123]

        ---------------------------

        使用(反)序列化機制建立物件:
        Student [id=123]
*///:~

  從Java虛擬機器層面看,除了使用new關鍵字建立物件的方式外,其他方式全部都是通過轉變為invokevirtual指令直接建立物件的。

二. Java 物件的建立過程

  當一個物件被建立時,虛擬機器就會為其分配記憶體來存放物件自己的例項變數及其從父類繼承過來的例項變數(即使這些從超類繼承過來的例項變數有可能被隱藏也會被分配空間)。在為這些例項變數分配記憶體的同時,這些例項變數也會被賦予預設值(零值)。在記憶體分配完成之後,Java虛擬機器就會開始對新建立的物件按照程式猿的意志進行初始化。在Java物件初始化過程中,主要涉及三種執行物件初始化的結構,分別是 例項變數初始化例項程式碼塊初始化 以及 建構函式初始化

1、例項變數初始化與例項程式碼塊初始化

  我們在定義(宣告)例項變數的同時,還可以直接對例項變數進行賦值或者使用例項程式碼塊對其進行賦值。如果我們以這兩種方式為例項變數進行初始化,那麼它們將在建構函式執行之前完成這些初始化操作。實際上,如果我們對例項變數直接賦值或者使用例項程式碼塊賦值,那麼編譯器會將其中的程式碼放到類的建構函式中去,並且這些程式碼會被放在對超類建構函式的呼叫語句之後(還記得嗎?Java要求建構函式的第一條語句必須是超類建構函式的呼叫語句),建構函式本身的程式碼之前。例如:

public class InstanceVariableInitializer {  

    private int i = 1;  
    private int j = i + 1;  

    public InstanceVariableInitializer(int var){
        System.out.println(i);
        System.out.println(j);
        this.i = var;
        System.out.println(i);
        System.out.println(j);
    }

    {               // 例項程式碼塊
        j += 3; 

    }

    public static void main(String[] args) {
        new InstanceVariableInitializer(8);
    }
}/* Output: 
            1
            5
            8
            5
 *///:~

  上面的例子正好印證了上面的結論。特別需要注意的是,Java是按照程式設計順序來執行例項變數初始化器和例項初始化器中的程式碼的,並且不允許順序靠前的例項程式碼塊初始化在其後面定義的例項變數,比如:

public class InstanceInitializer {  
    {  
        j = i;  
    }  

    private int i = 1;  
    private int j;  
}  

public class InstanceInitializer {  
    private int j = i;  
    private int i = 1;  
} 

  上面的這些程式碼都是無法通過編譯的,編譯器會抱怨說我們使用了一個未經定義的變數。之所以要這麼做是為了保證一個變數在被使用之前已經被正確地初始化。但是我們仍然有辦法繞過這種檢查,比如:

public class InstanceInitializer {  
    private int j = getI();  
    private int i = 1;  

    public InstanceInitializer() {  
        i = 2;  
    }  

    private int getI() {  
        return i;  
    }  

    public static void main(String[] args) {  
        InstanceInitializer ii = new InstanceInitializer();  
        System.out.println(ii.j);  
    }  
} 

  如果我們執行上面這段程式碼,那麼會發現列印的結果是0。因此我們可以確信,變數j被賦予了i的預設值0,這一動作發生在例項變數i初始化之前和建構函式呼叫之前。

2、建構函式初始化

  我們可以從上文知道,例項變數初始化與例項程式碼塊初始化總是發生在建構函式初始化之前,那麼我們下面著重看看建構函式初始化過程。眾所周知,每一個Java中的物件都至少會有一個建構函式,如果我們沒有顯式定義建構函式,那麼它將會有一個預設無參的建構函式。在編譯生成的位元組碼中,這些建構函式會被命名成<init>()方法,引數列表與Java語言書寫的建構函式的引數列表相同。

  我們知道,Java要求在例項化類之前,必須先例項化其超類,以保證所建立例項的完整性。事實上,這一點是在建構函式中保證的:Java強制要求Object物件(Object是Java的頂層物件,沒有超類)之外的所有物件建構函式的第一條語句必須是超類建構函式的呼叫語句或者是類中定義的其他的建構函式,如果我們既沒有呼叫其他的建構函式,也沒有顯式呼叫超類的建構函式,那麼編譯器會為我們自動生成一個對超類建構函式的呼叫,比如:

public class ConstructorExample {  

} 

  對於上面程式碼中定義的類,我們觀察編譯之後的位元組碼,我們會發現編譯器為我們生成一個建構函式,如下,

aload_0  
invokespecial   #8; //Method java/lang/Object."<init>":()V  
return 

  上面程式碼的第二行就是呼叫Object類的預設建構函式的指令。也就是說,如果我們顯式呼叫超類的建構函式,那麼該呼叫必須放在建構函式所有程式碼的最前面,也就是必須是建構函式的第一條指令。正因為如此,Java才可以使得一個物件在初始化之前其所有的超類都被初始化完成,並保證建立一個完整的物件出來。

  特別地,如果我們在一個建構函式中呼叫另外一個建構函式,如下所示,

public class ConstructorExample {  
    private int i;  

    ConstructorExample() {  
        this(1);  
        ....  
    }  

    ConstructorExample(int i) {  
        ....  
        this.i = i;  
        ....  
    }  
}  

  對於這種情況,Java只允許在ConstructorExample(int i)內呼叫超類的建構函式,也就是說,下面兩種情形的程式碼編譯是無法通過的:

public class ConstructorExample {  
    private int i;  

    ConstructorExample() {  
        super();  
        this(1);  // Error:Constructor call must be the first statement in a constructor
        ....  
    }  

    ConstructorExample(int i) {  
        ....  
        this.i = i;  
        ....  
    }  
} 

或者,

public class ConstructorExample {  
    private int i;  

    ConstructorExample() {  
        this(1);  
        super();  //Error: Constructor call must be the first statement in a constructor
        ....  
    }  

    ConstructorExample(int i) {  
        this.i = i;  
    }  
}  

  Java通過對建構函式作出這種限制以便保證一個類的例項能夠在被使用之前正確地初始化。

3、 小結

  總而言之,例項化一個類的物件的過程是一個典型的遞迴過程,如下圖所示。進一步地說,在例項化一個類的物件時,具體過程是這樣的:

  在準備例項化一個類的物件前,首先準備例項化該類的父類,如果該類的父類還有父類,那麼準備例項化該類的父類的父類,依次遞迴直到遞迴到Object類。此時,首先例項化Object類,再依次對以下各類進行例項化,直到完成對目標類的例項化。具體而言,在例項化每個類時,都遵循如下順序:先依次執行例項變數初始化和例項程式碼塊初始化,再執行建構函式初始化。也就是說,編譯器會將例項變數初始化和例項程式碼塊初始化相關程式碼放到類的建構函式中去,並且這些程式碼會被放在對超類建構函式的呼叫語句之後,建構函式本身的程式碼之前。

             這裡寫圖片描述

4、例項變數初始化、例項程式碼塊初始化以及建構函式初始化綜合例項

  筆者在《 JVM類載入機制概述:載入時機與載入過程》一文中詳細闡述了類初始化時機和初始化過程,並在文章的最後留了一個懸念給各位,這裡來揭開這個懸念。建議讀者先看完《 JVM類載入機制概述:載入時機與載入過程》這篇再來看這個,印象會比較深刻,如若不然,也沒什麼關係~~    

//父類
class Foo {
    int i = 1;

    Foo() {
        System.out.println(i);             -----------(1)
        int x = getValue();
        System.out.println(x);             -----------(2)
    }

    {
        i = 2;
    }

    protected int getValue() {
        return i;
    }
}

//子類
class Bar extends Foo {
    int j = 1;

    Bar() {
        j = 2;
    }

    {
        j = 3;
    }

    @Override
    protected int getValue() {
        return j;
    }
}

public class ConstructorExample {
    public static void main(String... args) {
        Bar bar = new Bar();
        System.out.println(bar.getValue());             -----------(3)
    }
}/* Output: 
            2
            0
            2
 *///:~

  根據上文所述的類例項化過程,我們可以將Foo類的建構函式和Bar類的建構函式等價地分別變為如下形式:

   //Foo類建構函式的等價變換:
    Foo() {
        i = 1;
        i = 2;
        System.out.println(i);
        int x = getValue();
        System.out.println(x);
    }
    //Bar類建構函式的等價變換
    Bar() {
        Foo();
        j = 1;
        j = 3;
        j = 2
    }

  這樣程式就好看多了,我們一眼就可以觀察出程式的輸出結果。在通過使用Bar類的構造方法new一個Bar類的例項時,首先會呼叫Foo類建構函式,因此(1)處輸出是2,這從Foo類建構函式的等價變換中可以直接看出。(2)處輸出是0,為什麼呢?因為在執行Foo的建構函式的過程中,由於Bar過載了Foo中的getValue方法,所以根據Java的多型特性可以知道,其呼叫的getValue方法是被Bar過載的那個getValue方法。但由於這時Bar的建構函式還沒有被執行,因此此時j的值還是預設值0,因此(2)處輸出是0。最後,在執行(3)處的程式碼時,由於bar物件已經建立完成,所以此時再訪問j的值時,就得到了其初始化後的值2,這一點可以從Bar類建構函式的等價變換中直接看出。

三. 類的初始化時機與過程

  關於類的初始化時機,筆者在博文《 JVM類載入機制概述:載入時機與載入過程》已經介紹的很清楚了,此處不再贅述。簡單地說,在類載入過程中,準備階段是正式為類變數(static 成員變數)分配記憶體並設定類變數初始值(零值)的階段,而初始化階段是真正開始執行類中定義的java程式程式碼(位元組碼)並按程式猿的意圖去初始化類變數的過程。更直接地說,初始化階段就是執行類構造器<clinit>()方法的過程。<clinit>()方法是由編譯器自動收集類中的所有類變數的賦值動作和靜態程式碼塊static{}中的語句合併產生的,其中編譯器收集的順序是由語句在原始檔中出現的順序所決定。

  類構造器<clinit>()與例項構造器<init>()不同,它不需要程式設計師進行顯式呼叫,虛擬機器會保證在子類類構造器<clinit>()執行之前,父類的類構造<clinit>()執行完畢。由於父類的構造器<clinit>()先執行,也就意味著父類中定義的靜態程式碼塊/靜態變數的初始化要優先於子類的靜態程式碼塊/靜態變數的初始化執行。特別地,類構造器<clinit>()對於類或者介面來說並不是必需的,如果一個類中沒有靜態程式碼塊,也沒有對類變數的賦值操作,那麼編譯器可以不為這個類生產類構造器<clinit>()。此外,在同一個類載入器下,一個類只會被初始化一次,但是一個類可以任意地例項化物件。也就是說,在一個類的生命週期中,類構造器<clinit>()最多會被虛擬機器呼叫一次,而例項構造器<init>()則會被虛擬機器呼叫多次,只要程式設計師還在建立物件。

  注意,這裡所謂的例項構造器<init>()是指收集類中的所有例項變數的賦值動作、例項程式碼塊和建構函式合併產生的,類似於上文對Foo類的建構函式和Bar類的建構函式做的等價變換。

四. 總結

  1、一個例項變數在物件初始化的過程中會被賦值幾次?

  我們知道,JVM在為一個物件分配完記憶體之後,會給每一個例項變數賦予預設值,這個時候例項變數被第一次賦值,這個賦值過程是沒有辦法避免的。如果我們在宣告例項變數x的同時對其進行了賦值操作,那麼這個時候,這個例項變數就被第二次賦值了。如果我們在例項程式碼塊中,又對變數x做了初始化操作,那麼這個時候,這個例項變數就被第三次賦值了。如果我們在建構函式中,也對變數x做了初始化操作,那麼這個時候,變數x就被第四次賦值。也就是說,在Java的物件初始化過程中,一個例項變數最多可以被初始化4次。

  2、類的初始化過程與類的例項化過程的異同?

  類的初始化是指類載入過程中的初始化階段對類變數按照程式猿的意圖進行賦值的過程;而類的例項化是指在類完全載入到記憶體中後建立物件的過程。

  3、假如一個類還未載入到記憶體中,那麼在建立一個該類的例項時,具體過程是怎樣的?

  我們知道,要想建立一個類的例項,必須先將該類載入到記憶體並進行初始化,也就是說,類初始化操作是在類例項化操作之前進行的,但並不意味著:只有類初始化操作結束後才能進行類例項化操作。例如,筆者在博文《 JVM類載入機制概述:載入時機與載入過程》中所提到的下面這個經典案例:

public class StaticTest {
    public static void main(String[] args) {
        staticFunction();
    }

    static StaticTest st = new StaticTest();

    static {   //靜態程式碼塊
        System.out.println("1");
    }

    {       // 例項程式碼塊
        System.out.println("2");
    }

    StaticTest() {    // 例項構造器
        System.out.println("3");
        System.out.println("a=" + a + ",b=" + b);
    }

    public static void staticFunction() {   // 靜態方法
        System.out.println("4");
    }

    int a = 110;    // 例項變數
    static int b = 112;     // 靜態變數
}/* Output: 
        2
        3
        a=110,b=0
        1
        4
 *///:~

  大家能得到正確答案嗎?筆者已經在博文《 JVM類載入機制概述:載入時機與載入過程》中解釋過這個問題了,此不贅述。        總的來說,類例項化的一般過程是:父類的類構造器<clinit>() -> 子類的類構造器<clinit>() -> 父類的成員變數和例項程式碼塊 -> 父類的建構函式 -> 子類的成員變數和例項程式碼塊 -> 子類的建構函式。

五. 更多

  更多關於類載入器等方面的內容,包括JVM預定義的類載入器、雙親委派模型等知識點,請參見我的轉載博文《深入理解Java類載入器(一):Java類載入原理解析》