天天看點

基礎強化:深入了解JVM中的方法調用

方法調用并不等同于方法中的代碼被執行,方法調用階段唯一的任務就是确定被調用方法的版本(即調用哪一個方法),暫時還未涉及方法内部的具體運作過程。

一切方法調用在Class檔案裡面存儲的都隻是符号引用,而不是方法在實際運作時記憶體布局中的入口位址(也就是之前說的直接引用)。

解析

所有方法調用的目标方法在Class檔案裡面都是一個常量池中的符号引用,在類加載的解析階段,會将其中的一部分符号引用轉化為直接引用,這種解析能夠成立的前提是:方法在程式真正運作之前就有一個可确定的調用版本,并且這個方法的調用版本在運作期是不可改變的。

換句話說,調用目标在程式代碼寫好、編譯器進行編譯那一刻就已經确定下來。這類方法的調用被稱為解析(Resolution),在Java語言中符合這種要求的主要有靜态方法和私有方法。

方法調用指令

  • invokestatic:用于調用靜态方法。
  • invokespecial:用于調用執行個體構造器<init>()方法、私有方法和父類中的方法。
  • invokevirtual:用于調用所有的虛方法。
  • invokeinterface:用于調用接口方法,會在運作時再确定一個實作該接口的對象。
  • invokedynamic:先在運作時動态解析出調用點限定符所引用的方法,然後再執行該方法。
前面4條調用指令,分派邏輯都固化在Java虛拟機内部,而invokedynamic指令的分派邏輯是由使用者設定的引導方法來決定的。

方法分類

在java語言中方法主要分為“虛方法”和“非虛方法”。

  • 非虛方法:在類加載的時候就可以把符号引用解析為該方法的直接引用。比如:靜态方法、私有方法、執行個體構造器、父類方法和被final修飾的方法。
  • 虛方法:需要在運作時才能将符号引用轉換成直接引用,如,分派。

分派

分派(Dispatch)它可能是靜态的也可能是動态的,按照分派依據的宗量數可分為單分派和多分派。這兩類分派方式兩兩組合就構成了靜态單分派、靜态多分派、動态單分派、動态多分派4種分派組合情況。

靜态分派

依賴靜态類型來決定方法執行版本的分派動作,都稱為靜态分派。靜态分派的最典型應用表現就是方法重載,虛拟機(或者準确地說是編譯器)在重載時是通過參數的靜态類型來作為判定依據的。

public class StaticDispatch {

    static abstract class Human {
    }

    static class Man extends Human {
    }

    static class Woman extends Human {
    }

    public void sayHello(Human guy) {
        System.out.println("hello,guy!");
    }

    public void sayHello(Man guy) {
        System.out.println("hello,gentleman!");
    }

    public void sayHello(Woman guy) {
        System.out.println("hello,lady!");
    }

    public static void main(String[] args) {
        Human man = new Man();
        Human woman = new Woman();
        StaticDispatch sr = new StaticDispatch();
        sr.sayHello(man);
        sr.sayHello(woman);
    }
}      

運作結果:

hello,guy!
hello,guy!      
 Human man = new Man();      

這裡的Human就是變量的“靜态類型”(Static Type),或者叫“外觀類型”(Apparent Type);Man就是變量的“實際類型”(Actual Type)或者叫“運作時類型”(Runtime Type)。

動态分派

我們把在運作期根據實際類型确定方法執行版本的分派過程稱為動态分派。最典型的表現就是重寫。

public class DynamicDispatch {

    static abstract class Human {
        abstract void sayHello();
    }

    static class Man extends Human {
        public void sayHello() {
            System.out.println("hello,Man!");
        }
    }

    static class Woman extends Human {
        public void sayHello() {
            System.out.println("hello,Woman!");
        }
    }

    public static void main(String[] args) {
        Human man = new Man();
        Human woman = new Woman();
        man.sayHello();
        woman.sayHello();
    }
}      
hello,Man!
hello,Woman!      

我們通過javap指令看下main方法的位元組碼:

...
  public static void main(java.lang.String[]);
    descriptor: ([Ljava/lang/String;)V
    flags: ACC_PUBLIC, ACC_STATIC
    Code:
      stack=2, locals=3, args_size=1
         0: new           #2                  // class com/xiaolyuh/DynamicDispatch$Man
         3: dup
         4: invokespecial #3                  // Method com/xiaolyuh/DynamicDispatch$Man."<init>":()V
         7: astore_1
         8: new           #4                  // class com/xiaolyuh/DynamicDispatch$Woman
        11: dup
        12: invokespecial #5                  // Method com/xiaolyuh/DynamicDispatch$Woman."<init>":()V
        15: astore_2
        16: aload_1
        17: invokevirtual #6                  // Method com/xiaolyuh/DynamicDispatch$Human.sayHello:()V
        20: aload_2
        21: invokevirtual #6                  // Method com/xiaolyuh/DynamicDispatch$Human.sayHello:()V
        24: return
      LineNumberTable:
        line 27: 0
        line 28: 8
        line 29: 16
        line 30: 20
        line 31: 24
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0      25     0  args   [Ljava/lang/String;
            8      17     1   man   Lcom/xiaolyuh/DynamicDispatch$Human;
           16       9     2 woman   Lcom/xiaolyuh/DynamicDispatch$Human;
}
...      

通過位元組碼我們發現:在main方法中,sayHello()方法的調用對應的符号引用是一樣的,com/xiaolyuh/DynamicDispatch$Human.sayHello:()V。

在這裡我們可以得出一個結論:在動态分派的情況下,在編譯時期我們是無法确定方法的直接引用的,那麼它是怎麼實作重載方法的調用的呢?問題關鍵是在invokevirtual指令上,在執行invokevirtual指令時,invokevirtual指令會去确定方法的調用版本。

invokevirtual指令的運作過程

  1. 找到操作數棧頂的第一個元素所指向的對象的實際類型,記作C。
  2. 如果在類型C中找到與常量中的描述符和簡單名稱都相符的方法,則進行通路權限校驗,如果通過則傳回這個方法的直接引用,查找過程結束;不通過則傳回java.lang.IllegalAccessError異常。
  3. 否則,按照繼承關系從下往上依次對C的各個父類進行第二步的搜尋和驗證過程。4. 如果始終沒有找到合适的方法,則抛出java.lang.AbstractMethodError異常。

正是因為invokevirtual指令執行的第一步就是在運作期确定接收者的實際類型,是以兩次調用中的invokevirtual指令并不是把常量池中方法的符号引用解析到直接引用上就結束了,還會根據方法接收者的實際類型來選擇方法版本,這個過程就是Java語言中方法重寫的本質。

當子類聲明了與父類同名的字段時,雖然在子類的記憶體中兩個字段都會存在,但是子類的字段會遮蔽父類的同名字段

動态分派的實作

因為動态方法執行非常頻繁,并且動态分派的方法版本選擇需要在運作時,在實際接受者類型的方法中繼資料中搜尋合适的目标方法,是以,Java虛拟機實作基于執行性能的考慮,虛拟機會為類型在方法區中建立一個虛方法表(Virtual Method Table,也稱為vtable,與此對應的,在invokeinterface執行時也會用到接口方法表——Interface Method Table,簡稱itable),使用虛方法表索引來代替中繼資料查找以提高性能。

基礎強化:深入了解JVM中的方法調用

虛方法表中存放着各個方法的實際入口位址。如果某個方法在子類中沒有被重寫,那子類的虛方法表中的位址入口和父類相同方法的位址入口是一緻的,都指向父類的實作入口。如果子類中重寫了這個方法,子類虛方法表中的位址也會被替換為指向子類實作版本的入口位址。

在圖中,Son重寫了來自Father的全部方法,是以Son的方法表沒有指向Father類型資料的箭頭。但是Son和Father都沒有重寫來自Object的方法,是以它們的方法表中所有從Object繼承來的方法都指向了Object的資料類型。

虛方法表一般在類加載的連接配接階段進行初始化,準備了類的變量初始值後,虛拟機會把該類的虛方法表也一同初始化完畢。

單分派與多分派

方法的接收者與方法的參數統稱為方法的宗量。分派基于多少種宗量,可以将分派劃分為單分派和多分派兩種。單分派是根據一個宗量對目标方法進行選擇,多分派則是根據兩個及以上的宗量對目标方法進行選擇。

  • 靜态分派需要根據靜态類型和方法參數兩個宗量來确定方法調用,是以屬于多分派。
  • 動态分派隻需要根據實際類型一個宗量來确定方法的調用,是以屬于單分派。
在動态分派的過程中,方法簽名是确定的,是以方法參數就不會變,方法調用就取決于參數的實際類型。

總結