深入探究JVM之方法呼叫及Lambda表示式實現原理

夜勿語發表於2020-08-05

@

前言

在最開始講解JVM記憶體結構的時候有簡單分析過方法的執行原理——每一次方法呼叫都會生成一個棧幀並壓入棧中,方法鏈的執行就是一個個棧幀彈出棧的過程,本篇就從位元組碼層面詳細分析方法的呼叫細節。

正文

解析

Java中方法的呼叫對應位元組碼有5條指令:

  • invokestatic:用於呼叫靜態方法。
  • invokespecial:用於呼叫例項構造器<init>方法、私有方法和父類中的方法。
  • invokevirtual:用於呼叫所有的虛方法。
  • invokeinterface:用於呼叫介面方法,會在執行時再確定一個實現該介面的物件。
  • invokedynamic:先在執行時動態解析出呼叫點限定符所引用的方法,然後再執行該方法。

invokedynamic與前4條指令不同的是,該指令分派的邏輯是由使用者指定,用於支援動態型別語言特性(相關概念後文會詳細描述)。
Java中有非虛方法虛方法,前者是指在解析階段可以確定的唯一的呼叫版本,如靜態方法、構造器方法、父類方法(特指在子類中使用super呼叫,而不是在客戶端使用物件引用呼叫)、私有方法(即使用invokestatic和invokespecial呼叫的方法)以及被final修飾的方法(使用invokevirtual呼叫),這些方法在類載入階段就會把方法的符號引用解析為直接引用;除此之外的都是虛方法,虛方法則只能在執行期進行分派呼叫。

分派

分派分為靜態動態,同時還會根據宗量數(可以簡單理解為影響方法選擇的因素,如方法的接收者引數)分為靜態單分派靜態多分派動態單分派動態多分派

靜態分派

靜態分派就是指根據靜態型別(方法中定義的變數)來決定方法執行版本的分派動作,Java中典型的靜態分派就是方法過載。下面先來看段程式碼示例:

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) {
		StaticDispatch sr = new StaticDispatch();

		Human man = new Man();
		Human woman = new Woman();

		sr.sayHello(man);
		sr.sayHello(woman);
	}
}

下面的結果是否跟你想的是否一樣呢?

hello,guy!
hello,guy!

這裡全都是呼叫的引數為Human型別的方法,原因就是在main方法中定義的變數型別都是Human,這個就屬於靜態型別,而等於後面的物件則屬於實際型別,實際型別只能在執行期間獲取到,因此編譯器在編譯階段時只能根據靜態型別選取到對應的方法,所以這裡列印的都是"hello,guy!"。
不過不要想當然的認為靜態型別就只會匹配到一個唯一的方法,如果有自動拆、裝箱,變長引數,向上轉型等引數,就可以匹配到多個,不過它們是存在優先順序關係的。

動態分派

Java裡面的動態分派與它的多型性息息相關,即方法重寫,如下面的程式碼:

public class DynamicDispatch {

    static abstract class Virus{ //病毒
        protected abstract void ill();//生病
    }
    static class Cold extends Virus{
        @Override
        protected void ill() {
            System.out.println("感冒了,好不舒服!");
        }
    }
    static class CoronaVirus extends Virus{//冠狀病毒
        @Override
        protected void ill() {
            System.out.println("粘膜感染,空氣傳播,請帶好口罩!");
        }
    }
    public static void main(String[] args) {

        Virus clod=new Cold();
        clod.ill();
        clod = new CoronaVirus();
        clod.ill();
    }
}

這裡的輸出結果相信大家都清楚,但你是否深入考慮過它的呼叫細節呢?先來看看位元組碼:

  public static void main(java.lang.String[]);
    Code:
       0: new           #2                  // class ex8/DynamicDispatch$Cold
       3: dup
       4: invokespecial #3                  // Method ex8/DynamicDispatch$Cold."<init>":()V
       7: astore_1
       8: aload_1
       9: invokevirtual #4                  // Method ex8/DynamicDispatch$Virus.ill:()V
      12: new           #5                  // class ex8/DynamicDispatch$CoronaVirus
      15: dup
      16: invokespecial #6                  // Method ex8/DynamicDispatch$CoronaVirus."<init>":()V
      19: astore_1
      20: aload_1
      21: invokevirtual #4                  // Method ex8/DynamicDispatch$Virus.ill:()V
      24: return

可以看到呼叫方法時都是通過invokevirtual指令呼叫的,但註釋顯示兩次呼叫的常量池以及符號引用都是一樣的,那為什麼就會產生不同的結果呢?在《Java虛擬機器規範》中規定了invokevirtual的呼叫邏輯:

  • 找到運算元棧頂的第一個元素所指向的物件的實際型別,記作C。
  • 如果在型別C中找到與常量中的描述符和簡單名稱都相符的方法,則進行訪問許可權校驗,如果通過則返回這個方法的直接引用,查詢過程結束;不通過則返回java.lang.IllegalAccessError異常。
  • 否則,按照繼承關係從下往上依次對C的各個父類進行第二步的搜尋和驗證過程。
  • 如果始終沒有找到合適的方法,則丟擲java.lang.AbstractMethodError異常.

這裡面第一步就是在執行期間找到接收者的實際型別,在真正呼叫方法時就是根據這個型別進行呼叫的,所以會產生不同的結果。不過需要注意的是欄位不存在多型的概念,即invokevirtual指令對欄位是無效的,當子類宣告與父類同名的欄位時,就會掩蓋父類中的欄位,如下面的程式碼:

public class FieldHasNoPolymorphic {
	static class Father {
		public int money = 1;
	
		public Father() {
			money = 2;
			showMeTheMoney();
		}
	
		public void showMeTheMoney() {
			System.out.println("I am Father, i have $" + money);
		}
	}
	
	static class Son extends Father {
		public int money = 3;
	
		public Son() {
			money = 4;
			showMeTheMoney();
		}
	
		public void showMeTheMoney() {
			System.out.println("I am Son, i have $" + money);
		}
	}
	
	public static void main(String[] args) {
		Father gay = new Son();
		System.out.println("This gay has $" + gay.money);
	}
}

輸出結果如下:

I am Son, i have $0
I am Son, i have $4
This gay has $2

在建立Son物件時,首先會呼叫父類的構造器,而父類構造器又呼叫了showMeTheMoney方法,該方法會呼叫子類的版本,對應的拿到的欄位也是子類中的,而此時子類構造器還沒有執行,所以輸出的money是0,但最後根據gay的靜態型別輸出money是2,即沒有拿到執行中的實際型別,所以Java中欄位是不存在動態分派的。
這裡的解釋看似合情合理,但仍然有一個問題,呼叫子類構造器首先會呼叫父類構造器,也就是說這時候子類還沒有初始化完成,那為什麼父類就可以呼叫子類的例項方法呢?這時候可以反編譯main的位元組碼看看:

  public static void main(java.lang.String[]);
    Code:
       0: new           #2                  // class ex8/Test$Son
       3: dup
       4: invokespecial #3                  // Method ex8/Test$Son."<init>":()V
       7: astore_1
       8: getstatic     #4                  // Field java/lang/System.out:Ljava/io/PrintStream;
      11: new           #5                  // class java/lang/StringBuilder
      14: dup
      15: invokespecial #6                  // Method java/lang/StringBuilder."<init>":()V
      18: ldc           #7                  // String This gay has $
      20: invokevirtual #8                  // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
      23: aload_1
      24: getfield      #9                  // Field ex8/Test$Father.money:I
      27: invokevirtual #10                 // Method java/lang/StringBuilder.append:(I)Ljava/lang/StringBuilder;
      30: invokevirtual #11                 // Method java/lang/StringBuilder.toString:()Ljava/lang/String;
      33: invokevirtual #12                 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
      36: return
}

重點看到第一句,首先就是呼叫new位元組碼建立物件並將其引用值壓入棧頂,也就是說在呼叫構造方法之前物件在記憶體中已經分配好了,所以在父類構造器中可以呼叫子類的例項方法,這個其實在之前的物件建立章節已經講過了,現在就串在一起了。

單分派和多分派

Java是一門靜態單分派,動態單分派的語言,讀者如果充分理解了上文,這裡是非常好理解的。再來看一段程式碼:

public class Dispatch {
    static class QQ{}
    static class WX{}
    public static class Father{
        public void hardChoice(QQ arg){
            System.out.println("father choose qq");
        }
        public void hardChoice(WX arg){
            System.out.println("father choose weixin");
        }
    }
    public static class Son extends Father{
        public void hardChoice(QQ arg){
            System.out.println("son choose qq");
        }
        public void hardChoice(WX arg){
            System.out.println("son choose weixin");
        }
    }
    public static void main(String[] args) {
        Father father = new Father();
        Father son = new Son();
        father.hardChoice(new WX());
        son.hardChoice(new QQ());
    }
}

通過這段程式碼,我們可以看出,在編譯階段選取方法有兩個影響因素:一是需要看靜態型別是Father還是Son,二是方法引數。所以Java中靜態分派屬於靜態多分派。而在執行階段,呼叫的方法簽名是已經確定了的,即不管引數的實際型別是“騰訊QQ”還是“奇瑞QQ”,走的都是hardChoice(QQ arg)方法,唯一的影響就是該方法的實際接收者,所以Java中的動態分派屬於動態單分派

動態分派的實現

說了這麼多,虛擬機器到底是怎麼實現動態分派的呢?不可能在整個方法區去搜尋尋找,那樣效率是非常低的。實際上虛擬機器在方法區會為每個型別建立一個虛方法表(支援invokevirtual 指令)以及介面方法表(支援invokeinterface指令),如下圖:
在這裡插入圖片描述
方發表中存的是各個方法的實際入口地址,如果子類沒有重寫父類中的方法,那麼父子類指向同一個地址,否則,子類就會指向自己重寫後的方法入口地址。

Lambda表示式的實現原理

java8增加了對Lambda表示式的支援:

    public static void main(String[] args) {
        Runnable r = () -> System.out.println("Hello Lambda!");
        r.run();
    }

上面程式碼是Lambda表示式最簡單的運用,有沒有想過它的底層是怎麼實現的呢?直接用javap -v命令反編譯看看:

 public static void main(java.lang.String[]);
    descriptor: ([Ljava/lang/String;)V
    flags: ACC_PUBLIC, ACC_STATIC
    Code:
      stack=1, locals=2, args_size=1
         0: invokedynamic #2,  0              // InvokeDynamic #0:run:()Ljava/lang/Runnable;
         5: astore_1
         6: aload_1
         7: invokeinterface #3,  1            // InterfaceMethod java/lang/Runnable.run:()V
        12: return
}
SourceFile: "LambdaDemo.java"
InnerClasses:
     public static final #57= #56 of #60; //Lookup=class java/lang/invoke/MethodHandles$Lookup of class java/lang/invoke/MethodHandles
BootstrapMethods:
  0: #27 invokestatic java/lang/invoke/LambdaMetafactory.metafactory:(Ljava/lang/invoke/MethodHandles$Lookup;Ljava/lang/String;Ljava/lang/invoke/MethodType;Ljava/lang/invoke/MethodType;Ljava/lang/invoke/MethodHandle;Ljava/lang/invoke/MethodType;)Ljava/lang/invoke/CallSite;
    Method arguments:
      #28 ()V
      #29 invokestatic ex8/LambdaDemo.lambda$main$0:()V
      #28 ()V

我刪掉了不重要的部分,可以看到Lambda的呼叫時通過invokedynamic指令實現的,另外從位元組碼中我們可以看到會生成Bootstrap Method引導方法,該方法存在於BootstrapMethods屬性中,這個是JDK1.7新加入的。從這個屬性我們可以發現Lambda表示式的最終是通過MethodHandle方法控制程式碼來實現的,虛擬機器會執行引導方法並獲得返回的CallSite物件,通過這個物件最終呼叫到我們自己實現的方法上。
Lambda還分為捕獲和非捕獲,當從表示式外部獲取了非靜態的變數時,這個表示式就是捕獲的,反之就是非捕獲的,如下面兩個方法:第一個方法就是非捕獲的,第二個是捕獲的。

    public static void repeatMessage() {
        Runnable r = () -> {
            System.out.println("Hello Lambda!");
        };
    }
    
    public static void repeatMessage(String msg, int num) {
        Runnable r = () -> {
            for (int i = 0; i < num; i++) {
                System.out.println(msg);
            }
        };
    }

非捕獲的比捕獲的Lambda表示式效能更高,因為前者只需要計算一次,而後者每次都要重新計算,但無論如何,最差的情況下和內部類效能也是差不多的,所以儘量使用非捕獲的Lambda表示式。
關於Lambda的實現就講解到這,下面主要來看看MethodHandle的使用。

MethodHandle

var arrays = {"abc", new ObjectX(), 123, Dog, Cat, Car..}
for(item in arrays){
	item.sayHello();
}

上面這段程式碼在動態型別語言(型別檢查的主體過程是在執行期而不是編譯期進行)中是沒有什麼問題的,但是在Java中實現的話就會產生很多副作用,比如額外的效能開銷(陣列中每個型別都不一樣,就會導致方法內聯失去它本來的作用,還會帶來更大的負擔)。因此JDK1.7新加入invokedynamic指令和java.lang.invoke包,MethodHandle就存在於該包中,這個包的主要目的是在之前單純依靠符號引用來確定呼叫的目標方法這條路之外,提供一種新的動態確定目標方法的機制。下面來看看MehtodHandler的使用:

public class MethodHandleDemo {
    static class Bike {
        String sound() {
            return "Bike sound";
        }
    }

    static class Animal {
        String sound() {
            return "Animal sound";
        }
    }

    static class Man extends Animal {
        @Override
        String sound() {
            return "Man sound";
        }

        String listen() {
            return "listen";
        }
    }

    String invoke(Object o, String name) throws Throwable {
        //方法控制程式碼
        MethodHandles.Lookup lookup = MethodHandles.lookup();
        // MethodType:代表“方法型別”,包含了方法的返回值(methodType()的第一個引數)和具體引數(methodType()第二個及以後的引數)。
        MethodType methodType = MethodType.methodType(String.class);
        // 在指定類中查詢符合給定的方法名稱、方法型別,並且符合呼叫許可權的方法控制程式碼。
        MethodHandle methodHandle = lookup.findVirtual(o.getClass(), name, methodType);
        String obj = (String) methodHandle.invoke(o);
        return obj;
    }

    public static void main(String[] args) throws Throwable {
        String str = new MethodHandleDemo().invoke(new Bike(), "sound");
        System.out.println(str);
        str = new MethodHandleDemo().invoke(new Animal(), "sound");
        System.out.println(str);
        str = new MethodHandleDemo().invoke(new Man(), "sound");
        System.out.println(str);
        str = new MethodHandleDemo().invoke(new Man(), "listen");
        System.out.println(str);
    }
}

MethodType是用於指定方法的返回型別和引數,然後通過MethodHandles.Lookup模擬位元組碼的呼叫,因此對應的有findVirtualfindStaticfindSpecial等方法,這些方法就會返回一個MethodHandle的物件,最終通過這個物件的invoke或者invokeExact方法就能呼叫實際想要呼叫的物件方法(這裡需要注意的是前者是鬆散匹配,即可以自動轉型,而後者則必須是精確匹配,引數返回值型別都必須一樣,否則就會報錯)。
通過上面的程式碼我們知道,在執行中不論實際型別是什麼,只要有方法簽名以及返回值能對應上,就能呼叫成功,相當於動態的替換了符號引用中的靜態型別部分,也解決了動態語言對方法內聯等編譯優化的不良影響。
另外我們可以發現MethodHandle在功能和使用上都和反射差不多,但是使用更加簡單,也更輕量級,對應的效能也比反射要高。

總結

靜態分派和動態分派在Java中都是支援的,並且是靜態多分派,動態單分派;深刻理解分派的原理以及方法的分派規則,才能更好的理解程式的執行過程。另外為什麼會出現MethodHandle類,它能給我們帶來哪些便利,熟悉並掌握可以讓我們寫出更靈活的程式。

相關文章