1. 程式人生 > >一步搞清楚多態與類初始化的底層原理

一步搞清楚多態與類初始化的底層原理

形式 訪問 運行時 接收 底層原理 方法調用 代表性 沒有初始化 一次

首先我們先看一個段非常有代表性的代碼,裏面一口氣牽扯到了多態和類初始化順序知識。

public class Test {

    public static void main(String[] args) {
        A test = new B();
    }
}

class A {
    int value = 10;

    A() {
        System.out.println("父類構造器");
        process();
    }

    public void process() {
        System.out.println("父類的process");
        value++;
        System.out.println(value);
    }
}

class B extends A {
    int value = 12;
    {
        value++;
    }
    B() {
        System.out.println("子類構造器");
        process();
    }

    public void process() {
        System.out.println("子類的process");
        System.out.println(value);
        value++;
        System.out.println(value);
    }
}

它的輸出是:

父類構造器

子類的process

0

1

子類構造器

子類的process

13

14

我想現在你一定很困惑,不要慌上車!帶你了解底層的原理

為什麽會調用子類的process()方法?

這裏的底層原理是Java的動態分派機制

對於方法重寫,Java采用的是動態分派機制,也就是說在運行的時候才確定調用哪個方法。由於A的實際類型是B,因此調用的就是B的process()法。

原理在底層字節碼中的invokevirtual指令的多態查找過程,分為以下幾個步驟:

  1. 找到操作數棧棧頂的第一個元素所指向的對象的實際類型,記為C
  2. 如果在類型C中找到與常量中描述符和簡單名稱都相符的方法,則進行訪問權限的校驗,如果通過則返回這個方法的直接引用,查找結束;如果不通過,則返回非法訪問異常
  3. 如果在類型C中沒有找到,則按照繼承關系從下到上依次對C的各個父類進行第2步的搜索和驗證過程
  4. 如果始終沒有找到合適的方法,則拋出抽象方法錯誤的異常

從這個過程可以發現,在第一步的時候就在運行期確定接收對象(執行方法的所有者程稱為接受者)的實際類型,所以當調用invokevirtual指令就會把運行時常量池中符號引用解析為直接引用,這就是方法重寫的本質。

相信到這你還是迷迷糊糊的,那是因為缺少對類加載過程中解析知識的了解

解析是類加載的過程之一

解析階段時虛擬機將常量池內的符號引用替換為直接引用的過程。

  • 符號引用以一組符號來描述所引用的目標,符號可以是任何形式的字面量,只要使用時能無歧義的地位到目標即可。
  • 直接引用可以是直接指向目標的指針、相對偏移量或是一個能間接定位到目標的句柄。
  • 我們知道Class文件的常量池中存有大量的符號引用(字節碼中方法調用指令就以常量池中指向方法的符號引用作為參數)。這些符號引用一部分會在類加載階段或者第一次使用的時候就轉化為直接引用,這種轉化稱為靜態解析。
  • 另一部分將在每一次運行期間轉化為直接引用,這部分稱為動態連接。

通俗點說,所有方法調用中的目標方法在Class文件裏面都是一個常量池中的符號引用,在類加載的解析階段,會將其中的一部分符號引用轉化為直接引用,這種解析能成立的前提是——>方法在程序真正運行之前就有一個可確定的調用版本(主要是靜態方法和私有方法),它們的調用版本在運行期是不可變的。因為靜態方法和私有方法不可能通過繼承或別的方式重寫成其他版本!!劃重點——>其他版本,因此他們都在類加載階段解析完成了。

綜上可知,在動態分派的機制下,因為子類繼承父類重寫了process()方法,只有在程序運行時才能確定的調用版本,將符號引用轉化成了直接引用,指向了實例的process()方法。

這種在運行期根據實際類型確定方法執版本的分派過程就是動態分派。

為什麽打印出來的是0和1?

這是因為在對象實例化的時候,劃分內存後會直接賦零值。

對象的創建

  • 虛擬機遇到一條new指令時,首先將會去檢查這個指令的參數能否能在常量池中定位到一個類的符號引用,並且檢查這個符號引用代表的類是否已被加載、解析和初始化過。如果沒有,那必須先執行相應的類加載過程。
  • 在類加載檢查通過後,虛擬機將為新生對象分配內存。對象所需內存的大小在類加載完成後便可完全確定,為對象分配空間的任務等同於把一塊確定大小的內存從Java堆中劃分出來。
    • 如果Java堆中的內存是絕對規整的,所有用過的內存都放在一邊,空閑的內存放在另一邊,中間放著一個指針作為分界點的指示器,那所分配內存就僅僅是把那個指針向空閑空間那邊挪動一段與對象大小相等的距離,這種叫做指針碰撞
    • 如果Java堆中的內存不是規整的,虛擬機就必須維護一個列表,記錄哪塊內存塊是可用的,在分配的時候從列表中到找一塊足夠大的空間劃分給對象實例,並更新列表上的記錄,這鐘叫做空閑列表
  • 並發分配對象內存有兩種解決方案->方案一:虛擬機采用CAS配上失敗重試的方式保證更新操作的原子性;方案二:把內存分配的動作按照線程劃分在不同的空間之中進行,即每個線程在Java堆中預先分配一小塊內存,稱為本地線程分配緩存(Thread Local Allocation Buffer,TLAB)。哪個線程要分配內存就在哪個線程的TLAB上分配,只有TLAB用完並分配新的TLAB時,才需要同步鎖定。是否開啟TLAB:-XX:+/-UseTLAB
  • 內存分配完成後,虛擬機需要將分配到的內存空間都初始化為零值,這一步操作保證了對象的實例字段在Java代碼中可以不賦初始值就直接使用,程序能訪問到這些字段的數據類型所對應的零值。
  • 虛擬機要對對象進行設置,例如對象是哪個實例,如何找到類的元數據信息、對象的哈希碼、對象GC分代年齡,將這些信息存放在對象頭之中。
  • 知執行new指令之後會接著執行init方法,把對象按照程序員的意願進行初始化。

可以知道,當父類調用子類的process()方法時,子類並沒有初始化完成,僅僅是分配了內存,這裏有個實例變量初始化順序:

遵循的原則是:

  (1)按照代碼中的順序依次執行實例變量定義語句和實例變量代碼塊;

  (2)如果創建該類的對象時該類的類變量尚未初始化,則先初始化類變量,再初始化實例變量;

  (3)如果該類有父類的話,則先創建一個父類對象;並且,如果父類類變量沒被初始化時,先初始化父類的類變量,再初始化父類的實例變量,再調用父類的默認構造器;

//有繼承的情形(且該類和父類的類變量未被初始化)
1.父類的static變量初始化和static代碼塊
2.子類的static變量初始化和static代碼塊
3.父類的實例變量初始化和實例變量初始化代碼塊
4.父類的構造函數
5.子類的實例變量初始化和實例變量初始化代碼塊
6.子類構造函數

相信到這你理解了為什麽會打出0和1了,是因為父類的構造函數是在子類的實例變量初始化之前執行的。所以當輸出value時,其值為0。

一步搞清楚多態與類初始化的底層原理