從jvm角度看懂類初始化、方法重寫、過載。

帥地發表於2018-08-06

類初始化

在講類的初始化之前,我們先來大概瞭解一下類的宣告週期。如下圖

 

類的宣告週期可以分為7個階段,但今天我們只講初始化階段。我們我覺得出來使用解除安裝階段外,初始化階段是最貼近我們平時學的,也是筆試做題過程中最容易遇到的,假如你想了解每一個階段的話,可以看看深入理解Java虛擬機器這本書。

下面開始講解初始化過程。

注意:

這裡需要指出的是,在執行類的初始化之前,其實在準備階段就已經為類變數分配過記憶體,並且也已經設定過類變數的初始值了。例如像整數的初始值是0,物件的初始值是null之類的。基本資料型別的初始值如下:

資料型別初始值資料型別初始值
int 0 boolean false
long 0L float 0.0f
short (short)0 double 0.0d
char ‘\u0000’ reference null
byte (byte)0

大家先想一個問題,當我們在執行一個java程式時,每個類都會被初始化嗎?假如並非每個類都會執行初始化過程,那什麼時候一個類會執行初始化過程呢?

答案是並非每個類都會執行初始化過程,你想啊,如果這個類根本就不用用到,那初始化它幹嘛,佔用空間。

至於何時執行初始化過程,虛擬機器規範則是嚴格規定了有且只有5中情況會馬上對類進行初始化

  1. 當使用new這個關鍵字例項化物件、讀取或者設定一個類的靜態欄位,以及呼叫一個類的靜態方法時會觸發類的初始化(注意,被final修飾的靜態欄位除外)。
  2. 使用java.lang.reflect包的方法對類進行反射呼叫時,如果這個類還沒有進行過初始化,則會觸發該類的初始化。
  3. 當初始化一個類時,如果其父類還沒有進行過初始化,則會先觸發其父類。
  4. 當虛擬機器啟動時,使用者需要指定一個要執行的主類(包含main()方法的那個類),虛擬機器會先初始化這個主類。
  5. 當使用JDK 1.7的動態語言支援時,如果一個…..(省略,說了也看不懂,哈哈)。

注意是有且只有。這5種行為我們稱為對一個類的主動引用

初始化過程

類的初始化過程都幹了些什麼呢?

在類的初始化過程中,說白了就是執行了一個類構造器()方法過程。注意,這個clinit並非類的建構函式(init())。

至於clinit()方法都包含了哪些內容?

實際上,clinit()方法是由編譯器自動收集類中的所有類變數的賦值動作和靜態語句塊(static{}塊)中的語句合併產生的,編譯器收集的順序則是由語句在原始檔中出現的順序來決定的。並且靜態語句塊中只能訪問到定義在靜態語句塊之前的變數,定義在它之後的變數,在前面的靜態語句塊可以賦值,但不能訪問。如下面的程式。

public class Test1 {
    static {
        t = 10;//編譯可以正常通過
        System.out.println(t);//提示illegal forward reference錯誤
    }
    static int t = 0;
}

給大家拋個練習

public class Father {
    public static int t1 = 10;
    static {
        t1 = 20;
    }
}
class Son extends Father{
    public static int t2 = t1;
}
//測試呼叫
class Test2{
    public static void main(String[] args){
        System.out.println(Son.t2);
    }
}

輸出結果是什麼呢?

答案是20。我相信大家都知道為啥。因為會先初始化父類啊。

不過這裡需要注意的是,對於類來說,執行該類的clinit()方法時,會先執行父類的clinit()方法,但對於介面來說,執行介面的clinit()方法並不會執行父介面的clinit()方法。只有當用到父類介面中定義的變數時,才會執行父介面的clinit()方法。

被動引用

上面說了類初始化的五種情況,我們稱之為稱之為主動引用。居然存在主動,也意味著存在所謂的被動引用。這裡需要提出的是,被動引用並不會觸發類的初始化。下面,我們舉例幾個被動引用的例子:

  1. 通過子類引用父類的靜態欄位,不會觸發子類的初始化
/**
 * 1.通過子類引用父類的靜態欄位,不會觸發子類的初始化
 */
public class FatherClass {
    //靜態塊
    static {
        System.out.println("FatherClass init");
    }
    public static int value = 10;
}

class SonClass extends FatherClass {
    static {
        System.out.println("SonClass init");
    }
}
 class Test3{
    public static void main(String[] args){
        System.out.println(SonClass.value);
    }
}

輸出結果

FatherClass init

說明並沒有觸發子類的初始化

  1. 通過陣列定義來引用類,不會觸發此類的初始化。
 class Test3{
    public static void main(String[] args){
        SonClass[] sonClass = new SonClass[10];//引用上面的SonClass類。
    }      
 }

輸出結果是啥也沒輸出。

  1. 引用其他類的常量並不會觸發那個類的初始化
public class FatherClass {
    //靜態塊
    static {
        System.out.println("FatherClass init");
    }
    public static final String value = "hello";//常量
}

class Test3{
    public static void main(String[] args){
        System.out.println(FatherClass.value);
    }
}

輸出結果:hello

實際上,之所以沒有輸出”FatherClass init”,是因為在編譯階段就已經對這個常量進行了一些優化處理,例如,由於Test3這個類用到了這個常量”hello”,在編譯階段就已經將”hello”這個常量儲存到了Test3類的常量池中了,以後對FatherClass.value的引用實際上都被轉化為Test3類對自身常量池的引用了。也就是說,在編譯成class檔案之後,兩個class已經沒啥毛關係了。


過載

對於過載,我想學過java的都懂,但是今天我們中虛擬機器的角度來看看過載是怎麼回事。

首先我們先來看一段程式碼:

//定義幾個類
public abstract class Animal {
}
class Dog extends Animal{
}
class Lion extends Animal{
}

class Test4{
    public void run(Animal animal){
        System.out.println("動物跑啊跑");
    }
    public void run(Dog dog){
        System.out.println("小狗跑啊跑");
    }
    public void run(Lion lion){
        System.out.println("獅子跑啊跑");
    }
    //測試
    public static void main(String[] args){
        Animal dog = new Dog();
        Animal lion = new Lion();;
        Test4 test4 = new Test4();
        test4.run(dog);
        test4.run(lion);
    }
}

執行結果:

動物跑啊跑

動物跑啊跑

相信大家學過過載的都能猜到是這個結果。但是,為什麼會選擇這個方法進行過載呢?虛擬機器是如何選擇的呢?

在此之前我們先來了解兩個概念。

先來看一行程式碼:

Animal dog = new Dog();

對於這一行程式碼,我們把Animal稱之為變數dog的靜態型別,而後面的Dog稱為變數dog的實際型別

所謂靜態型別也就是說,在程式碼的編譯期就可以判斷出來了,也就是說在編譯期就可以判斷dog的靜態型別是啥了。但在編譯期無法知道變數dog的實際型別是什麼。

現在我們再來看看虛擬機器是根據什麼來過載選擇哪個方法的。

對於靜態型別相同,但實際型別不同的變數,虛擬機器在過載的時候是根據引數的靜態型別而不是實際型別作為判斷選擇的。並且靜態型別在編譯器就是已知的了,這也代表在編譯階段,就已經決定好了選擇哪一個過載方法。

由於dog和lion的靜態型別都是Animal,所以選擇了run(Animal animal)這個方法。

不過需要注意的是,有時候是可以有多個過載版本的,也就是說,過載版本並非是唯一的。我們不妨來看下面的程式碼。

public class Test {
    public static void sayHello(Object arg){
        System.out.println("hello Object");
    }
    public static void sayHello(int arg){
        System.out.println("hello int");
    }
    public static void sayHello(long arg){
        System.out.println("hello long");
    }
    public static void sayHello(Character arg){
        System.out.println("hello Character");
    }
    public static void sayHello(char arg){
        System.out.println("hello char");
    }
    public static void sayHello(char... arg){
        System.out.println("hello char...");
    }
    public static void sayHello(Serializable arg){
        System.out.println("hello Serializable");
    }

    //測試
    public static void main(String[] args){
        char a = 'a';
        sayHello('a');
    }
}

執行下程式碼。
相信大家都知道輸出結果是

hello char

因為a的靜態型別是char,隨意會匹配到sayHello(char arg);

但是,如果我們把sayHello(char arg)這個方法註釋掉,再執行下。

結果輸出:

hello int

實際上這個時候由於方法中並沒有靜態型別為char的方法,它就會自動進行型別轉換。‘a’除了可以是字元,還可以代表數字97。因此會選擇int型別的進行過載。

我們繼續註釋掉sayHello(int arg)這個方法。結果會輸出:

hello long。

這個時候’a’進行兩次型別轉換,即 ‘a’ -> 97 -> 97L。所以匹配到了sayHell(long arg)方法。

實際上,’a’會按照char ->int -> long -> float ->double的順序來轉換。但並不會轉換成byte或者short,因為從char到byte或者short的轉換是不安全的。(為什麼不安全?留給你思考下)

繼續註釋掉long型別的方法。輸出結果是:

hello Character

這時發生了一次自動裝箱,’a’被封裝為Character型別。

繼續註釋掉Character型別的方法。輸出

hello Serializable

為什麼?

一個字元或者數字與序列化有什麼關係?實際上,這是因為Serializable是Character類實現的一個介面,當自動裝箱之後發現找不到裝箱類,但是找到了裝箱類實現了的介面型別,所以在一次發生了自動轉型。

我們繼續註釋掉Serialiable,這個時候的輸出結果是:

hello Object

這時是’a’裝箱後轉型為父類了,如果有多個父類,那將從繼承關係中從下往上開始搜尋,即越接近上層的優先順序越低。

繼續註釋掉Object方法,這時候輸出:

hello char…

這個時候’a’被轉換為了一個陣列元素。

從上面的例子中,我們可以看出,元素的靜態型別並非就是一定是固定的,它在編譯期根根據優先順序原則來進行轉換。其實這也是java語言實現過載的本質

重寫

我們先來看一段程式碼

//定義幾個類
public abstract class Animal {
    public abstract void run();
}
class Dog extends Animal{
    @Override
    public void run() {
        System.out.println("小狗跑啊跑");
    }
}
class Lion extends Animal{
    @Override
    public void run() {
        System.out.println("獅子跑啊跑");
    }
}
class Test4{
    //測試
    public static void main(String[] args){
        Animal dog = new Dog();
        Animal lion = new Lion();;
        dog.run();
        lion.run();
    }
}

執行結果:

小狗跑啊跑
獅子跑啊跑

我相信大家對這個結果是毫無疑問的。他們的靜態型別是一樣的,虛擬機器是怎麼知道要執行哪個方法呢?

顯然,虛擬機器是根據實際型別來執行方法的。我們來看看main()方法中的一部分位元組碼

//宣告:我只是挑出了一部分關鍵的位元組碼
public static void (java.lang.String[]);
    Code:
    Stack=2, Locals=3, Args_size=1;//可以不用管這個
    //下面的是關鍵
    0:new #16;//即new Dog
    3: dup
    4: invokespecial #18; //呼叫初始化方法
    7: astore_1
    8: new #19 ;即new Lion
    11: dup
    12: invokespecial #21;//呼叫初始化方法
    15: astore_2

    16: aload_1; 壓入棧頂
    17: invokevirtual #22;//呼叫run()方法
    20: aload_2 ;壓入棧頂
    21: invokevirtual #22;//呼叫run()方法
    24: return

解釋一下這段位元組碼:

0-15行的作用是建立Dog和Lion物件的記憶體空間,呼叫Dog,Lion型別的例項構造器。對應的程式碼:

Animal dog = new Dog();

Animal lion = new Lion();

接下來的16-21句是關鍵部分,16、20兩句分分別把剛剛建立的兩個物件的引用壓到棧頂。17和21是run()方法的呼叫指令。

從指令可以看出,這兩條方法的呼叫指令是完全一樣的。可是最終執行的目標方法卻並不相同。這是為啥?

實際上:

invokevirtual方法呼叫指令在執行的時候是這樣的:

  1. 找到棧頂的第一個元素所指向的物件的實際型別,記作C.
  2. 如果型別C中找到run()這個方法,則進行訪問許可權的檢驗,如果可以訪問,則方法這個方法的直接引用,查詢結束;如果這個方法不可以訪問,則丟擲java.lang.IllegalAccessEror異常。
  3. 如果在該物件中沒有找到run()方法,則按照繼承關係從下往上對C的各個父類進行第二步的搜尋和檢驗。
  4. 如果都沒有找到,則丟擲java.lang.AbstractMethodError異常。

所以雖然指令的呼叫是相同的,但17行呼叫run方法時,此時棧頂存放的物件引用是Dog,21行則是Lion。

這,就是java語言中方法重寫的本質。

本次的講解到此結束,希望對你有所幫助。

相關文章