深刻探究JVM之方法調用及Lambda表達式實現原理

@java

前言

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

正文

解析

Java中方法的調用對應字節碼有5條指令:app

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

invokedynamic與前4條指令不一樣的是,該指令分派的邏輯是由用戶指定,用於支持動態類型語言特性(相關概念後文會詳細描述)。
Java中有非虛方法虛方法,前者是指在解析階段能夠肯定的惟一的調用版本,如靜態方法、構造器方法、父類方法(特指在子類中使用super調用,而不是在客戶端使用對象引用調用)、私有方法(即便用invokestatic和invokespecial調用的方法)以及被final修飾的方法(使用invokevirtual調用),這些方法在類加載階段就會把方法的符號引用解析爲直接引用;除此以外的都是虛方法,虛方法則只能在運行期進行分派調用。ide

分派

分派分爲靜態動態,同時還會根據宗量數(能夠簡單理解爲影響方法選擇的因素,如方法的接收者參數)分爲靜態單分派靜態多分派動態單分派動態多分派性能

靜態分派

靜態分派就是指根據靜態類型(方法中定義的變量)來決定方法執行版本的分派動做,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);
	}
}

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

hello,guy!
hello,guy!

這裏全都是調用的參數爲Human類型的方法,緣由就是在main方法中定義的變量類型都是Human,這個就屬於靜態類型,而等於後面的對象則屬於實際類型,實際類型只能在運行期間獲取到,所以編譯器在編譯階段時只能根據靜態類型選取到對應的方法,因此這裏打印的都是"hello,guy!"。
不過不要想固然的認爲靜態類型就只會匹配到一個惟一的方法,若是有自動拆、裝箱,變長參數,向上轉型等參數,就能夠匹配到多個,不過它們是存在優先級關係的。spa

動態分派

Java裏面的動態分派與它的多態性息息相關,即方法重寫,以下面的代碼:code

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類,它能給咱們帶來哪些便利,熟悉並掌握可讓咱們寫出更靈活的程序。

相關文章
相關標籤/搜索