1. 程式人生 > >深入理解 Java 虛擬機器(九)方法呼叫

深入理解 Java 虛擬機器(九)方法呼叫

方法呼叫

方法呼叫不等同於方法執行,方法呼叫階段唯一任務就是確定被呼叫方法的版本(即呼叫哪一個方法),暫時還不涉及方法內部的具體執行過程。一切方法呼叫在 Class 檔案裡面儲存的都只是符號引用,需要在類載入期間,甚至到執行期間才能確定目標方法的直接引用。

解析

在類載入的解析階段,會把其中一部分方法的符號引用轉化為直接引用,解析成立的前提是:方法在程式真正執行之前就有一個可確定的呼叫版本,並且這個方法的呼叫版本在執行期是不可改變的,這類方法的呼叫稱為解析。

在 Java 中,符合編譯期可知,執行期不可變的要求的方法主要包括靜態方法和私有方法,因此它們都適合在類載入階段進行解析。

Java 虛擬機器提供了 5 條方法呼叫的位元組碼指令: 1) invokestatic:呼叫靜態方法 2) invokespecial:呼叫 < init> 方法、私有方法和父類方法 3) invokevirtual:呼叫所有的虛方法 4) invokeinterface:呼叫介面方法,在執行時再確定一個實現此介面的物件 5) invokedynamic:執行時動態解析出呼叫點限定符所引用的方法,然後再執行

前 4 條指令的分派邏輯是固定在 Java 虛擬機器內部的,而 invokedynamic 指令的分派邏輯是由使用者所設定的引導方法決定的。只要能被 invokestatic、invokespecial 指令呼叫的方法,都可以在解析階段確定唯一的呼叫版本,它們在類載入的時候就會把符號引用解析為該方法的直接引用,這些方法可以稱為非需方法,其它方法可以稱為虛方法。

此外還有一種方法,就是被 final 修飾的方法,雖然 final 方法是使用 invokevirtual 指令來呼叫的,但 Java 語言規範中明確說明了 final 方法是一種非虛方法。

分派

解析是一個靜態的過程,而分配呼叫可能是靜態的,也可能是動態的,根據分配的宗量(後面會說明)數可以分配為單分派和多分派,因此共有靜態單分派、靜態多分派、動態單分派、動態多分派 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!

虛擬機器在過載時是通過引數的靜態型別而不是實際型別作為判定依據的,因此,在編譯階段,Javac 編譯器會根據引數的靜態型別決定使用哪個過載版本。

所有依賴靜態型別來決定方法執行版本的分派動作成為靜態分派,靜態分派發生在編譯階段。另外,雖然編譯器能確定方法的過載版本,但很多情況下這個過載版本並不是唯一的,往往只能確定一個更合適的版本,比如:

public class Overload {

    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) {
        sayHello('a');
    }
}

這段程式碼的輸出結果是:

hello char

但它有一個很神奇的地方,假如註釋掉方法 sayHello(char arg),那麼它會輸出:

hello int

繼續註釋下去,發現 ‘a’ 會按優先順序依次被轉型為 char、int、long、Character、Serializeable(Character 實現的介面之一)、Object、char…,可見變長引數的過載優先順序是最低的,但注意,’a’ 不能轉型為 Integer、Long 等型別。

不過,這種程式碼相當於“茴香豆的茴有幾種寫法”的研究,實際程式設計應儘量避免這種情況。

2.2.2 動態分派

動態分派和覆蓋(Override,複寫、重寫)有著很密切的關聯,比如:

public class DynamicDispatch {

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

    static class Man extends Human {
        @Override
        protected void sayHello() {
            System.out.println("man say hello");
        }
    }

    static class Woman extends Human {
        @Override
        protected void sayHello() {
            System.out.println("woman say hello");
        }
    }

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

執行結果:

man say hello
woman say hello
woman say hello

man、woman 這兩個物件是將要執行的 sayHello() 方法的所有者,稱為接收者。

使用 javap 分析這段程式碼:

  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 org/fenixsoft/polymorphic/DynamicDispatch$Man
         3: dup
         4: invokespecial #3                  // Method org/fenixsoft/polymorphic/DynamicDispatch$Man."<init>":()V
         7: astore_1
         8: new           #4                  // class org/fenixsoft/polymorphic/DynamicDispatch$Woman
        11: dup
        12: invokespecial #5                  // Method org/fenixsoft/polymorphic/DynamicDispatch$Woman."<init>":()V
        15: astore_2
        16: aload_1
        17: invokevirtual #6                  // Method org/fenixsoft/polymorphic/DynamicDispatch$Human.sayHello:()V
        20: aload_2
        21: invokevirtual #6                  // Method org/fenixsoft/polymorphic/DynamicDispatch$Human.sayHello:()V
        24: new           #4                  // class org/fenixsoft/polymorphic/DynamicDispatch$Woman
        27: dup
        28: invokespecial #5                  // Method org/fenixsoft/polymorphic/DynamicDispatch$Woman."<init>":()V
        31: astore_1
        32: aload_1
        33: invokevirtual #6                  // Method org/fenixsoft/polymorphic/DynamicDispatch$Human.sayHello:()V
        36: return

可以發現,第 17、33 條語句中的引數都是同一個,但最終執行的目標方法不同,這是 invokevirtual 的特性決定的,invokevirtual 指令的執行時解析過程大致可以分為一下幾個步驟:

1) 找到運算元棧頂的第一個元素所指向的物件的實際型別 C

2) 如果在 C 中找到與常量中的描述符和簡單名稱都相符的方法,則進行訪問許可權校驗,通過則返回直接引用,查詢結束;否則丟擲 java.lang.IllegalAccessError 異常

3) 否則,按照繼承關係從下往上一次對 C 的各個父類進行第 2 步的搜尋和驗證過程

4) 如果沒有找到,則丟擲 java.lang.AbstractMethodError 異常

單分派與多分派

方法的接收者與方法的引數統稱為方法的宗量。例子:

public class Dispatch {

    static class QQ {}

    static class _360 {}

    public static class Father {
        public void hardChoice(QQ arg) {
            System.out.println("father choose qq");
        }

        public void hardChoice(_360 arg) {
            System.out.println("father choose 360");
        }
    }

    public static class Son extends Father {
        public void hardChoice(QQ arg) {
            System.out.println("son choose qq");
        }

        public void hardChoice(_360 arg) {
            System.out.println("son choose 360");
        }
    }

    public static void main(String[] args) {
        Father father = new Father();
        Father son = new Son();
        father.hardChoice(new _360());
        son.hardChoice(new QQ());
    }
}

執行結果:

father choose 360
son choose qq

在這個例子中,有兩個分派過程。一個是編譯階段編譯器的選擇過程,也就是靜態分派的過程,此時選擇目標方法的依據有兩點:物件的靜態型別、方法引數的型別,因此是靜態多分派。而執行階段虛擬機器的選擇過程,也就是動態分派的過程,此時選擇目標方法的依據只有一個:物件的實際型別,因此是動態單分派。

因此,今天的 Java 是一門靜態多分派,動態單分派的語言。

虛擬機器動態分派的實現

由於動態分派是非常頻繁的動作,因此基於效能的考慮,大部分虛擬機器實現都會進行優化,最常用的是穩定優化,為類在方法區中簡歷一個虛方法表(與此對應的,invokeinterface 執行時也會用到介面方法表),使用虛方法表來代替元資料查詢以提高效能。虛方法表結構如圖:

虛擬機器位元組碼執行引擎

虛擬機器除了使用方法表之外,在條件允許的情況下,還會使用內聯快取和基於型別繼承關係分析技術的守護內聯兩種非穩定的激進優化手段來獲得高效能。

動態型別語言支援

Java 虛擬機器的位元組碼指令集數量從 Sun 釋出第一款 Java 虛擬機器到 JDK 7 之間,一直沒發生任何變化,直到 JDK 7,位元組碼指令集終於迎來了第一位新成員——invokedynamic,這條指令的增加是 JDK 7 實現動態型別語言支援而進行的改進之一,也是為 JDK 8 可以順利實現 Lambda 表示式做技術準備。

動態型別語言的關鍵特徵是它的型別檢查的主體過程是在執行期,而不是編譯期。

Java 虛擬機器的願景之一是支援 Java 以外如 Groovy 這樣的動態型別語言,JDK 1.7 以前也能實現類似的效果,但會帶來額外的效能或記憶體開銷,因此在 Java 虛擬機器層面上提供動態型別的語言的直接支援就成為了 Java 語言平臺的發展趨勢之一。

JDK 1.7 加入了 java.lang.invoke 包,這個包的目的是在之前單純依靠符號引用來確定呼叫的目標方法這種方式以外,提供一種新的動態確定目標方法的機制,稱為 MethodHandle。例子:

/**
* JSR 292 MethodHandle基礎用法演示
* @author zzm
*/
public class MethodHandleTest {

    static class ClassA {
        public void println(String s) {
            System.out.println("ClassA: " + s);
        }
    }

    public static void main(String[] args) throws Throwable {
        Object obj = System.currentTimeMillis() % 2 == 0 ? System.out : new ClassA();
        // 無論obj最終是哪個實現類,下面這句都能正確呼叫到println方法。
        getPrintlnMH(obj).invokeExact("icyfenix");
    }

    private static MethodHandle getPrintlnMH(Object reveiver) throws Throwable {
        // MethodType:代表“方法型別”,包含了方法的返回值和具體引數
        MethodType mt = MethodType.methodType(void.class, String.class);
        // lookup()方法來自於 MethodHandles.lookup
        // 作用是在指定類中查詢符合給定的方法名稱、方法型別,並且符合呼叫許可權的方法控制代碼
        // 因為這裡呼叫的是一個虛方法,方法第一個引數是隱式的,代表該方法的接收者 - this
        // this 這個引數以前是放在引數列表中進行傳遞,現在提供了 bindTo() 方法來完成這件事情
        return lookup().findVirtual(reveiver.getClass(), "println", mt).bindTo(reveiver);
    }

}

雖然用反射也能實現相同的效果,但 MethodHandle 和反射還是有區別的:

1) Reflection 是模仿 Java 程式碼層次的方法呼叫,MethodHandle 則是在模擬位元組碼層次的方法呼叫,MethodHandle.lookup 中的 3 個方法 findStatic、findVirtual、findSpecial 對應於 invokestatic、invokevirtual & invokeinterface、invokespecial 這幾條位元組碼指令。

2) Reflection 中的 java.lang.reflect.Method 物件遠比 MethodHandle 機制中的 java.lang.invoke.MethodHandle 物件所包含的資訊多

3) 由於 MethodHandle 是對位元組碼的方法指令呼叫的模擬,所以虛擬機器在這方面做的各種優化,理論上 MethodHandle 也可以採用類似的思路去支援(目前還不完善),而通過反射去呼叫方法則不行

4) 最關鍵的是,Reflection API 是為 Java 語言服務的,而 MethodHandle 則可服務於所有 Java 虛擬機器之上的語言

invokedynamic 指令與 MethodHandle 機制的作用是一樣的,都是為了解決 invoke 指令方法分派規則固化在虛擬機器之中的問題,把如何查詢目標方法的決定權從虛擬機器轉嫁到使用者程式碼之中,讓使用者有更高的自由度。invokedynamic 的第一個引數是 CONSTANT_InvokeDynamic_info 常量,從這個常量可以得到 3 項資訊:引導方法(Bootstrap Method)、方法型別(MethodType)和名稱。引導方法有固定的引數,返回值是 java.lang.invoke.CallSite 物件,這個代表真正要執行的目標方法呼叫。例子:

public class InvokeDynamicTest {

    public static void main(String[] args) throws Throwable {
        INDY_BootstrapMethod().invokeExact("icyfenix");
    }

    public static void testMethod(String s) {
        System.out.println("hello String: " + s);
    }

    // 生成 testMethod
    public static CallSite BootstrapMethod(MethodHandles.Lookup lookup, String name, MethodType mt) throws Throwable {
        return new ConstantCallSite(lookup.findStatic(InvokeDynamicTest.class, name, mt));
    }

    // 生成 BootstrapMethod 對應的 MethodType
    private static MethodType MT_BootstrapMethod() {
        return MethodType
                .fromMethodDescriptorString(
                        "(Ljava/lang/invoke/MethodHandles$Lookup;Ljava/lang/String;Ljava/lang/invoke/MethodType;)" +
                                "Ljava/lang/invoke/CallSite;",
                        null);
    }

    // 生成 BootstrapMethod
    private static MethodHandle MH_BootstrapMethod() throws Throwable {
        return lookup().findStatic(InvokeDynamicTest.class, "BootstrapMethod", MT_BootstrapMethod());
    }

    // 呼叫 testMethod
    private static MethodHandle INDY_BootstrapMethod() throws Throwable {
        CallSite cs = (CallSite) MH_BootstrapMethod().invokeWithArguments(lookup(), "testMethod",
                MethodType.fromMethodDescriptorString("(Ljava/lang/String;)V", null));
        return cs.dynamicInvoker();
    }
}

invokedynamic 與其它 4 條 invoke 指令最大的差別是它的分派邏輯不由虛擬機器決定,而是由程式設計師決定。