【修煉內功】[JVM] 虛擬機視角的方法調用

本文已收錄 【修煉內功】躍遷之路

虛擬機視角的方法調用

『咱們寫的Java方法在被編譯爲class文件後是如何被虛擬機執行的?對於重寫或者重載的方法,是在編譯階段就肯定具體方法的麼?若是不是,虛擬機在運行時又是如何肯定具體方法的?』java

方法調用不等於方法執行,一切方法調用在class文件中都只是常量池中的符號引用,這須要在類加載的解析階段甚至到運行期間才能將符號引用轉爲直接引用,肯定目標方法進行執行es6

在編譯過程當中編譯器並不知道目標方法的具體內存地址,所以編譯器會暫時使用符號引用來表示該目標方法

編譯代碼segmentfault

public class MethodDescriptor {
    public void printHello() {
        System.out.println("Hello");
    }

    public void printHello(String name) {
        System.out.println("Hello " + name);
    }

    public static void main(String[] args) {
        MethodDescriptor md = new MethodDescriptor();
        md.printHello();
        md.printHello("manerfan");
    }
}

查看其字節碼數組

method_invoke_1

main方法中調用兩次不一樣的printHello方法,對應class文件中均爲invokevirtual指令,分別調用常量池中的#12及#14,查看常量池ide

method_invoke_2

#12及#14對應兩個Methodref方法引用,這兩個方法引用均爲符號引用(使用方法描述符)而並不是直接引用函數

虛擬機識別方法的關鍵在於類名、方法名及方法描述符(method descriptor),方法描述符由方法的參數類型及返回類型構成性能

方法名及方法描述符在編譯階段即可以肯定,但對於實際類名,一些場景下(如類繼承)只有在運行時纔可知es5

方法調用指令

目前Java虛擬機裏提供了5中方法調用的字節碼指令spa

  • invokestatic: 調用靜態方法
  • invokespecial: 調用實例構造器<init>方法、私有方法及父類方法
  • invokevirtual: 調用虛方法(會在運行時肯定具體的方法對象)
  • invokeinterface: 調用接口方法(會在運行時肯定一個實現此接口的對象)
  • invokedynamic: 先在運行時動態解析出調用點限定符所引用的方法,而後再執行該方法

invokestatic及invokespecial調用的方法(靜態方法、構造方法、私有方法、父類方法),都可以在類加載的解析階段肯定惟一的調用版本,從而將符號引用直接解析爲該方法的直接引用,這些方法稱之爲非虛方法3d

而invokevirtual及invokeinterface調用的方法(final方法除外,下文提到),在解析階段並不能惟一肯定,只有在運行時才能拿到實際的執行類從而肯定惟一的調用版本,此時才能夠將符號引用轉爲直接引用,這些方法稱之爲虛方法

invokedynamic比較特殊,單獨分析

簡單示意,以下代碼

public interface MethodBase {
    String getName();
}

public class BaseMethod implements MethodBase {
    @Override
    public String getName() {
        return "manerfan";
    }

    public void print() {
        System.out.println(getName());
    }
}

public class MethodImpl extends BaseMethod {
    @Override
    public String getName() {
        return "maner-fan";
    }

    @Override
    public void print() {
        System.out.println("Hello " + getName());
    };

    public String getSuperName() {
        return super.getName();
    }

    public static String getDefaultName() {
        return "default";
    }
}

public class MethodDescriptor {
    public static void print(BaseMethod baseMethod) {
        baseMethod.print();
    }

    public static String getName(MethodBase methodBase) {
        return methodBase.getName();
    }

    public static void main(String[] args) {
        MethodImpl.getDefaultName();

        MethodImpl ml = new MethodImpl();
        ml.getSuperName();
        getName(ml);
        print(ml);
    }
}

查看MethodDescriptor的字節碼

method_invoke_3

不難發現,接口MethodBase中getName方法的調用均被編譯爲invokeinterface指令,子類BaseMethod中print方法的調用則被便覺得invokevirtual執行,靜態方法的調用被編譯爲invokestatic指令,而構造函數調用則被編譯爲invokespecial指令

查看MethodImpl字節碼

method_invoke_4

能夠看到,父類方法的調用則被編譯爲invokespecial指令

橋接方法

JVM - 類文件結構中有介紹方法的訪問標識,其中有兩條 ACC_BRIDGE(橋接方法) 及 ACC_SYNTHETIC(編譯器生成,不會出如今源碼中),而橋接方法即是由編譯器生成,且會將橋接方法標記爲ACC_BRIDGE及ACC_SYNTHETIC,那何時會生成橋接方法?

橋接方法是 JDK 1.5 引入泛型後,爲了使Java的泛型方法生成的字節碼和 1.5 版本前的字節碼相兼容,由編譯器自動生成的,就是說一個子類在繼承(或實現)一個父類(或接口)的泛型方法時,在子類中明確指定了泛型類型,那麼在編譯時編譯器會自動生成橋接方法(固然還有其餘狀況會生成橋接方法,這裏只是列舉了其中一種狀況)

public class BaseMethod<T> {
    public void print(T obj) {
        System.out.println("Hello " + obj.toString());
    }
}

public class MethodImpl extends BaseMethod<String> {
    @Override
    public void print(String name) {
        super.print(name);
    };
}

首先查看BaseMethod字節碼

method_invoke_5

因爲泛型的擦除機制,print的方法描述符入參被標記爲(Ljava/lang/Object;)V

再查看MethodImpl字節碼

method_invoke_6

MethodImpl只聲明瞭一個print方法,卻被編譯爲兩個,一個方法描述符爲(Ljava/lang/String;)V,另外一個爲(Ljava/lang/Object;)V且標記爲ACC_BRIDGE ACC_SYNTHETIC

print(java.lang.Object)方法中作了一層類型轉換,將入參轉爲String類型,進而再調用print(java.lang.String)方法

爲何要生成橋接方法

泛型能夠保證在編譯階段檢查對象類型是否匹配執行的泛型類型,但爲了向下兼容(1.5以前),在編譯時則會擦除泛型信息,若是不生成橋接方法則會致使字節碼中子類方法爲print(java.lang.Object)而父類爲print(java.lang.String),這樣的狀況是沒法作到向下兼容的

橋接方法的隱患

既然橋接方法是爲了向下兼容,那會不會有什麼反作用?

public class MethodDescriptor {
    public static void main(String[] args) {
        BaseMethod bm = new MethodImpl();
        bm.print("manerfan");
        bm.print(new Object());
    }
}

查看字節碼

method_invoke_7

能夠看到,雖然MethodImpl.print方法入參聲明爲String類型,但實際調用的仍是橋接方法print(java.lang.Object)

因爲子類的入參爲Object,因此編譯並不會失敗,但從MethodImpl的字節碼中能夠看到,橋接方法是有一次類型轉換的,在將類型轉爲String以後會調用print(java.lang.String)方法,那若是類型轉換失敗呢?運行程序能夠獲得

Hello manerfan
Exception in thread "main" java.lang.ClassCastException: java.lang.Object cannot be cast to java.lang.String
    at MethodImpl.print(MethodImpl.java:1)
    at MethodDescriptor.main(MethodDescriptor.java:5)

因此,因爲泛型的擦除機制,會致使某些狀況下(如方法橋接)的錯誤,只有在運行時才能夠被發現

對於其餘狀況,你們能夠編寫更爲具體的代碼查看其字節碼指令

分派

靜態分派

首先看一個重載的例子

public class StaticDispatch {
    static abstract class Animal {
        public abstract void croak();
    }

    static class Dog extends Animal {
        @Override
        public void croak() {
            System.out.println("汪汪叫~");
        }
    }

    static class Duck extends Animal {
        @Override
        public void croak() {
            System.out.println("呱呱叫~");
        }
    }

    public void croak(Animal animal) {
        System.out.println("xx叫~");
    }

    public void croak(Dog dog) {
        dog.croak();
    }

    public void croak(Duck duck) {
        duck.croak();
    }

    public static void main(String[] args) {
        Animal dog = new Dog();
        Animal duck = new Duck();
        StaticDispatch dispatcher = new StaticDispatch();
        dispatcher.croak(dog);
        dispatcher.croak(duck);
    }
}

運行結果

xx叫~
xx叫~

起始並不難理解爲何兩次都執行了croak(Animal)的方法,這裏要區分變量的靜態類型以及變量的實際類型

一個對象的靜態類型在編譯器是可知的,但並不知道其實際類型是什麼,實際類型只有在運行時纔可知

編譯器在重載時,是經過參數的靜態類型(而不是實際類型)做爲斷定依據以決定使用哪一個重載版本的,全部依賴靜態類型來定位方法執行版本的分派動做成爲靜態分派,靜態分派發生在編譯階段,所以嚴格來說靜態分派並非虛擬機的行爲

動態分派

一樣,仍是上述示例,修改main方法

public static void main(String[] args) {
     Animal dog = new Duck();
     Animal duck = new Dog();
     dog.croak();
     duck.croak();
 }

運行結果

呱呱叫~
汪汪叫~

顯然這裏並不能使用靜態分派來決定方法的執行版本(編譯階段並不知道dog及duck的實際類型),查看字節碼

method_invoke_8

兩次croak調用均使用了invokevirtual指令,invokevirtual指令(invokeinterface相似)運行時解析過程大體爲

  1. 找到對象實際類型C
  2. 在C常量池中查找方法描述符相符的方法,若是找到則返回方法的直接引用,若是無權訪問則拋jaba.lang.IllegalAccessError異常
  3. 若是未找到,則按照繼承關係從下到上一次對C的各個父類進行第2步的搜索
  4. 若是均未找到,則拋java.lang.AbstractMethodError異常

實際運行過程當中,動態分派是很是頻繁的動做,而動態分派的方法版本選擇須要在類的方法元數據中進行搜索,處於性能的考慮,類在方法區中均會建立一個虛方法表(virtual method table, vtable)及接口方法表(interface method table, itable),使用虛方法表(接口方法表)索引來代替元數據查找以提升性能

方法表本質上是一個數組,每一個數組元素都指向一個當前類機器祖先類中非私有的實力方法

method_invoke_9

動態調用

在JDK1.7之前,4條方法調用指令(invokestatic、invokespecial、invokevirtual、invokeinterface),均與包含目標方法類名、方法名及方法描述符的符號引用綁定,invokestatic及invokespecial的分派邏輯在編譯時便肯定,invokevirtual及invokeinterface的分配邏輯也由虛擬機在運行時決定,在此以前,JVM虛擬機並不能實現動態語言的一些特性,典型的例子即是鴨子類型(duck typing)

鴨子類型(duck typing)是多態(polymorphism)的一種形式,在這種形式中無論對象屬於哪一個,也無論聲明的具體接口是什麼,只要對象實現了相應的方法函數就能夠在對象上執行操做
public class StaticDispatch {
    static class Duck {
        public void croak() {
            System.out.println("呱呱叫~");
        }
    }
    
    static class Dog {
        public void croak() {
            System.out.println("學鴨子呱呱叫~");
        }
    }

    public static void duckCroak(Duck duckLike) {
        duckLike.croak();
    }

    public static void main(String[] args) {
        Duck duck = new Duck();
        Dog dog = new Dog();
        duckCroak(duck);
        duckCroak(dog); // 編譯錯誤
    }
}

咱們不關心Dog是否是Duck,只要Dog能夠像Duck同樣croak就能夠

方法句柄

Duck Dog croak的問題,咱們可使用反射來解決,也可使用一種新的、更底層的動態肯定目標方法的機制來實現--方法句柄

方法句柄是一個請類型的、可以被直接執行的引用,相似於C/C++中的函數指針,能夠指向常規的靜態方法或者實力方法,也能夠指向構造器或者字段

public class Dispatch {
    static class Duck {
        public void croak() {
            System.out.println("呱呱叫~");
        }
    }

    static class Dog {
        public void croak() {
            System.out.println("學鴨子呱呱叫~");
        }
    }

    public static void duckCroak(MethodHandle duckLike) throws Throwable {
        duckLike.invokeExact();
    }

    public static void main(String[] args) throws Throwable {
        Duck duck = new Duck();
        Dog dog = new Dog();

        MethodType mt = MethodType.methodType(void.class);
        MethodHandle duckCroak = MethodHandles.lookup().findVirtual(duck.getClass(), "croak", mt).bindTo(duck);
        MethodHandle dogCroak = MethodHandles.lookup().findVirtual(dog.getClass(), "croak", mt).bindTo(dog);

        duckCroak(duckCroak);
        duckCroak(dogCroak);
    }
}

這樣的事情,使用反射不同能夠實現麼?

  1. 本質上講,Reflection及MethodHandler都是在模擬方法調用,但Reflection是Java代碼層次的模擬,MethodHandler是字節碼層次的層次,更爲底層
  2. Reflection相比MethodHandler包含更多的信息,Reflection是重量級的,MethodHandler是輕量級的

invokedynamic

invokedynamic是Java1.7引入的一條新指令,用以支持動態語言的方法調用,解決原有4條"invoke*"指令方法分派規則固化在虛擬機中的問題,把如何查找目標方法的決定權從虛擬機轉嫁到具體用戶代碼中,使用戶擁有更高的自由度

invokedynamic將調用點(CallSite)抽象成一個Java類,而且將本來由Java虛擬機控制的方法調用以及方法連接暴露給了應用程序,在運行過程當中,每一條invokedynamic指令將捆綁一個調用點,而且會調用該調用點所連接的方法句柄

在Java8之前,並不能直接經過Java程序編譯生成invokedynamic指令,這裏寫一段代碼用以模擬上述過程

public class DynamicDispatch {
    /**
     * 動態調用的方法
     */
    private static void croak(String name) {
        System.out.println(name + " croak");
    }

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

    /**
     * 生成啓動方法
     */
    private static CallSite BootstrapMethod(MethodHandles.Lookup lookup, String name, MethodType mt) throws Throwable {
        return new ConstantCallSite(lookup.findStatic(DynamicDispatch.class, name, mt));
    }

    /**
     * 生成啓動方法的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);
    }

    /**
     * 生成啓動方法的MethodHandle
     */
    private static MethodHandle MH_BootstrapMethod() throws Throwable {
        return MethodHandles.lookup().findStatic(DynamicDispatch.class, "BootstrapMethod", MT_BootstrapMethod());
    }

    /**
     * 生成調用點,動態調用
     */
    private static MethodHandle INDY_BootstrapMethod() throws Throwable {
        // 生成調用點
        CallSite cs = (CallSite)MH_BootstrapMethod().invokeWithArguments(MethodHandles.lookup(), "croak",
            MethodType.fromMethodDescriptorString("(Ljava/lang/String;)V", null));
        // 動態調用
        return cs.dynamicInvoker();
    }
}

字節碼中,啓動方法由方法句柄來指定(MH_BootstrapMethod),該句柄指向一個返回類型爲調用點的靜態方法(BootstrapMethod)

  1. 在第一次執行invokedynamic時,JVM虛擬機會調用該指令所對應的啓動方法(BootstrapMethod)來生成調用點
  2. 啓動方法(BootstrapMethod)由方法句柄來指定(MH_BootstrapMethod)
  3. 啓動方法接受三個固定的參數,分別爲 Lookup實例、指代目標方法名的字符串及該調用點可以連接的方法句柄類型
  4. 將調用點綁定至該invokedynamic指令中,以後的運行中虛擬機會直接調用綁定的調用點所連接的方法句柄

Lambda表達式

Java8中的lambda表達式使用的即是invokedynamic指令

public class DynamicDispatch {
    public void croak(Supplier<String> name) {
        System.out.println(name.get() + "croak");
    }

    public static void main(String[] args) throws Throwable {
        new DynamicDispatch().croak(() -> "dog");
    }
}

查看字節碼

method_invoke_10

能夠看到,lambda表達式會被編譯爲invokedynamic指令,同時會生成一個私有靜態方法lambda$main$0,用以實現lambda表達式內部的邏輯

其實,除了會生成一個靜態方法以外,還會額外生成一個內部類,lambda啓動方法及調用點的詳細介紹請轉 Java8 - Lambda原理-到底是不是匿名類的語法糖


訂閱號

相關文章
相關標籤/搜索