天天看點

深入了解JVM虛拟機——JVM是如何實作反射的?

作者:天之藍3385

反射是 Java 語言中一個相當重要的特性,它允許正在運作的 Java 程式觀測,甚至是修改程式的動态行為。

舉例來說,我們可以通過 Class 對象枚舉該類中的所有方法,我們還可以通過 Method.setAccessible(位于 java.lang.reflect 包,該方法繼承自 AccessibleObject)繞過 Java 語言的通路權限,在私有方法所在類之外的地方調用該方法。

反射在 Java 中的應用十分廣泛。開發人員日常接觸到的 Java 內建開發環境(IDE)便運用了這一功能:每當我們敲入點号時,IDE 便會根據點号前的内容,動态展示可以通路的字段或者方法。

另一個日常應用則是 Java 調試器,它能夠在調試過程中枚舉某一對象所有字段的值。

深入了解JVM虛拟機——JVM是如何實作反射的?

(圖中 eclipse 的自動提示使用了反射)

在 Web 開發中,我們經常能夠接觸到各種可配置的通用架構。為了保證架構的可擴充性,它們往往借助 Java 的反射機制,根據配置檔案來加載不同的類。舉例來說,Spring 架構的依賴反轉(IoC),便是依賴于反射機制。

然而,我相信不少開發人員都嫌棄反射機制比較慢。甚至是甲骨文關于反射的教學網頁 [1],也強調了反射性能開銷大的缺點。

今天我們便來了解一下反射的實作機制,以及它性能糟糕的原因。如果你對反射 API 不是特别熟悉的話,你可以查閱我放在文稿末尾的附錄。

反射調用的實作

首先,我們來看看方法的反射調用,也就是 Method.invoke,是怎麼實作的。

public final class Method extends Executable {
  ...
  public Object invoke(Object obj, Object... args) throws ... {
    ... // 權限檢查
    MethodAccessor ma = methodAccessor;
    if (ma == null) {
      ma = acquireMethodAccessor();
    }
    return ma.invoke(obj, args);
  }
}           

如果你查閱 Method.invoke 的源代碼,那麼你會發現,它實際上委派給 MethodAccessor 來處理。MethodAccessor 是一個接口,它有兩個已有的具體實作:一個通過本地方法來實作反射調用,另一個則使用了委派模式。為了友善記憶,我便用“本地實作”和“委派實作”來指代這兩者。

每個 Method 執行個體的第一次反射調用都會生成一個委派實作,它所委派的具體實作便是一個本地實作。本地實作非常容易了解。當進入了 Java 虛拟機内部之後,我們便擁有了 Method 執行個體所指向方法的具體位址。這時候,反射調用無非就是将傳入的參數準備好,然後調用進入目标方法。

// v0 版本
import java.lang.reflect.Method;
 
public class Test {
  public static void target(int i) {
    new Exception("#" + i).printStackTrace();
  }
 
  public static void main(String[] args) throws Exception {
    Class<?> klass = Class.forName("Test");
    Method method = klass.getMethod("target", int.class);
    method.invoke(null, 0);
  }
}
 
# 不同版本的輸出略有不同,這裡我使用了 Java 10。
$ java Test
java.lang.Exception: #0
        at Test.target(Test.java:5)
        at java.base/jdk.internal.reflect.NativeMethodAccessorImpl .invoke0(Native Method)
 		at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62)
 		at java.base/jdk.internal.reflect.DelegatingMethodAccessorImpl.i.invoke(DelegatingMethodAccessorImpl.java:43)
        at java.base/java.lang.reflect.Method.invoke(Method.java:564)
  		at Test.main(Test.java:131           

為了友善了解,我們可以列印一下反射調用到目标方法時的棧軌迹。在上面的 v0 版本代碼中,我們擷取了一個指向 Test.target 方法的 Method 對象,并且用它來進行反射調用。在 Test.target 中,我會列印出棧軌迹。

可以看到,反射調用先是調用了 Method.invoke,然後進入委派實作(DelegatingMethodAccessorImpl),再然後進入本地實作(NativeMethodAccessorImpl),最後到達目标方法。

這裡你可能會疑問,為什麼反射調用還要采取委派實作作為中間層?直接交給本地實作不可以麼?

其實,Java 的反射調用機制還設立了另一種動态生成位元組碼的實作(下稱動态實作),直接使用 invoke 指令來調用目标方法。之是以采用委派實作,便是為了能夠在本地實作以及動态實作中切換。

// 動态實作的僞代碼,這裡隻列舉了關鍵的調用邏輯,其實它還包括調用者檢測、參數檢測的位元組碼。
package jdk.internal.reflect;
 
public class GeneratedMethodAccessor1 extends ... {
  @Overrides    
  public Object invoke(Object obj, Object[] args) throws ... {
    Test.target((int) args[0]);
    return null;
  }
}           

動态實作和本地實作相比,其運作效率要快上 20 倍 [2] 。這是因為動态實作無需經過 Java 到 C++ 再到 Java 的切換,但由于生成位元組碼十分耗時,僅調用一次的話,反而是本地實作要快上 3 到 4 倍 [3]。

考慮到許多反射調用僅會執行一次,Java 虛拟機設定了一個門檻值 15(可以通過 -Dsun.reflect.inflationThreshold= 來調整),當某個反射調用的調用次數在 15 之下時,采用本地實作;當達到 15 時,便開始動态生成位元組碼,并将委派實作的委派對象切換至動态實作,這個過程我們稱之為 Inflation。

為了觀察這個過程,我将剛才的例子更改為下面的 v1 版本。它會将反射調用循環 20 次。

// v1 版本
import java.lang.reflect.Method;
 
public class Test {
  public static void target(int i) {
    new Exception("#" + i).printStackTrace();
  }
 
  public static void main(String[] args) throws Exception {
    Class<?> klass = Class.forName("Test");
    Method method = klass.getMethod("target", int.class);
    for (int i = 0; i < 20; i++) {
      method.invoke(null, i);
    }
  }
}
 
# 使用 -verbose:class 列印加載的類
$ java -verbose:class Test
...
java.lang.Exception: #14
        at Test.target(Test.java:5)
        at java.base/jdk.internal.reflect.NativeMethodAccessorImpl .invoke0(Native Method)
        at java.base/jdk.internal.reflect.NativeMethodAccessorImpl .invoke(NativeMethodAccessorImpl.java:62)
        at java.base/jdk.internal.reflect.DelegatingMethodAccessorImpl .invoke(DelegatingMethodAccessorImpl.java:43)
        at java.base/java.lang.reflect.Method.invoke(Method.java:564)
        at Test.main(Test.java:12)
[0.158s][info][class,load] ...
...
[0.160s][info][class,load] jdk.internal.reflect.GeneratedMethodAccessor1 source: __JVM_DefineClass__
java.lang.Exception: #15
       at Test.target(Test.java:5)
       at java.base/jdk.internal.reflect.NativeMethodAccessorImpl .invoke0(Native Method)
       at java.base/jdk.internal.reflect.NativeMethodAccessorImpl .invoke(NativeMethodAccessorImpl.java:62)
       at java.base/jdk.internal.reflect.DelegatingMethodAccessorImpl .invoke(DelegatingMethodAccessorImpl.java:43)
       at java.base/java.lang.reflect.Method.invoke(Method.java:564)
       at Test.main(Test.java:12)
java.lang.Exception: #16
       at Test.target(Test.java:5)
       at jdk.internal.reflect.GeneratedMethodAccessor1 .invoke(Unknown Source)
       at java.base/jdk.internal.reflect.DelegatingMethodAccessorImpl .invoke(DelegatingMethodAccessorImpl.java:43)
       at java.base/java.lang.reflect.Method.invoke(Method.java:564)
       at Test.main(Test.java:12)
...           

可以看到,在第 15 次(從 0 開始數)反射調用時,我們便觸發了動态實作的生成。這時候,Java 虛拟機額外加載了不少類。其中,最重要的當屬 GeneratedMethodAccessor1(第 30 行)。并且,從第 16 次反射調用開始,我們便切換至這個剛剛生成的動态實作(第 40 行)。

反射調用的 Inflation 機制是可以通過參數(-Dsun.reflect.noInflation=true)來關閉的。這樣一來,在反射調用一開始便會直接生成動态實作,而不會使用委派實作或者本地實作。

反射調用的開銷

下面,我們便來拆解反射調用的性能開銷。

在剛才的例子中,我們先後進行了 Class.forName,Class.getMethod 以及 Method.invoke 三個操作。其中,Class.forName 會調用本地方法,Class.getMethod 則會周遊該類的公有方法。如果沒有比對到,它還将周遊父類的公有方法。可想而知,這兩個操作都非常費時。

值得注意的是,以 getMethod 為代表的查找方法操作,會傳回查找得到結果的一份拷貝。是以,我們應當避免在熱點代碼中使用傳回 Method 數組的 getMethods 或者 getDeclaredMethods 方法,以減少不必要的堆空間消耗。

在實踐中,我們往往會在應用程式中緩存 Class.forName 和 Class.getMethod 的結果。是以,下面我就隻關注反射調用本身的性能開銷。

為了比較直接調用和反射調用的性能差距,我将前面的例子改為下面的 v2 版本。它會将反射調用循環二十億次。此外,它還将記錄下每跑一億次的時間。

我将取最後五個記錄的平均值,作為預熱後的峰值性能。(注:這種性能評估方式并不嚴謹,我會在專欄的第三部分介紹如何用 JMH 來測性能。)

在我這個老筆記本上,一億次直接調用耗費的時間大約在 120ms。這和不調用的時間是一緻的。其原因在于這段代碼屬于熱循環,同樣會觸發即時編譯。并且,即時編譯會将對 Test.target 的調用内聯進來,進而消除了調用的開銷。

// v2 版本
mport java.lang.reflect.Method;
 
public class Test {
  public static void target(int i) {
    // 空方法
  }
 
  public static void main(String[] args) throws Exception {
    Class<?> klass = Class.forName("Test");
    Method method = klass.getMethod("target", int.class);
 
    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;
      }
 
      method.invoke(null, 128);
    }
  }
}           

下面我将以 120ms 作為基準,來比較反射調用的性能開銷。

由于目标方法 Test.target 接收一個 int 類型的參數,是以我傳入 128 作為反射調用的參數,測得的結果約為基準的 2.7 倍。我們暫且不管這個數字是高是低,先來看看在反射調用之前位元組碼都做了什麼。

59: aload_2                         // 加載 Method 對象
   60: aconst_null                     // 反射調用的第一個參數 null
   61: iconst_1
   62: anewarray Object                // 生成一個長度為 1 的 Object 數組
   65: dup
   66: iconst_0
   67: sipush 128
   70: invokestatic Integer.valueOf    // 将 128 自動裝箱成 Integer
   73: aastore                         // 存入 Object 數組中
   74: invokevirtual Method.invoke     // 反射調用           

這裡我截取了循環中反射調用編譯而成的位元組碼。可以看到,這段位元組碼除了反射調用外,還額外做了兩個操作。

第一,由于 Method.invoke 是一個變長參數方法,在位元組碼層面它的最後一個參數會是 Object 數組(感興趣的同學私下可以用 javap 檢視)。Java 編譯器會在方法調用處生成一個長度為傳入參數數量的 Object 數組,并将傳入參數一一存儲進該數組中。

第二,由于 Object 數組不能存儲基本類型,Java 編譯器會對傳入的基本類型參數進行自動裝箱。

這兩個操作除了帶來性能開銷外,還可能占用堆記憶體,使得 GC 更加頻繁。(如果你感興趣的話,可以用虛拟機參數 -XX:+PrintGC 試試。)那麼,如何消除這部分開銷呢?

關于第二個自動裝箱,Java 緩存了 [-128, 127] 中所有整數所對應的 Integer 對象。當需要自動裝箱的整數在這個範圍之内時,便傳回緩存的 Integer,否則需要建立一個 Integer 對象。

是以,我們可以将這個緩存的範圍擴大至覆寫 128(對應參數 -Djava.lang.Integer.IntegerCache.high=128),便可以避免需要建立 Integer 對象的場景。

或者,我們可以在循環外緩存 128 自動裝箱得到的 Integer 對象,并且直接傳入反射調用中。這兩種方法測得的結果差不多,約為基準的 1.8 倍。

現在我們再回來看看第一個因變長參數而自動生成的 Object 數組。既然每個反射調用對應的參數個數是固定的,那麼我們可以選擇在循環外建立一個 Object 數組,設定好參數,并直接交給反射調用。改好的代碼可以參照文稿中的 v3 版本。

// v3 版本 為基準的 2.9 倍
import java.lang.reflect.Method;
 
public class Test {
  public static void target(int i) {
    // 空方法
  }
 
  public static void main(String[] args) throws Exception {
    Class<?> klass = Class.forName("Test");
    Method method = klass.getMethod("target", int.class);
 
    Object[] arg = new Object[1]; // 在循環外構造參數數組
    arg[0] = 128;
 
    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;
      }
 
      method.invoke(null, arg);
    }
  }
}           

測得的結果反而更糟糕了,為基準的 2.9 倍。這是為什麼呢?

如果你在上一步解決了自動裝箱之後檢視運作時的 GC 狀況,你會發現這段程式并不會觸發 GC。其原因在于,原本的反射調用被内聯了,進而使得即時編譯器中的逃逸分析将原本建立的 Object 數組判定為不逃逸的對象。

如果一個對象不逃逸,那麼即時編譯器可以選擇棧配置設定甚至是虛拟配置設定,也就是不占用堆空間。具體我會在本專欄的第二部分詳細解釋。

如果在循環外建立數組,即時編譯器無法确定這個數組會不會中途被更改,是以無法優化掉通路數組的操作,可謂是得不償失。

到目前為止,我們的最好記錄是 1.8 倍。那能不能再進一步提升呢?

剛才我曾提到,可以關閉反射調用的 Inflation 機制,進而取消委派實作,并且直接使用動态實作。此外,每次反射調用都會檢查目标方法的權限,而這個檢查同樣可以在 Java 代碼裡關閉,在關閉了這兩項機制之後,也就得到了我們的 v4 版本,它測得的結果約為基準的 1.3 倍。

// v4 版本 約為基準的 1.3 倍
import java.lang.reflect.Method;
 
// 在運作指令中添加如下兩個虛拟機參數:
// -Djava.lang.Integer.IntegerCache.high=128
// -Dsun.reflect.noInflation=true
public class Test {
  public static void target(int i) {
    // 空方法
  }
 
  public static void main(String[] args) throws Exception {
    Class<?> klass = Class.forName("Test");
    Method method = klass.getMethod("target", int.class);
    method.setAccessible(true);  // 關閉權限檢查
 
    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;
      }
 
      method.invoke(null, 128);
    }
  }
}           

到這裡,我們基本上把反射調用的水分都榨幹了。接下來,我來把反射調用的性能開銷給提回去。

首先,在這個例子中,之是以反射調用能夠變得這麼快,主要是因為即時編譯器中的方法内聯。在關閉了 Inflation 的情況下,内聯的瓶頸在于 Method.invoke 方法中對 MethodAccessor.invoke 方法的調用。

深入了解JVM虛拟機——JVM是如何實作反射的?

我會在後面的文章中介紹方法内聯的具體實作,這裡先說個結論:在生産環境中,我們往往擁有多個不同的反射調用,對應多個 GeneratedMethodAccessor,也就是動态實作。

由于 Java 虛拟機的關于上述調用點的類型 profile(注:對于 invokevirtual 或者 invokeinterface,Java 虛拟機會記錄下調用者的具體類型,我們稱之為類型 profile)無法同時記錄這麼多個類,是以可能造成所測試的反射調用沒有被内聯的情況。

// v5 版本
import java.lang.reflect.Method;
 
public class Test {
  public static void target(int i) {
    // 空方法
  }
 
  public static void main(String[] args) throws Exception {
    Class<?> klass = Class.forName("Test");
    Method method = klass.getMethod("target", int.class);
    method.setAccessible(true);  // 關閉權限檢查
    polluteProfile();
 
    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;
      }
 
      method.invoke(null, 128);
    }
  }
 
  public static void polluteProfile() throws Exception {
    Method method1 = Test.class.getMethod("target1", int.class);
    Method method2 = Test.class.getMethod("target2", int.class);
    for (int i = 0; i < 2000; i++) {
      method1.invoke(null, 0);
      method2.invoke(null, 0);
    }
  }
  public static void target1(int i) { }
  public static void target2(int i) { }
}           

在上面的 v5 版本中,我在測試循環之前調用了 polluteProfile 的方法。該方法将反射調用另外兩個方法,并且循環上 2000 遍。

而測試循環則保持不變。測得的結果約為基準的 6.7 倍。也就是說,隻要誤擾了 Method.invoke 方法的類型 profile,性能開銷便會從 1.3 倍上升至 6.7 倍。

之是以這麼慢,除了沒有内聯之外,另外一個原因是逃逸分析不再起效。這時候,我們便可以采用剛才 v3 版本中的解決方案,在循環外構造參數數組,并直接傳遞給反射調用。這樣子測得的結果約為基準的 5.2 倍。

除此之外,我們還可以提高 Java 虛拟機關于每個調用能夠記錄的類型數目(對應虛拟機參數 -XX:TypeProfileWidth,預設值為 2,這裡設定為 3)。最終測得的結果約為基準的 2.8 倍,盡管它和原本的 1.3 倍還有一定的差距,但總算是比 6.7 倍好多了。

總結與實踐

今天我介紹了 Java 裡的反射機制。

在預設情況下,方法的反射調用為委派實作,委派給本地實作來進行方法調用。在調用超過 15 次之後,委派實作便會将委派對象切換至動态實作。這個動态實作的位元組碼是自動生成的,它将直接使用 invoke 指令來調用目标方法。

方法的反射調用會帶來不少性能開銷,原因主要有三個:變長參數方法導緻的 Object 數組,基本類型的自動裝箱、拆箱,還有最重要的方法内聯。

今天的實踐環節,你可以将最後一段代碼中 polluteProfile 方法的兩個 Method 對象,都改成擷取名字為“target”的方法。請問這兩個獲得的 Method 對象是同一個嗎(==)?他們 equal 嗎(.equals(…))?對我們的運作結果有什麼影響?

import java.lang.reflect.Method;
 
public class Test {
  public static void target(int i) {
    // 空方法
  }
 
  public static void main(String[] args) throws Exception {
    Class<?> klass = Class.forName("Test");
    Method method = klass.getMethod("target", int.class);
    method.setAccessible(true);  // 關閉權限檢查
    polluteProfile();
 
    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;
      }
 
      method.invoke(null, 128);
    }
  }
 
  public static void polluteProfile() throws Exception {
    Method method1 = Test.class.getMethod("target", int.class);
    Method method2 = Test.class.getMethod("target", int.class);
    for (int i = 0; i < 2000; i++) {
      method1.invoke(null, 0);
      method2.invoke(null, 0);
    }
  }
  public static void target1(int i) { }
  public static void target2(int i) { }
}           

附錄:反射 API 簡介

通常來說,使用反射 API 的第一步便是擷取 Class 對象。在 Java 中常見的有這麼三種。

  1. 使用靜态方法 Class.forName 來擷取。
  2. 調用對象的 getClass() 方法。
  3. 直接用類名 +“.class”通路。對于基本類型來說,它們的包裝類型(wrapper classes)擁有一個名為“TYPE”的 final 靜态字段,指向該基本類型對應的 Class 對象。

例如,Integer.TYPE 指向 int.class。對于數組類型來說,可以使用類名 +“[ ].class”來通路,如 int[ ].class。

除此之外,Class 類和 java.lang.reflect 包中還提供了許多傳回 Class 對象的方法。例如,對于數組類的 Class 對象,調用 Class.getComponentType() 方法可以獲得數組元素的類型。

一旦得到了 Class 對象,我們便可以正式地使用反射功能了。下面我列舉了較為常用的幾項。

  1. 使用 newInstance() 來生成一個該類的執行個體。它要求該類中擁有一個無參數的構造器。
  2. 使用 isInstance(Object) 來判斷一個對象是否該類的執行個體,文法上等同于 instanceof 關鍵字(JIT 優化時會有差别,我會在本專欄的第二部分詳細介紹)。
  3. 使用 Array.newInstance(Class,int) 來構造該類型的數組。
  4. 使用 getFields()/getConstructors()/getMethods() 來通路該類的成員。除了這三個之外,Class 類還提供了許多其他方法,詳見 [4]。需要注意的是,方法名中帶 Declared 的不會傳回父類的成員,但是會傳回私有成員;而不帶 Declared 的則相反。

當獲得了類成員之後,我們可以進一步做如下操作。

  • 使用 Constructor/Field/Method.setAccessible(true) 來繞開 Java 語言的通路限制。
  • 使用 Constructor.newInstance(Object[]) 來生成該類的執行個體。
  • 使用 Field.get/set(Object) 來通路字段的值。
  • 使用 Method.invoke(Object, Object[]) 來調用方法。

繼續閱讀