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

類初始化

在講類的初始化以前,咱們先來大概瞭解一下類的聲明週期。以下圖java

類的聲明週期能夠分爲7個階段,但今天咱們只講初始化階段。咱們我以爲出來使用卸載階段外,初始化階段是最貼近咱們平時學的,也是筆試作題過程當中最容易遇到的,假如你想了解每個階段的話,能夠看看深刻理解Java虛擬機這本書。數組

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

注意:ide

這裏須要指出的是,在執行類的初始化以前,其實在準備階段就已經爲類變量分配過內存,而且也已經設置過類變量的初始值了。例如像整數的初始值是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中狀況會立刻對類進行初始化code

  1. 當使用new這個關鍵字實例化對象、讀取或者設置一個類的靜態字段,以及調用一個類的靜態方法時會觸發類的初始化(注意,被final修飾的靜態字段除外)。
  2. 使用java.lang.reflect包的方法對類進行反射調用時,若是這個類尚未進行過初始化,則會觸發該類的初始化。
  3. 當初始化一個類時,若是其父類尚未進行過初始化,則會先觸發其父類。
  4. 當虛擬機啓動時,用戶須要指定一個要執行的主類(包含main()方法的那個類),虛擬機會先初始化這個主類。
  5. 當使用JDK 1.7的動態語言支持時,若是一個.....(省略,說了也看不懂,哈哈)。

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

初始化過程

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

在類的初始化過程當中,說白了就是執行了一個類構造器<clinit>()方法過程。注意,這個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

說明並無觸發子類的初始化

2.經過數組定義來引用類,不會觸發此類的初始化。

class Test3{
    public static void main(String[] args){
        SonClass[] sonClass = new SonClass[10];//引用上面的SonClass類。
    }      
 }

輸出結果是啥也沒輸出。

3.引用其餘類的常量並不會觸發那個類的初始化

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句是關鍵部分,1六、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語言中方法重寫的本質。

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

關注公個人衆號: 苦逼的碼農,獲取更多原創文章,後臺回覆 禮包送你一份特別的資源大禮包。
相關文章
相關標籤/搜索