java程式設計中,使用反射來增強靈活性(如各類框架)、某些抽象(如各類框架)及減少樣板程式碼(如Java Bean)。
因此,反射在實際的java專案中被大量使用。
由於專案裡存在反射的效能瓶頸,使用的是ReflectASM高效能反射庫來優化。
因此,在空閒時間研究了下的這個庫,並做了簡單的Beachmark。
<!–more–>
介紹
ReflectASM是使用位元組碼生成來加強反射的效能。
反射包含多種反射,這個庫很簡單,它提供的特性則是:
- 根據匹配的字串操作成員變數。
- 根據匹配的字串呼叫成員函式。
- 根據匹配的字串呼叫建構函式。
這三種也恰恰是實際使用中最多的,且在特殊場景下也容易產生效能問題。
例子
舉個例子,使用MethodAccess來反射呼叫類的函式:
Person person = new Person();
MethodAccess m = MethodAccess.get(Person.class);
Object value = m.invoke(person, "getName");
更多的例子參考官方文件,這個庫本身就不大,就幾個類。
實現原理
MethodAccess.get方法
static public MethodAccess get (Class type) {
ArrayList<Method> methods = new ArrayList<Method>();
boolean isInterface = type.isInterface();
if (!isInterface) {
Class nextClass = type;
while (nextClass != Object.class) {
addDeclaredMethodsToList(nextClass, methods);
nextClass = nextClass.getSuperclass();
}
} else {
recursiveAddInterfaceMethodsToList(type, methods);
}
int n = methods.size();
String[] methodNames = new String[n];
Class[][] parameterTypes = new Class[n][];
Class[] returnTypes = new Class[n];
for (int i = 0; i < n; i++) {
Method method = methods.get(i);
methodNames[i] = method.getName();
parameterTypes[i] = method.getParameterTypes();
returnTypes[i] = method.getReturnType();
}
String className = type.getName();
String accessClassName = className + "MethodAccess";
if (accessClassName.startsWith("java.")) accessClassName = "reflectasm." + accessClassName;
Class accessClass;
AccessClassLoader loader = AccessClassLoader.get(type);
synchronized (loader) {
try {
accessClass = loader.loadClass(accessClassName);
} catch (ClassNotFoundException ignored) {
String accessClassNameInternal = accessClassName.replace(`.`, `/`);
String classNameInternal = className.replace(`.`, `/`);
ClassWriter cw = new ClassWriter(ClassWriter.COMPUTE_MAXS);
MethodVisitor mv;
/* ... 位元組碼生成 */
byte[] data = cw.toByteArray();
accessClass = loader.defineClass(accessClassName, data);
}
}
try {
MethodAccess access = (MethodAccess)accessClass.newInstance();
access.methodNames = methodNames;
access.parameterTypes = parameterTypes;
access.returnTypes = returnTypes;
return access;
} catch (Throwable t) {
throw new RuntimeException("Error constructing method access class: " + accessClassName, t);
}
}
大致邏輯為:
- 通過java反射獲取必要的函式名、函式型別等資訊。
- 動態生成一個用於呼叫被反射物件的類,其為MethodAccess的子類。
- 反射生成動態生成的類,返回。
由於裡面包含位元組碼生成操作,所以相對來說這個函式是比較耗時的。
我們來分析一下,如果第二次呼叫對相同的類呼叫MethodAccess.get()
方法,會不會好一些?
注意到:
synchronized (loader) {
try {
accessClass = loader.loadClass(accessClassName);
} catch {
/* ... */
}
}
因此,如果這個動態生成的MethodAccess類已經生成過,第二次呼叫MethodAccess.get
是不會操作位元組碼生成的。
但是,前面的一大堆準備反射資訊的操作依然會被執行。所以,如果在程式碼中封裝這樣的一個函式試圖使用ReflectASM庫:
Object reflectionInvoke(Object bean, String methodName) {
MethodAccess m = MethodAccess.get(bean.getClass());
return m.invoke(bean, methodName);
}
那麼每次反射呼叫前都得執行這麼一大坨準備反射資訊的程式碼,實際上還不如用原生反射呢。這個後面會有Beachmark。
為什麼不在找不到動態生成的MethodAccess類時(即第一次呼叫)時,再準備反射資訊?這個得問作者。
動態生成的類
通過idea偵錯程式獲取動態生成類的位元組碼
那麼那個動態生成的類的內部到底是什麼?
由於這個類是動態生成的,所以獲取它的定義比較麻煩。
一開始我試圖尋找java的ClassLoader的API獲取它的位元組碼,但是似乎沒有這種API。
後來,我想了一個辦法,直接在MethodAccess.get
裡面的這行程式碼打斷點:
byte[] data = cw.toByteArray();
通過idea的偵錯程式把data
的內容複製出來。但是這又遇到一個問題,data是二進位制內容,根本複製不出來。
一個一年要400美刀的IDE,為啥不能做的貼心一點啊?
既然是二進位制內容,那麼只能設法將其編碼成文字再複製了。通過idea偵錯程式自定義view的功能,將其編碼成base64後複製了出來。
然後,搞個python小指令碼將其base64解碼回.class檔案:
#!/usr/bin/env python3
import base64
with open("tmp.txt", "rb") as fi, open("tmp.class", "wb") as fo:
base64.decode(fi, fo)
反編譯.class檔案,得到:
//
// Source code recreated from a .class file by IntelliJ IDEA
// (powered by Fernflower decompiler)
//
package io.github.frapples.javademoandcookbook.commonutils.entity;
import com.esotericsoftware.reflectasm.MethodAccess;
public class PointMethodAccess extends MethodAccess {
public PointMethodAccess() {
}
public Object invoke(Object var1, int var2, Object... var3) {
Point var4 = (Point)var1;
switch(var2) {
case 0:
return var4.getX();
case 1:
var4.setX((Integer)var3[0]);
return null;
case 2:
return var4.getY();
case 3:
var4.setY((Integer)var3[0]);
return null;
case 4:
return var4.toString();
case 5:
return Point.of((Integer)var3[0], (Integer)var3[1], (String)var3[2]);
default:
throw new IllegalArgumentException("Method not found: " + var2);
}
}
}
可以看到,生成的invoke方法中,直接根據索引使用switch直接呼叫。
所以,只要使用得當,效能媲美原生呼叫是沒有什麼問題的。
MethodAccess.invoke方法
來看invoke
方法內具體做了哪些操作:
abstract public Object invoke (Object object, int methodIndex, Object... args);
/** Invokes the method with the specified name and the specified param types. */
public Object invoke (Object object, String methodName, Class[] paramTypes, Object... args) {
return invoke(object, getIndex(methodName, paramTypes), args);
}
/** Invokes the first method with the specified name and the specified number of arguments. */
public Object invoke (Object object, String methodName, Object... args) {
return invoke(object, getIndex(methodName, args == null ? 0 : args.length), args);
}
/** Returns the index of the first method with the specified name. */
public int getIndex (String methodName) {
for (int i = 0, n = methodNames.length; i < n; i++)
if (methodNames[i].equals(methodName)) return i;
throw new IllegalArgumentException("Unable to find non-private method: " + methodName);
}
如果通過函式名稱呼叫函式(即呼叫invoke(Object, String, Class[], Object...)
,
則MethodAccess
是先遍歷所有函式名稱拿到索引,然後根據索引呼叫對應方法(即呼叫虛擬函式invoke(Object, int, Object...)
,
實際上是通過多型呼叫位元組碼動態生成的子類的對應函式。
如果被反射呼叫的類的函式很多,則這個遍歷操作帶來的效能損失不能忽略。
所以,效能要求高的場合,應該預先通過getIndex
方法提前獲得索引,然後後面即可以直接使用invoke(Object, int, Object...)
來呼叫。
Beachmark
談這種細粒度操作級別的效能問題,最有說服力的就是實際測試資料了。
下面,Talk is cheap, show you my beachmark.
首先是相關環境:
作業系統版本: elementary OS 0.4.1 Loki 64-bit
CPU: 雙核 Intel® Core™ i5-7200U CPU @ 2.50GHz
JMH基準測試框架版本: 1.21
JVM版本: JDK 1.8.0_181, OpenJDK 64-Bit Server VM, 25.181-b13
Benchmark Mode Cnt Score Error Units
// 通過MethodHandle呼叫。預先得到某函式的MethodHandle
ReflectASMBenchmark.javaMethodHandleWithInitGet thrpt 5 122.988 ± 4.240 ops/us
// 通過java反射呼叫。快取得到的Method物件
ReflectASMBenchmark.javaReflectWithCacheGet thrpt 5 11.877 ± 2.203 ops/us
// 通過java反射呼叫。預先得到某函式的Method物件
ReflectASMBenchmark.javaReflectWithInitGet thrpt 5 66.702 ± 11.154 ops/us
// 通過java反射呼叫。每次呼叫都先取得Method物件
ReflectASMBenchmark.javaReflectWithOriginGet thrpt 5 3.654 ± 0.795 ops/us
// 直接呼叫
ReflectASMBenchmark.normalCall thrpt 5 1059.926 ± 99.724 ops/us
// ReflectASM通過索引呼叫。預先取得MethodAccess物件,預先取得某函式的索引
ReflectASMBenchmark.reflectAsmIndexWithCacheGet thrpt 5 639.051 ± 47.750 ops/us
// ReflectASM通過函式名呼叫,快取得到的MethodAccess物件
ReflectASMBenchmark.reflectAsmWithCacheGet thrpt 5 21.868 ± 1.879 ops/us
// ReflectASM通過函式名呼叫,預先得到的MethodAccess
ReflectASMBenchmark.reflectAsmWithInitGet thrpt 5 53.370 ± 0.821 ops/us
// ReflectASM通過函式名呼叫,每次呼叫都取得MethodAccess
ReflectASMBenchmark.reflectAsmWithOriginGet thrpt 5 0.593 ± 0.005 ops/us
可以看到,每次呼叫都來一次MethodAccess.get
,效能是最慢的,時間消耗是java原生呼叫的6倍,不如用java原生呼叫。
最快的則是預先取得MethodAccess和函式的索引並用索引來呼叫。其時間消耗僅僅是直接呼叫的2倍不到。
基準測試程式碼見:
https://github.com/frapples/j…
jmh框架十分專業,在基準測試前會做複雜的預熱過程以減少環境、優化等影響,基準測試也儘可能通過合理的迭代次數等方式來減小誤差。
所以,在預設的迭代次數、預熱次數下,跑一次基準測試的時間不短,CPU呼呼的轉。。。
最後總結
在使用ReflectASM對某類進行反射呼叫時,需要預先生成或獲取位元組碼動態生成的MethodAccess子類物件。
這一操作是非常耗時的,所以正確的使用方法應該是:
- 在某個利用反射的耗時函式啟動前,先預先生成這個MethodAccess物件。
- 如果是自己裡面ReflectASM封裝工具類,則應該設計快取,快取生成的MethodAccess物件。
如果不這樣做,這個ReflectASM用的沒有任何意義,效能還不如java的原生反射。
如果想進一步提升效能,那麼還應該避免使用函式的字串名稱來呼叫,而是在耗時的函式啟動前,預先獲取函式名稱對應的整數索引。
在後面的耗時的函式,使用這個整數索引進行呼叫。