1. 程式人生 > >讀鄭雨迪《深入拆解Java虛擬機器》 -- 第八講 JVM是如何實現invokedynamic的

讀鄭雨迪《深入拆解Java虛擬機器》 -- 第八講 JVM是如何實現invokedynamic的

以前,我們賽馬 只能由馬參加,但是對於一些年輕人裡流行的鴨子型別(duck typing),只要跑起來像馬的,它就是一隻馬,也可以參加賽馬比賽。

class Horse {
    public void race() {
        System.out.println("Horse.race()");
    }
}

class Deer {
    public void race() {
        System.out.println("Deer.race()");
    }
}

class Cobra {
    public void race() {
        System.out.println("How do you turn this on? ");
    }
}

(如何用同一種方式呼叫他們的賽跑方法?)

說到了這裡,如果我們將賽跑定義為對賽跑方法(對應上述程式碼中的race())的呼叫的話,那麼這個故事的關鍵,就在於能不能在馬場中呼叫非馬型別的賽跑方法。

為了解答這個問題,我們先來看一下Java裡的方法呼叫。在Java中,方法呼叫被編譯為invokestatic、invokespecial、invokevirtual以及invokeinterface四種指令。這些指令與包含目標方法類名、方法名以及方法描述符的符號引用捆綁。在實際執行之前,Java虛擬機器將根據這個符號引用連結到具體的目標方法。

可以看到,在四種呼叫指令中,Java虛擬機器明確要求呼叫需要提供目標方法的類名。在這種體系下,我們有兩個解決方案。

  • 呼叫其中一種型別的賽跑方法,比如說馬類的賽跑方法。對於非馬型別,則給它一套馬甲,當成馬來賽跑。
  • 通過反射機制,來查詢並且呼叫各個型別中的賽跑方法,以此模擬真正的賽跑。

顯然,比起直接呼叫,這兩種方法都相當複雜,執行效率也可想而知。為了解決這個問題,Java7引入了一條新的指令invokedynamic該指令的呼叫機制抽象出調用點這一個概念,並允許應用程式將呼叫點連線至任何符合條件的方法上

public static void startRace(java.lang.Object)
    0: aload_0               // 載入一個任意物件
    1: invokedynamic race    // 呼叫賽跑方法

理想的呼叫方式

作為invokedynamic的準備工作,Java7引入了更加底層、更加靈活的方法抽象:方法控制代碼(MethodHandle)

方法控制代碼的概念

方法控制代碼是一個強型別的,能夠被直接執行的引用。該引用可以指向常規的靜態方法或者例項方法,也可以指向構造器或者欄位。當指向欄位時,方法控制代碼實則指向包含欄位訪問位元組碼的虛構方法,語義上等價於目標欄位的getter或者setter方法。

這裡需要注意的是,它並不會直接指向目標欄位所在類的getter/setter,畢竟無法保證已有的getter/setter方法就是在訪問目標欄位。

方法控制代碼的型別(MethodType)是由所指向方法的引數型別以及返回型別組成的。它是用來確定方法控制代碼是否適配的唯一關鍵。當使用方法控制代碼時,我們其實並不關心方法控制代碼所指向方法的類名或者方法名。

方法控制代碼的建立是通過 MethodHandles.Lookup 類來完成的。它提供了多個API,既可以使用反射API中的Method來查詢,也可以根據類、方法名以及方法控制代碼來查詢。

當使用後者這種查詢方式時,使用者需要區分具體的呼叫型別,比如說對於用invokestatic呼叫的靜態方法,我們需要使用 Lookup.findStatic 方法;對於用invokevirtual呼叫的例項方法,以及用invokeinterface呼叫的介面方法,我們需要使用findVirtual方法;對於用invokespecial呼叫的例項方法,我們則需要使用findSpecial方法。

呼叫方法控制代碼,和原本對應的呼叫指令是一致的。也就是說,對於原本用invokevirtual呼叫的方法控制代碼,它也會採用動態繫結;而對於原本用invokespecial 呼叫的方法控制代碼,它會採用靜態繫結。

class Foo {
    private static void bar(Object o) {
        ...
    }
    public static Lookup lookup() {
        return MethodHandles.lookup();
    }
}

//獲取方法控制代碼的不同方式
//1 通過反射
Method,Lookup l = Foo.looup(); //具備 Foo 類的訪問許可權
Method m = Foo.class.getDeclaredMethod("bar", Object.class);
MethodHandle mh0 = l.unreflect(m);

//2 通過findstatic
MethodType t = MethodType.methodType(void.class, Object.class);
MethodHandle mh1 = l.findStatic(Foo.class, "bar", t);

方法控制代碼同樣也有許可權問題。但它與反射API不同,其許可權檢查是在控制代碼的建立階段完成的。在實際呼叫過程中,Java虛擬機器並不會檢查方法控制代碼的許可權。如果該控制代碼被多次呼叫的話,那麼與反射呼叫相比,它將省下重複許可權檢查的開銷。

需要注意的是,方法控制代碼的訪問許可權不取決於方法控制代碼的建立位置,而是取決於 Lookup 物件的建立位置。

舉個例子, 對於一個私有欄位,如果Lookup物件是在私有欄位所在類中獲取的,那麼這個Lookup物件便擁有對該私有欄位的訪問許可權,即使是在所在類的外邊,也能夠通過該 Lookup 物件建立該私有欄位的 getter 或者 setter。

由於方法控制代碼沒有執行時許可權檢查, 因此,應用程式需要負責方法控制代碼的管理。 一旦它釋出了某些指向私有方法的方法控制代碼,那麼這些私有方法便被暴露出去了。

方法控制代碼的操作

方法控制代碼的呼叫可分為兩種

  • 需要嚴格匹配引數型別的invokeExact。假設一個方法控制代碼將接受一個Object 型別的引數, 如果你直接傳入String 作為實際引數,那麼方法控制代碼的呼叫會在執行時丟擲方法型別不匹配的異常。正確的呼叫方式是將該String 顯式轉化為 Object 型別。

在普通Java方法呼叫中, 我們只有在選擇過載方法時, 才會用到這種顯式轉化。這是因為經過顯式轉化後,引數的聲明發生了改變,因此有可能匹配到不同的方法描述符,從而選取不同的目標方法。呼叫方法控制代碼也是利用同樣的原理,並且涉及了一個簽名多樣性(signature polymorphism) 的概念。(在這裡我們暫且認為簽名等同於方法描述符。)

public final native @PolymorphicSignature Object invokeExact(Object... args) throws Throwable;

方法控制代碼API有一個特殊的註解類

@PolymorphicSignature。在碰到被它註解的方法呼叫時,Java編譯器會根據所傳入引數的宣告型別來生成方法描述符,而不是採用目標方法所宣告的描述符。

在剛才的例子中,當傳入的引數是String時,對應的方法描述符包含String類;而當我們轉化為Object時,對應的方法描述符則包含Object類。

public void test(MethodHandle mh, String s) throws Throwable {
    mh.invokeExact(s);
    mh.invokeExact((Object) s);
}

//對應的 Java 位元組碼
public void test(MethodHandle, String) throws java.lang.Throwable;
    Code:
        0: aload_1
        1: aload_2
        2: invokevirtual MethodHandle.invokeExact:(Ljava/lang/String;)V
        5: aload_1
        6: aload_2
        7: invokevirtual MethodHandle.invokeExact:(Ljava/lang/Object;)V
       10: return

invokeExact 會確認該invokevirtual 指令對應的方法描述符,和該方法控制代碼的型別是否嚴格匹配。在不匹配的情況下, 便會在執行時丟擲異常。

  • 如果需要自動適配引數型別,那麼可以選取方法控制代碼的第二種呼叫方式invoke。它同樣是一個簽名多型性的方法。invoke會呼叫MethodHandle.asType 方法,生成一個介面卡方法控制代碼,對傳入的引數進行適配,在呼叫原方法控制代碼,對傳入的引數進行適配,再呼叫原原方法控制代碼。呼叫原方法控制代碼的返回值同樣也會先進行適配,然後再返回給呼叫者。

方法控制代碼還支援增刪改引數的操作,這些操作都是通過生成另一個方法控制代碼來實現的。這其中,改操作就是剛剛介紹的 MethodHandle.asType 方法。刪操作指的是將傳入的部分引數就地拋棄,再呼叫另一個方法控制代碼。它對應的API是MethodHandles.dropArguments 方法。

增操作則會往傳入的引數中插入額外的引數,再呼叫另一個方法控制代碼,它對應的API是MethodHandle.bindTo 方法。Java8中捕獲型別的 Lambda 表示式便是用這種操作來實現的。

增操作還可以用來實現方法的柯里化。舉個例子,有一個指向f(x, y)的方法控制代碼, 我們可以將 x 繫結為 4,生成另一個方法控制代碼 g(y) = f(4, y)。 在執行過程中, 每當呼叫g(y) 的方法控制代碼, 它會在引數列宗最前面參入一個4,在呼叫指向f(x, y) 的方法控制代碼。

方法控制代碼的實現

下面我們來看看 HotSpot 虛擬機器中方法控制代碼呼叫的具體實現。(由於篇幅原因, 這裡只討論DirectMethodHandle。)

前面提到,呼叫方法控制代碼所使用的 invokeExact 或者 invoke 方法具備簽名多型性的特性。它們會根據具體的傳入引數來生成方法描述符。那麼,擁有這個描述符的方法實際存在嗎?對 invokeExact 或者 invoke 的呼叫具體會進入哪個方法呢?

import java.lang.invoke.*;

public class Foo {
    public static void bar(Object o) {
        new Exception().printStackTrace();
    }

    public static void main(String[] args) throws Throwable{
        MethodHandles.Lookup l = MethodHandles.lookup();
        MethodType t = MethodType.methodType(void.class, Object.class);
        MethodHandle mh = l.findStatic(Foo.class, "bar", t);
        mh.invokeExact(new Object());
    }
}

和查閱反射呼叫的方式一樣,我們可以通過新建異常例項來檢視棧軌跡。打印出來的佔軌跡如下所示:

javac Foo.java
java Foo
java.lang.Exception
	at Foo.bar(Foo.java:5)
	at Foo.main(Foo.java:12)

也就是說, invokeExact 的目標方法竟然就是方法控制代碼指向的方法。

前面說到,invokeExact會對引數的型別進行校驗, 並在不匹配的情況下丟擲異常。如果它直接呼叫了方法控制代碼所指向的方法,那麼這部分引數型別校驗的邏輯將無處安放。因此,唯一的可能便是Java虛擬機器隱藏了部分棧資訊。

當我們啟用了 -XX:+ShowHiddenFrames 這個引數來列印被Java虛擬機器隱藏了的棧資訊時,我們就會發現mian方法和目標方法中隔著兩個貌似是生成的方法。

java -XX:+UnlockDiagnosticVMOptions -XX:+ShowHiddenFrames Foo
java.lang.Exception
	at Foo.bar(Foo.java:5)
	at java.lang.invoke.LambdaForm$DMH/1173230247.invokeStatic_L_V(LambdaForm$DMH:1000010)
	at java.lang.invoke.LambdaForm$MH/1414644648.invokeExact_MT(LambdaForm$MH:1000016)
	at Foo.main(Foo.java:12)

實際上,Java虛擬機器會對invokeExact呼叫做特殊處理,呼叫至一個共享的、與方法控制代碼型別相關的特殊介面卡中。這個介面卡是一個LambdaForm,我們可以通過新增虛擬機器引數將之到處成class檔案 (-Djava.lang.invoke.MethodHandle.DUMP_CLASS_FILES=true)。

final class java.lang.invoke.LambdaForm$MH000 {  static void invokeExact_MT000_LLLLV(jeava.lang.bject, jjava.lang.bject, jjava.lang.bject);
    Code:
        : aload_0
      1 : checkcast      #14                 //Mclass java/lang/invoke/ethodHandle
        : dup
      5 : astore_0
        : aload_32        : checkcast      #16                 //Mclass java/lang/invoke/ethodType
      10: invokestatic  I#22                 // Method java/lang/invoke/nvokers.checkExactType:(MLjava/lang/invoke/ethodHandle,;Ljava/lang/invoke/ethodType);V
      13: aload_0
      14: invokestatic   #26     I           // Method java/lang/invoke/nvokers.checkCustomized:(MLjava/lang/invoke/ethodHandle);V
      17: aload_0
      18: aload_1
      19: ainvakevirtudl #30             2   // Methodijava/lang/nvokev/ethodHandle.invokeBasic:(LLeava/lang/bject;;V
       23 return

可以看到,在這個介面卡中,它會呼叫Invokers.checkType 方法來檢查引數型別, 然後呼叫 Invokers.checkCustomized 方法。後者會在方法控制代碼的執行次數超過一個閾值(對應引數 -Djava.lang.invoke.MethodHandle.CUSTOMIZE_THRESHOLD,預設值是127)。最後,它會呼叫方法控制代碼的invokeBasic 的方法、

Java虛擬機器同樣會對invokeBasic 呼叫做特殊處理,這會將呼叫至方法控制代碼本身所持有的介面卡中。這個介面卡同樣是一個LamdaForm,你可以通過反射機制將其打印出來。

// 該方法控制代碼持有的 LambdaForm 例項的 toString() 結果
DMH.invokeStatic_L_V = Lambda(a0:L, a1:L) => {
    t2:L=DirectMethodHandle.internalMemberName(a0:L);
    t3:V=MethodHandle.linkToStatic(a1:L, t2:L);void}

這個介面卡將獲取方法控制代碼中的 MemberName 型別的欄位, 並且以它為引數呼叫 linkToStatic 方法。Java 虛擬機器也會對 linkToStatic 呼叫做特殊處理, 它將根據傳入的MemberName 引數所儲存的方法地址或者方法表索引,直接跳轉至目標方法。

final class MemberName implements Member, Cloneable {
...
    // @Injected JVM_Method* vmtarget;
    // @Injected int         vmindex;
...
}

那麼前面那個介面卡中的優化又是怎麼回事?實際上,方法控制代碼一開始持有的介面卡是共享的。當它被多次呼叫之後。Invoker.checkCustomized 方法會為該方法控制代碼生成一個特有的介面卡。這個特有的介面卡會將方法控制代碼作為常量,直接獲取其MemberName 型別的欄位,並繼續後面的linkToStatic 呼叫。

final class java.lang.invoke.LambdaForm$DMH000 {
  static void invokeStatic000_LL_V(java.lang.Object, java.lang.Object);
    Code:
       0: ldc           #14                 // String CONSTANT_PLACEHOLDER_1 <<Foo.bar(Object)void/invokeStatic>>
       2: checkcast     #16                 // class java/lang/invoke/MethodHandle
       5: astore_0     // 上面的優化程式碼覆蓋了傳入的方法控制代碼
       6: aload_0      // 從這裡開始跟初始版本一致
       7: invokestatic  #22                 // Method java/lang/invoke/DirectMethodHandle.internalMemberName:(Ljava/lang/Object;)Ljava/lang/Object;
      10: astore_2
      11: aload_1
      12: aload_2
      13: checkcast     #24                 // class java/lang/invoke/MemberName
      16: invokestatic  #28                 // Method java/lang/invoke/MethodHandle.linkToStatic:(Ljava/lang/Object;Ljava/lang/invoke/MemberName;)V
      19: return

可以看到,方法控制代碼的呼叫和反射呼叫一樣,都是間接呼叫。因此,它也會面臨無法內聯的問題。不過,與反射呼叫不同的是,方法控制代碼的內聯瓶頸在於即時編譯器能否將該方法控制代碼識別為常量。

我們來測量一下方法控制代碼的效能。可以通過重構程式碼,將方法控制代碼程式設計常量,來提升方法控制代碼呼叫的效能。

import java.lang.invoke.*;

public class Foo {
	public void bar(Object o) {
		
	}

	public static void main(String[] args) throws Throwable {
		MethodHandles.Lookup l = MethodHandles.lookup();
		MethodType t = MethodType.methodType(void.class, Object.class);
		MethodHandle mh = l.findVirtual(Foo.class, "bar", t);

		long current = System.currentTimeMillis();
		for(int i = 1; i < 2000000000; i++) {
			if(i % 100000000 == 0) {
				long temp = System.currentTimeMillis();
				System.out.println(temp - current);
				current = temp;
			}
			mh.invokeExact(new Foo(), new Object());
		}
	}

}

得到輸出:

1917
1411
1529
1609
1436
1810
1600
1631
1482
1549
1610
1543
1533
1574
1527
1509
1709
1563
1557

關於將方法控制代碼變成常量來進行優化,我還沒有思路,有思路的童鞋可以在討論區指導一下,謝謝。

此文從極客時間專欄《深入理解Java虛擬機器》搬運而來,撰寫此文的目的:

  1. 對自己的學習總結歸納

  2. 此篇文章對想深入理解Java虛擬機器的人來說是非常不錯的文章,希望大家支援一下鄭老師。