反射是 Java 語言中一個相當重要的特性,它允許正在運作的 Java 程式觀測,甚至是修改程式的動态行為。
舉例來說,我們可以通過 Class 對象枚舉該類中的所有方法,我們還可以通過 Method.setAccessible(位于 java.lang.reflect 包,該方法繼承自 AccessibleObject)繞過 Java 語言的通路權限,在私有方法所在類之外的地方調用該方法。
反射在 Java 中的應用十分廣泛。開發人員日常接觸到的 Java 內建開發環境(IDE)便運用了這一功能:每當我們敲入點号時,IDE 便會根據點号前的内容,動态展示可以通路的字段或者方法。
另一個日常應用則是 Java 調試器,它能夠在調試過程中枚舉某一對象所有字段的值。
(圖中 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 方法的調用。
我會在後面的文章中介紹方法内聯的具體實作,這裡先說個結論:在生産環境中,我們往往擁有多個不同的反射調用,對應多個 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 中常見的有這麼三種。
- 使用靜态方法 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[]) 來調用方法。