JVM是如何實現反射的

_吹雪_發表於2018-10-13

前言

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

舉例來說,我們可以通過 Class 物件列舉該類中的所有方法,我們還可以通過 Method.setAccessible(位於 java.lang.reflect 包,該方法繼承自 AccessibleObject)繞過 Java 語言的訪問許可權,在私有方法所在類之外的地方呼叫該方法。

反射在 Java 中的應用十分廣泛。開發人員日常接觸到的 Java 整合開發環境(IDE)便運用了這一功能:每當我們敲入點號時,IDE 便會根據點號前的內容,動態展示可以訪問的欄位或者方法。

在 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)
 a      t java.base/jdk.internal.reflect.NativeMethodAccessorImpl. .invoke(NativeMethodAccessorImpl.java:62)
 t       java.base/jdk.internal.reflect.DelegatingMethodAccessorImpl.i .invoke(DelegatingMethodAccessorImpl.java:43)
        java.base/java.lang.reflect.Method.invoke(Method.java:564)
  t        Test.main(Test.java:131

為了方便理解,我們可以列印一下反射呼叫到目標方法時的棧軌跡。在上面的 v0 版本程式碼中,我們獲取了一個指向 Test.target 方法的 Method 物件,並且用它來進行反射呼叫。在 Test.target 中,我會列印出棧軌跡。

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

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

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

動態實現和本地實現相比,其執行效率要快上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 版本
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 版本
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 方法的呼叫。

我會在後面的文章中介紹方法內聯的具體實現,這裡先說個結論:在生產環境中,我們往往擁有多個不同的反射呼叫,對應多個 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 倍好多了。

總結與實踐

在預設情況下,方法的反射呼叫為委派實現,委派給本地實現來進行方法呼叫。在呼叫超過 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 中常見的有這麼三種。

  • 使用靜態方法 Class.forName 來獲取。
  • 呼叫物件的 getClass() 方法。
  • 直接用類名 +“.class”訪問。對於基本型別來說,它們的包裝型別(wrapper classes)擁有一個名為“TYPE”的 final 靜態欄位,指向該基本型別對應的 Class 物件。

例如,Integer.TYPE 指向 int.class。對於陣列型別來說,可以使用類名 +“[ ].class”來訪問,如 int[].class。

除此之外,Class 類和 java.lang.reflect 包中還提供了許多返回 Class 物件的方法。例如,對於陣列類的 Class 物件,呼叫 Class.getComponentType() 方法可以獲得陣列元素的型別。

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

  • 使用 newInstance() 來生成一個該類的例項。它要求該類中擁有一個無引數的構造器。
  • 使用 isInstance(Object) 來判斷一個物件是否該類的例項,語法上等同於 instanceof 關鍵字(JIT 優化時會有差別,我會在本專欄的第二部分詳細介紹)。
  • 使用 Array.newInstance(Class,int) 來構造該型別的陣列。
  • 使用 getFields()/getConstructors()/getMethods() 來訪問該類的成員。除了這三個之外,Class 類還提供了許多其他方法,詳見 [4]。需要注意的是,方法名中帶 Declared 的不會返回父類的成員,但是會返回私有成員;而不帶 Declared 的則相反。

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

  • 使用 Constructor/Field/Method.setAccessible(true) 來繞開 Java 語言的訪問限制。
  • 使用 Constructor.newInstance(Object[]) 來生成該類的例項。
  • 使用 Field.get/set(Object) 來訪問欄位的值。
  • 使用 Method.invoke(Object, Object[]) 來呼叫方法。

相關文章