1. 程式人生 > >JVM 之(16)方法呼叫

JVM 之(16)方法呼叫

前言

Java具備三種特性:封裝、繼承、多型。
Java檔案在編譯過程中不會進行傳統編譯的連線步驟,方法呼叫的目標方法以符號引用的方式儲存在Class檔案中,這種多型特性給Java帶來了更靈活的擴充套件能力,但也使得方法呼叫變得相對複雜,需要在類載入期間,甚至到執行期間才能確定目標方法的直接引用。

方法呼叫

所有方法呼叫的目標方法在Class檔案裡面都是常量池中的符號引用。在類載入的解析階段,如果一個方法在執行之前有確定的呼叫版本,且在執行期間不變,虛擬機器會將其符號引用解析為直接呼叫。

這種 編譯期可知,執行期不可變 的方法,主要包括靜態方法和私有方法兩大類,前者與具體類直接關聯,後者在外部不可訪問,兩者都不能通過繼承或別的方式進行重寫。

JVM提供瞭如下方法呼叫位元組碼指令:

  1. invokestatic:呼叫靜態方法;
  2. invokespecial:呼叫例項構造方法<init>,私有方法和父類方法;
  3. invokevirtual:呼叫虛方法;
  4. invokeinterface:呼叫介面方法,在執行時再確定一個實現此介面的物件;
  5. invokedynamic:在執行時動態解析出呼叫點限定符所引用的方法之後,呼叫該方法;

通過invokestatic和invokespecial指令呼叫的方法,可以在解析階段確定唯一的呼叫版本,符合這種條件的有靜態方法、私有方法、例項構造器和父類方法4種,它們在類載入時會把符號引用解析為該方法的直接引用。

public class InvokestaticTest {
    
    InvokestaticTest(){
        
    }
    
    public static void sayHello() {
        System.out.println("hello");
    }
    public static void main(String args[]) {
        sayHello();
    }
}

javap  -verbose  InvokestaticTest.class

可以發現例項構造器是通過invokespecial指令呼叫的, 

sayHello方法是通過invokestatic指令呼叫的。
通過invokestatic和invokespecial指令呼叫的方法,可以稱為非虛方法,其餘情況稱為虛方法,不過有一個特例,即被final關鍵字修飾的方法,雖然使用invokevirtual指令呼叫,由於它無法被覆蓋重寫,所以也是一種非虛方法。

非虛方法的呼叫是一個靜態的過程,由於目標方法只有一個確定的版本,所以在類載入的解析階段就可以把符合引用解析為直接引用,而虛方法的呼叫是一個分派的過程,有靜態也有動態,可分為靜態單分派、靜態多分派、動態單分派和動態多分派。

靜態分派

靜態分派發生在程式碼的編譯階段。針對於方法的過載

public class StaticDispatch {

    static class Parent{}
    static class Child1 extends Parent{}
    static class Child2 extends Parent{}

    public void sayHello(Parent parent){
        System.out.println("parent sayHello");
    }
    public void sayHello(Child1 child1){
        System.out.println("child1 sayHello");
    }
    public void sayHello(Child2 child2){
        System.out.println("child2 sayHello");
    }

    public static void main(String[] args) {
        Parent parent = new Parent();
        Parent parent1 = new Child1();
        Parent parent2 = new Child2();

        StaticDispatch staticDispatch = new StaticDispatch();
        staticDispatch.sayHello(parent);
        staticDispatch.sayHello(parent1);
        staticDispatch.sayHello(parent2);
        staticDispatch.sayHello((Child2)parent2);
    }
}
parent sayHello
parent sayHello
parent sayHello
child2 sayHello

javap  -verbose StaticDispatch.class

通過位元組碼指令,可以發現四次hello方法都是通過invokevirtual指令進行呼叫,而且前三次呼叫的是引數為Parent型別的sayHello方法,最後一次進行強轉後,呼叫Child2型別的sayHello方法。

再舉個例子,程式碼如下:

public class StaticDispatchTest {

    public void sayHello(short word){
        System.out.println("short" + word);
    }
    public void sayHello(int word){
        System.out.println("int" + word);
    }
    public void sayHello(long word){
        System.out.println("long" + word);
    }
    public void sayHello(String word){
        System.out.println("String" + word);
    }
    public void sayHello(char word){
        System.out.println("char" + word);
    }
    public void sayHello(Character word){
        System.out.println("Character" + word);
    }
    public void sayHello(Object word){
        System.out.println("Object" + word);
    }
    public void sayHello(char ... word){
        System.out.println("char ..." + word);
    }

    public static void main(String[] args) {
        StaticDispatchTest staticDispatch = new StaticDispatchTest();
        staticDispatch.sayHello('a');
    }
}
chara

優先匹配到char方法,其次是int,long,Character, Objedt, char...

        在編譯階段,Java編譯器會根據引數的靜態型別決定呼叫哪個過載版本,但在有些情況下,過載的版本不是唯一的,這樣只能選擇一個“更加合適的版本”進行呼叫,所以不建議在實際專案中使用這種模糊的方法過載。


動態分派

         在執行期間根據引數的實際型別確定方法執行版本的過程稱為動態分派,動態分派和多型性中的重寫(override)有著緊密的聯絡
         由於動態分派是非常頻繁的動作,因此在虛擬機器的實際實現中,會基於效能的考慮,並不會如此頻繁的搜尋對應方法,一般會在方法區中建立一個虛方法表,使用虛方法表代替方法查詢以提高效能。
        虛方法表在類載入的連線階段進行初始化,存放著各個方法的實際入口地址,如果某個方法在子類中沒有被重寫,那麼子類的虛方法表中該方法的入口地址和父類保持一致。
        一個類的方法表包含類的所有方法入口地址,從父類繼承的方法放在前面,接下來是介面方法和自定義的方法。如果某個方法在子類中沒有被重寫,那子類的虛方法表裡面的地址入口和父類相同的方法的入口地址一致。如果子類重寫了這個方法,子類方法表中的地址將會替換為指向子類實現版本的入口地址。

public class DynamicDispatch {

    static class Parent{
        public void sayHello(){
            System.out.println("Parent");
        }
    }
    static class Child1 extends Parent {
        public void sayHello(){
            System.out.println("Child1");
        }
    }
    static class Child2 extends Parent {
        public void sayHello(){
            System.out.println("Child2");
        }
    }
    public static void main(String[] args) {
        Parent parent = new Parent();
        Parent parent1 = new Child1();
        Parent parent2 = new Child2();

        parent.sayHello();
        parent1.sayHello();
        parent2.sayHello();
    }
}
Parent
Child1
Child2

javap  -verbose DynamicDispatch.class

可以發現,25、29和33的指令完全一樣,但最終執行的目標方法卻不相同,這得從invokevirtual指令的多型查詢說起了,invokevirtual指令在執行時分為以下幾個步驟:

  1. 找到運算元棧的棧頂元素所指向的物件的實際型別,記為C;
  2. 如果型別C中存在描述符和簡單名稱都相符的方法,則進行訪問許可權驗證,如果驗證通過,則直接返回這個方法的直接引用,否則返回java.lang.IllegalAccessError異常;
  3. 如果型別C中不存在對應的方法,則按照繼承關係,從下往上依次對型別C的各父類進行搜尋和驗證,進行第2步的操作;
  4. 如果各個父類也沒對應的方法,則丟擲異常AbstractMethodError


invokevirtual和invokeinterface的區別

         虛擬函式表上的虛方法是按照從父類到子類的順序排序的,因此對於使用invokevirtual呼叫的虛擬函式,JVM完全可以在編譯期就確定了虛擬函式在方法表上的offset,或者在首次呼叫之後就把這個offset快取起來,這樣就可以快速地從方法表中定位所要呼叫的方法地址。 
        然而對於介面型別引用,由於一個介面可以被不同的Class來實現,所以介面方法在不同類的方法表的offset當然就(很可能)不一樣了。因此,每次介面方法的呼叫,JVM都會搜尋一遍虛擬函式表,效率會比invokevirtual要低。