反射是Java语言中一个相当重要的特性,允许正在运行的Java程序观测,甚至是修改程序的动态行为。。。
举例来说可以通过Class对象枚举该类中的所有方法,还可以通过Method.setAccessible绕过Java语言的访问权限,在私有方法所在类之外的地方调用该方法。
另外一个日常应用是Java调试器,能够在调试过程中枚举某一对象所有字段的值。
Web开发中经常能接触到各种可配置的通用框架,为了保证框架的可扩展性,往往需要借助Java的反射机制,根据配置文件来加载不同的类,Spring框架的依赖反转(IOC)便是依赖于反射机制。但是很多人嫌弃反射机制比较慢甚至甲骨文关于反射教学网页同样强调了反射性能开销大的缺点。
反射调用的实现
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实例的第一次反射调用都会生成一个委派实现,所委派的具体实现便是一个本地实现,当进入JVM内部之后,拥有了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
上面代码中获取了一个指向Test.target方法的Method对象,并且用它来进行反射调用。反射调用显示调用了Method.invoke然后进入委派实现,再进入本地实现最后到达目标方法。。
那么为什么反射调用还要采取委派实现作为中间层,直接交给本地实现不可以么?
事实上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倍,是因为动态实现无需经过Java到C++再到Java的切换,但是由于生成字节码十分耗时,仅仅调用一次的话,反而运行效率是本地实现的3到4倍。
许多反射可能只执行一次,JVM设置了一个阈值15,当某个反射调用的调用次数在15之下采用本地实现,达到15时便开始动态生成字节码,并将委派实现的委派对象切换至动态实现,这个过程称之为Inflation。
// 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)
...
将反射调用循环20次,可以看到在第15次反射调用的时便触发了动态实现的生成,JVM额外加载了很多类最重要的是GeneratedMethodAccessor1并且从第16次反射调用开始便切换至刚刚生成的动态实现。
反射调用的Inflation机制可以通过参数(-Dsun.reflect.noInflation=true)来关闭,这样反射调用一开始便会直接生成动态实现不会使用委派实现或者本地实现。。。
反射调用的开销
下面,我们就来说一下反射带给我们额外性能的开销。我们在刚才的例子中,使用到了
Class.forName():调用本地方法
Class.getMethod():遍历该类的共有方法。如果没有匹配到,还会遍历父类的共有方法
我们可以看出上面的两个方法实现都是很耗时的,尤其是getMethod(),我们至少要避免getMethod方法在热点代码中少用。
下面的例子会将反射调用执行二十亿次
// 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);
}
}
}
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数组,Java编译器会在方法调用处生成一个长度为传入参数数量的Object数组,并将传入参数一一存储进该数组中。
- 由于Object数组不能存储基本类型,Java编译器会对传入的基本类型参数进行自动装箱。
这两个操作除了带来性能开销外,还可能占用堆内存,使得GC更加频繁。
那么如何消除这部分开销呢?
关于第二个自动装箱,Java缓存了[-128,127]中所有整数所对应的Integer对象。当需要自动装箱的整数在这个范围之内时,便返回缓存的Integer否则需要新建一个Integer对象。
所以为了不新建Integer对象,可将缓存的范围扩大。
总结
默认情况下,方法的反射调用为委派实现,委派给本地实现来进行方法调用。调用超过15次后委派实现便会将委派对象切换至动态实现。这个动态实现的字节码是自动实现的,将直接适应invoke指令来调用目标方法。。
方法 的反射调用会带来不少性能开销,原因主要有三个:变长参数方法导致的Object数组,基本类型的自动装箱、拆箱,还有最重要的方法内联。