天天看點

深入了解JVM虛拟機——JVM是怎麼實作invokedynamic的

作者:一個即将被退役的碼農

前不久,“虛拟機”賽馬俱樂部來了個年輕人,标榜自己是動态語言,是先進分子。

這一天,先進分子牽着一頭鹿進來,說要參加賽馬。咱部裡的老學究 Java 就不同意了呀,鹿又不是馬,哪能參加賽馬。

當然了,這種墨守成規的調用方式,自然是先進分子所不齒的。現在年輕人裡流行的是鴨子類型(duck typing)[1],隻要是跑起來像隻馬的,它就是一隻馬,也就能夠參加賽馬比賽。

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 虛拟機明确要求方法調用需要提供目标方法的類名。在這種體系下,我們有兩個解決方案。一是調用其中一種類型的賽跑方法,比如說馬類的賽跑方法。對于非馬的類型,則給它套一層馬甲,當成馬來賽跑。

另外一種解決方式,是通過反射機制,來查找并且調用各個類型中的賽跑方法,以此模拟真正的賽跑。

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

public static void startRace(java.lang.Object)
       0: aload_0                // 加載一個任意對象
       1: invokedynamic race     // 調用賽跑方法           

(理想的調用方式)

作為 invokedynamic 的準備工作,Java 7 引入了更加底層、更加靈活的方法抽象 :方法句柄(MethodHandle)。

方法句柄的概念

方法句柄是一個強類型的,能夠被直接執行的引用 [2]。該引用可以指向正常的靜态方法或者執行個體方法,也可以指向構造器或者字段。當指向字段時,方法句柄實則指向包含字段通路位元組碼的虛構方法,語義上等價于目标字段的 getter 或者 setter 方法。

這裡需要注意的是,它并不會直接指向目标字段所在類中的 getter/setter,畢竟你無法保證已有的 getter/setter 方法就是在通路目标字段。

方法句柄的類型(MethodType)是由所指向方法的參數類型以及傳回類型組成的。它是用來确認方法句柄是否适配的唯一關鍵。當使用方法句柄時,我們其實并不關心方法句柄所指向方法的類名或者方法名。

打個比方,如果兔子的“賽跑”方法和“睡覺”方法的參數類型以及傳回類型一緻,那麼對于兔子遞過來的一個方法句柄,我們并不知道會是哪一個方法。

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

當使用後者這種查找方式時,使用者需要區分具體的調用類型,比如說對于用 invokestatic 調用的靜态方法,我們需要使用 Lookup.findStatic 方法;對于用 invokevirutal 調用的執行個體方法,以及用 invokeinterface 調用的接口方法,我們需要使用 findVirtual 方法;對于用 invokespecial 調用的執行個體方法,我們則需要使用 findSpecial 方法。

調用方法句柄,和原本對應的調用指令是一緻的。也就是說,對于原本用 invokevirtual 調用的方法句柄,它也會采用動态綁定;而對于原本用 invkespecial 調用的方法句柄,它會采用靜态綁定。

class Foo {
  private static void bar(Object o) {
    ..
  }
  public static Lookup lookup() {
    return MethodHandles.lookup();
  }
}
 
// 擷取方法句柄的不同方式
MethodHandles.Lookup l = Foo.lookup(); // 具備 Foo 類的通路權限
Method m = Foo.class.getDeclaredMethod("bar", Object.class);
MethodHandle mh0 = l.unreflect(m);
 
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 方法。Java 8 中捕獲類型的 Lambda 表達式便是用這種操作來實作的,下一篇我會詳細進行解釋。

增操作還可以用來實作方法的柯裡化 [3]。舉個例子,有一個指向 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());
  }
}           

和查閱反射調用的方式一樣,我們可以通過建立異常執行個體來檢視棧軌迹。列印出來的占軌迹如下所示:

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

也就是說,invokeExact 的目标方法竟然就是方法句柄指向的方法。

先别高興太早。我剛剛提到過,invokeExact 會對參數的類型進行校驗,并在不比對的情況下抛出異常。如果它直接調用了方法句柄所指向的方法,那麼這部分參數類型校驗的邏輯将無處安放。是以,唯一的可能便是 Java 虛拟機隐藏了部分棧資訊。

當我們啟用了 -XX:+ShowHiddenFrames 這個參數來列印被 Java 虛拟機隐藏了的棧資訊時,你會發現 main 方法和目标方法中間隔着兩個貌似是生成的方法。

$ java -XX:+UnlockDiagnosticVMOptions -XX:+ShowHiddenFrames Foo
java.lang.Exception
        at Foo.bar(Foo.java:5)
        at java.base/java.lang.invoke.DirectMethodHandle$Holder. invokeStatic(DirectMethodHandle$Holder:1000010)
        at java.base/java.lang.invoke.LambdaForm$MH000/766572210. invokeExact_MT000_LLL_V(LambdaForm$MH000:1000019)
        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.checkExactType 方法來檢查參數類型,然後調用 Invokers.checkCustomized 方法。後者會在方法句柄的執行次數超過一個門檻值時進行優化(對應參數 -Djava.lang.invoke.MethodHandle.CUSTOMIZE_THRESHOLD,預設值為 127)。最後,它會調用方法句柄的 invokeBasic 方法。

Java 虛拟機同樣會對 invokeBasic 調用做特殊處理,這會将調用至方法句柄本身所持有的擴充卡中。這個擴充卡同樣是一個 LambdaForm,你可以通過反射機制将其列印出來。

// 該方法句柄持有的 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 參數所存儲的方法位址或者方法表索引,直接跳轉至目标方法。

那麼前面那個擴充卡中的優化又是怎麼回事?實際上,方法句柄一開始持有的擴充卡是共享的。當它被多次調用之後,Invokers.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           

可以看到,方法句柄的調用和反射調用一樣,都是間接調用。是以,它也會面臨無法内聯的問題。不過,與反射調用不同的是,方法句柄的内聯瓶頸在于即時編譯器能否将該方法句柄識别為常量。具體内容我會在下一篇中進行詳細的解釋。

invokedynamic 指令

invokedynamic 是 Java 7 引入的一條新指令,用以支援動态語言的方法調用。具體來說,它将**調用點(CallSite)**抽象成一個 Java 類,并且将原本由 Java 虛拟機控制的方法調用以及方法連結暴露給了應用程式。在運作過程中,每一條 invokedynamic 指令将捆綁一個調用點,并且會調用該調用點所連結的方法句柄。

在第一次執行 invokedynamic 指令時,Java 虛拟機會調用該指令所對應的啟動方法(BootStrap Method),來生成前面提到的調用點,并且将之綁定至該 invokedynamic 指令中。在之後的運作過程中,Java 虛拟機則會直接調用綁定的調用點所連結的方法句柄。

在位元組碼中,啟動方法是用方法句柄來指定的。這個方法句柄指向一個傳回類型為調用點的靜态方法。該方法必須接收三個固定的參數,分别為一個 Lookup 類執行個體,一個用來指代目标方法名字的字元串,以及該調用點能夠連結的方法句柄的類型。

除了這三個必需參數之外,啟動方法還可以接收若幹個其他的參數,用來輔助生成調用點,或者定位所要連結的目标方法。

import java.lang.invoke.*;
 
class Horse {
  public void race() {
    System.out.println("Horse.race()"); 
  }
}
 
class Deer {
  public void race() {
    System.out.println("Deer.race()");
  }
}
 
// javac Circuit.java
// java Circuit
public class Circuit {
 
  public static void startRace(Object obj) {
    // aload obj
    // invokedynamic race()
  }
 
  public static void main(String[] args) {
    startRace(new Horse());
    // startRace(new Deer());
  }
  
  public static CallSite bootstrap(MethodHandles.Lookup l, String name, MethodType callSiteType) throws Throwable {
    MethodHandle mh = l.findVirtual(Horse.class, name, MethodType.methodType(void.class));
    return new ConstantCallSite(mh.asType(callSiteType));
  }
}           

我在文稿中貼了一段代碼,其中便包含一個啟動方法。它将接收前面提到的三個固定參數,并且傳回一個連結至 Horse.race 方法的 ConstantCallSite。

這裡的 ConstantCallSite 是一種不可以更改連結對象的調用點。除此之外,Java 核心類庫還提供多種可以更改連結對象的調用點,比如 MutableCallSite 和 VolatileCallSite。

這兩者的差別就好比正常字段和 volatile 字段之間的差別。此外,應用程式還可以自定義調用點類,來滿足特定的重連結需求。

由于 Java 暫不支援直接生成 invokedynamic 指令 [1],是以接下來我會借助之前介紹過的位元組碼工具 ASM 來實作這一目的。

import java.io.IOException;
import java.lang.invoke.*;
import java.nio.file.*;
 
import org.objectweb.asm.*;
 
// javac -cp /path/to/asm-all-6.0_BETA.jar:. ASMHelper.java
// java -cp /path/to/asm-all-6.0_BETA.jar:. ASMHelper
// java Circuit
public class ASMHelper implements Opcodes {
 
  private static class MyMethodVisitor extends MethodVisitor {
 
    private static final String BOOTSTRAP_CLASS_NAME = Circuit.class.getName().replace('.', '/');
    private static final String BOOTSTRAP_METHOD_NAME = "bootstrap";
    private static final String BOOTSTRAP_METHOD_DESC = MethodType
        .methodType(CallSite.class, MethodHandles.Lookup.class, String.class, MethodType.class)
        .toMethodDescriptorString();
 
    private static final String TARGET_METHOD_NAME = "race";
    private static final String TARGET_METHOD_DESC = "(Ljava/lang/Object;)V";
 
    public final MethodVisitor mv;
 
    public MyMethodVisitor(int api, MethodVisitor mv) {
      super(api);
      this.mv = mv;
    }
 
    @Override
    public void visitCode() {
      mv.visitCode();
      mv.visitVarInsn(ALOAD, 0);
      Handle h = new Handle(H_INVOKESTATIC, BOOTSTRAP_CLASS_NAME, BOOTSTRAP_METHOD_NAME, BOOTSTRAP_METHOD_DESC, false);
      mv.visitInvokeDynamicInsn(TARGET_METHOD_NAME, TARGET_METHOD_DESC, h);
      mv.visitInsn(RETURN);
      mv.visitMaxs(1, 1);
      mv.visitEnd();
    }
  }
 
  public static void main(String[] args) throws IOException {
    ClassReader cr = new ClassReader("Circuit");
    ClassWriter cw = new ClassWriter(cr, ClassWriter.COMPUTE_FRAMES);
    ClassVisitor cv = new ClassVisitor(ASM6, cw) {
      @Override
      public MethodVisitor visitMethod(int access, String name, String descriptor, String signature,
          String[] exceptions) {
        MethodVisitor visitor = super.visitMethod(access, name, descriptor, signature, exceptions);
        if ("startRace".equals(name)) {
          return new MyMethodVisitor(ASM6, visitor);
        }
        return visitor;
      }
    };
    cr.accept(cv, ClassReader.SKIP_FRAMES);
 
    Files.write(Paths.get("Circuit.class"), cw.toByteArray());
  }
}           

你無需了解上面這段代碼的具體含義,隻須了解它會更改同一目錄下 Circuit 類的 startRace(Object) 方法,使之包含 invokedynamic 指令,執行所謂的賽跑方法。

public static void startRace(java.lang.Object);
         0: aload_0
         1: invokedynamic #80,  0 // race:(Ljava/lang/Object;)V
         6: return           

如果你足夠細心的話,你會發現該指令所調用的賽跑方法的描述符,和 Horse.race 方法或者 Deer.race 方法的描述符并不一緻。這是因為 invokedynamic 指令最終調用的是方法句柄,而方法句柄會将調用者當成第一個參數。是以,剛剛提到的那兩個方法恰恰符合這個描述符所對應的方法句柄類型。

到目前為止,我們已經可以通過 invokedynamic 調用 Horse.race 方法了。為了支援調用任意類的 race 方法,我實作了一個簡單的單态内聯緩存。如果調用者的類型命中緩存中的類型,便直接調用緩存中的方法句柄,否則便更新緩存。

// 需要更改 ASMHelper.MyMethodVisitor 中的 BOOTSTRAP_CLASS_NAME
import java.lang.invoke.*;
 
public class MonomorphicInlineCache {
 
  private final MethodHandles.Lookup lookup;
  private final String name;
 
  public MonomorphicInlineCache(MethodHandles.Lookup lookup, String name) {
    this.lookup = lookup;
    this.name = name;
  }
 
  private Class<?> cachedClass = null;
  private MethodHandle mh = null;
 
  public void invoke(Object receiver) throws Throwable {
    if (cachedClass != receiver.getClass()) {
      cachedClass = receiver.getClass();
      mh = lookup.findVirtual(cachedClass, name, MethodType.methodType(void.class));
    }
    mh.invoke(receiver);
  }
 
  public static CallSite bootstrap(MethodHandles.Lookup l, String name, MethodType callSiteType) throws Throwable {
    MonomorphicInlineCache ic = new MonomorphicInlineCache(l, name);
    MethodHandle mh = l.findVirtual(MonomorphicInlineCache.class, "invoke", MethodType.methodType(void.class, Object.class));
    return new ConstantCallSite(mh.bindTo(ic));
  }
}           

可以看到,盡管 invokedynamic 指令調用的是所謂的 race 方法,但是實際上我傳回了一個連結至名為“invoke”的方法的調用點。由于調用點僅要求方法句柄的類型能夠比對,是以這個連結是合法的。

不過,這正是 invokedynamic 的目的,也就是将調用點與目标方法的連結交由應用程式來做,并且依賴于應用程式對目标方法進行驗證。是以,如果應用程式将賽跑方法連結至兔子的睡覺方法,那也隻能怪應用程式自己了。

Java 8 的 Lambda 表達式

在 Java 8 中,Lambda 表達式也是借助 invokedynamic 來實作的。

具體來說,Java 編譯器利用 invokedynamic 指令來生成實作了函數式接口的擴充卡。這裡的函數式接口指的是僅包括一個非 default 接口方法的接口,一般通過 @FunctionalInterface 注解。不過就算是沒有使用該注解,Java 編譯器也會将符合條件的接口辨認為函數式接口。

int x = ..
IntStream.of(1, 2, 3).map(i -> i * 2).map(i -> i * x);           

舉個例子,上面這段代碼會對 IntStream 中的元素進行兩次映射。我們知道,映射方法 map 所接收的參數是 IntUnaryOperator(這是一個函數式接口)。也就是說,在運作過程中我們需要将 i -> i * 2 和 i -> i * x 這兩個 Lambda 表達式轉化成 IntUnaryOperator 的執行個體。這個轉化過程便是由 invokedynamic 來實作的。

在編譯過程中,Java 編譯器會對 Lambda 表達式進行解文法糖(desugar),生成一個方法來儲存 Lambda 表達式的内容。該方法的參數清單不僅包含原本 Lambda 表達式的參數,還包含它所捕獲的變量。(注:方法引用,如 Horse::race,則不會生成生成額外的方法。)

在上面那個例子中,第一個 Lambda 表達式沒有捕獲其他變量,而第二個 Lambda 表達式(也就是 i->i*x)則會捕獲局部變量 x。這兩個 Lambda 表達式對應的方法如下所示。可以看到,所捕獲的變量同樣也會作為參數傳入生成的方法之中。

// i -> i * 2
  private static int lambda$0(int);
    Code:
       0: iload_0
       1: iconst_2
       2: imul
       3: ireturn
 
  // i -> i * x
  private static int lambda$1(int, int);
    Code:
       0: iload_1
       1: iload_0
       2: imul
       3: ireturn           

第一次執行 invokedynamic 指令時,它所對應的啟動方法會通過 ASM 來生成一個擴充卡類。這個擴充卡類實作了對應的函數式接口,在我們的例子中,也就是 IntUnaryOperator。啟動方法的傳回值是一個 ConstantCallSite,其連結對象為一個傳回擴充卡類執行個體的方法句柄。

根據 Lambda 表達式是否捕獲其他變量,啟動方法生成的擴充卡類以及所連結的方法句柄皆不同。

如果該 Lambda 表達式沒有捕獲其他變量,那麼可以認為它是上下文無關的。是以,啟動方法将建立一個擴充卡類的執行個體,并且生成一個特殊的方法句柄,始終傳回該執行個體。

如果該 Lambda 表達式捕獲了其他變量,那麼每次執行該 invokedynamic 指令,我們都要更新這些捕獲了的變量,以防止它們發生了變化。

另外,為了保證 Lambda 表達式的線程安全,我們無法共享同一個擴充卡類的執行個體。是以,在每次執行 invokedynamic 指令時,所調用的方法句柄都需要建立一個擴充卡類執行個體。

在這種情況下,啟動方法生成的擴充卡類将包含一個額外的靜态方法,來構造擴充卡類的執行個體。該方法将接收這些捕獲的參數,并且将它們儲存為擴充卡類執行個體的執行個體字段。

你可以通過虛拟機參數 -Djdk.internal.lambda.dumpProxyClasses=/DUMP/PATH 導出這些具體的擴充卡類。這裡我導出了上面這個例子中兩個 Lambda 表達式對應的擴充卡類。

// i->i*2 對應的擴充卡類
final class LambdaTest$Lambda$1 implements IntUnaryOperator {
 private LambdaTest$Lambda$1();
  Code:
    0: aload_0
    1: invokespecial java/lang/Object."<init>":()V
    4: return
 
 public int applyAsInt(int);
  Code:
    0: iload_1
    1: invokestatic LambdaTest.lambda$0:(I)I
    4: ireturn
}
 
// i->i*x 對應的擴充卡類
final class LambdaTest$Lambda$2 implements IntUnaryOperator {
 private final int arg$1;
 
 private LambdaTest$Lambda$2(int);
  Code:
    0: aload_0
    1: invokespecial java/lang/Object."<init>":()V
    4: aload_0
    5: iload_1
    6: putfield arg$1:I
    9: return
 
 private static java.util.function.IntUnaryOperator get$Lambda(int);
  Code:
    0: new LambdaTest$Lambda$2
    3: dup
    4: iload_0
    5: invokespecial "<init>":(I)V
    8: areturn
 
 public int applyAsInt(int);
  Code:
    0: aload_0
    1: getfield arg$1:I
    4: iload_1
    5: invokestatic LambdaTest.lambda$1:(II)I
    8: ireturn
}           

可以看到,捕獲了局部變量的 Lambda 表達式多出了一個 get$Lambda 的方法。啟動方法便會所傳回的調用點連結至指向該方法的方法句柄。也就是說,每次執行 invokedynamic 指令時,都會調用至這個方法中,并構造一個新的擴充卡類執行個體。

這個多出來的建立執行個體會對程式性能造成影響嗎?

Lambda 以及方法句柄的性能分析

我再次請出測試反射調用性能開銷的那段代碼,并将其改造成使用 Lambda 表達式的 v6 版本。

// v6 版本
import java.util.function.IntConsumer;
 
public class Test {
  public static void target(int i) { }
 
  public static void main(String[] args) throws Exception {
    long current = System.currentTimeMillis();
    for (int i = 1; i <= 2_000_000_000; i++) {
      if (i % 100_000_000 == 0) {
        long temp = System.currentTimeMillis();
        System.out.println(temp - current);
        current = temp;
      }
 
      ((IntConsumer) j -> Test.target(j)).accept(128);
      // ((IntConsumer) Test::target.accept(128);
    }
  }
}           

測量結果顯示,它與直接調用的性能并無太大的差別。也就是說,即時編譯器能夠将轉換 Lambda 表達式所使用的 invokedynamic,以及對 IntConsumer.accept 方法的調用統統内聯進來,最終優化為空操作。

這個其實不難了解:Lambda 表達式所使用的 invokedynamic 将綁定一個 ConstantCallSite,其連結的目标方法無法改變。是以,即時編譯器會将該目标方法直接内聯進來。對于這類沒有捕獲變量的 Lambda 表達式而言,目标方法隻完成了一個動作,便是加載緩存的擴充卡類常量。

另一方面,對 IntConsumer.accept 方法的調用實則是對擴充卡類的 accept 方法的調用。

如果你檢視了 accept 方法對應的位元組碼的話,你會發現它僅包含一個方法調用,調用至 Java 編譯器在解 Lambda 文法糖時生成的方法。

該方法的内容便是 Lambda 表達式的内容,也就是直接調用目标方法 Test.target。将這幾個方法調用内聯進來之後,原本對 accept 方法的調用則會被優化為空操作。

下面我将之前的代碼更改為帶捕獲變量的 v7 版本。理論上,每次調用 invokedynamic 指令,Java 虛拟機都會建立一個擴充卡類的執行個體。然而,實際運作結果還是與直接調用的性能一緻。

// v7 版本
import java.util.function.IntConsumer;
 
public class Test {
  public static void target(int i) { }
 
  public static void main(String[] args) throws Exception {
    int x = 2;
 
    long current = System.currentTimeMillis();
    for (int i = 1; i <= 2_000_000_000; i++) {
      if (i % 100_000_000 == 0) {
        long temp = System.currentTimeMillis();
        System.out.println(temp - current);
        current = temp;
      }
 
      ((IntConsumer) j -> Test.target(x + j)).accept(128);
    }
  }
}           

顯然,即時編譯器的逃逸分析又将該建立執行個體給優化掉了。我們可以通過虛拟機參數 -XX:-DoEscapeAnalysis 來關閉逃逸分析。果然,這時候測得的值約為直接調用的 2.5 倍。

盡管逃逸分析 能夠去除這些額外的建立執行個體開銷,但是它也不是時時奏效。它需要同時滿足兩件事:invokedynamic 指令所執行的方法句柄能夠内聯,和接下來的對 accept 方法的調用也能内聯。

隻有這樣,逃逸分析才能判定該擴充卡執行個體不逃逸。否則,我們會在運作過程中不停地生成擴充卡類執行個體。是以,我們應當盡量使用非捕獲的 Lambda 表達式。

總結與實踐

invokedynamic 底層機制的基石:方法句柄。

方法句柄是一個強類型的、能夠被直接執行的引用。它僅關心所指向方法的參數類型以及傳回類型,而不關心方法所在的類以及方法名。方法句柄的權限檢查發生在建立過程中,相較于反射調用節省了調用時反複權限檢查的開銷。

方法句柄可以通過 invokeExact 以及 invoke 來調用。其中,invokeExact 要求傳入的參數和所指向方法的描述符嚴格比對。方法句柄還支援增删改參數的操作,這些操作是通過生成另一個充當擴充卡的方法句柄來實作的。

invokedynamic 指令以及 Lambda 表達式的實作。

invokedymaic 指令抽象出調用點的概念,并且将調用該調用點所連結的方法句柄。在第一次執行 invokedynamic 指令時,Java 虛拟機将執行它所對應的啟動方法,生成并且綁定一個調用點。之後如果再次執行該指令,Java 虛拟機則直接調用已經綁定了的調用點所連結的方法。

Lambda 表達式到函數式接口的轉換是通過 invokedynamic 指令來實作的。該 invokedynamic 指令對應的啟動方法将通過 ASM 生成一個擴充卡類。

對于沒有捕獲其他變量的 Lambda 表達式,該 invokedynamic 指令始終傳回同一個擴充卡類的執行個體。對于捕獲了其他變量的 Lambda 表達式,每次執行 invokedynamic 指令将建立一個擴充卡類執行個體。

不管是捕獲型的還是未捕獲型的 Lambda 表達式,它們的性能上限皆可以達到直接調用的性能。其中,捕獲型 Lambda 表達式借助了即時編譯器中的逃逸分析,來避免實際的建立擴充卡類執行個體的操作。

繼續閱讀