(注:文章总结自极客时间郑雨迪老师的《深入拆解java虚拟机》课程,且自己做了一些测试和修正)
反射是 Java 语言中一个相当重要的特性,它允许正在运行的 Java 程序观测,甚至是修改程序的动态行为。
1 常用反射API简介
1.1 获取class对象
- Class.forName(“类名”)
- 对象.getClass() 方法
- 类名.class
- 对于基本类型来说,它们的包装类型拥有一个名为“TYPE”的 final 静态字段,指向该基本类型对应的 Class 对象。例如,Integer.TYPE 指向 int.class
1.2 类对象的的常用api
- 类对象. newInstance(),生成一个该类的实例。它要求该类中拥有一个无参数的构造器。
- 类对象.isInstance(Object),判断一个对象是否该类的实例。
- Array.newInstance(Class,int),构造该类型的数组。
- getFields()/getConstructors()/getMethods(),访问该类的成员。
注:类对象的方法中,方法名中带 Declared 的不会返回父类的成员,但是会返回私有成员;而不带 Declared 的则相反。
1.3 类成员的常用api
- Constructor/Field/Method.setAccessible(true) ,绕开 Java 语言的访问限制。
- Constructor.newInstance(Object[]),生成该类的实例。
- Field.get/set(Object),访问字段的值。
- Method.invoke(Object, Object[]),调用方法。
2 反射调用的实现
每个 Method 实例的第一次反射调用都会生成一个委派实现,它所委派的具体实现便是一个本地实现。本地实现非常容易理解。当进入了 Java 虚拟机内部之后,我们便拥有了 Method 实例所指向方法的具体地址。这时候,反射调用无非就是将传入的参数准备好,然后调用进入目标方法。
import java.lang.reflect.Method;
public class Main {
public static void test(int i) {
new Exception("#" + i).printStackTrace();
}
public static void main(String[] args) throws Exception {
Class<?> clazz = Class.forName("Main");
Method method = clazz.getMethod("test", int.class);
method.invoke(null, 0);
}
}
运行结果
java.lang.Exception: #0
at Main.test(Main.java:8)
at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62)
at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
at java.lang.reflect.Method.invoke(Method.java:498)
at Main.main(Main.java:14)
可以看到,反射调用先是调用了 Method.invoke,然后进入委派实现(DelegatingMethodAccessorImpl),再然后进入本地实现(NativeMethodAccessorImpl),最后到达目标方法。
为什么要先进入委派实现才调用本地实现呢?
java 反射机制中有另一种动态生成字节码的实现,直接使用invoke指令来调用目标方法。之所以采用委派实现,是为了能够在本地实现和动态生成自己吗实现中切换。
动态生成字节码实现比起本地实现来说,运行效率快20倍左右,这是因为动态实现无需经过Java到C++再到Java的转换,但是因为生成字节码的过程很好使,所以仅调用一次的时候,反而是本地实现更快,快3-4倍左右。
这时候,java虚拟机的机制介入了,java虚拟机设置了一个阈值可以通过 -Dsun.reflect.inflationThreshold= 来调整),当某个反射调用的调用次数在15次以下的时候,只采用本地实现,当达到15次时,开始动态生成字节码,并将委派实现和委派对象切换至动态实现,这个过程我们成为Inflation。
我们来做个测试看下堆栈的不同
import java.lang.reflect.Method;
public class Main {
public static void test(int i) {
new Exception("#" + i).printStackTrace();
}
public static void main(String[] args) throws Exception {
Class<?> clazz = Class.forName("Main");
Method method = clazz.getMethod("test", int.class);
for (int i = 1; i <= 20; i++) {
method.invoke(null, i);
}
}
}
使用java -verbose:class Main.java运行结果
java.lang.Exception: #15
at Main.test(Main.java:9)
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:566)
at Main.main(Main.java:16)
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:566)
at jdk.compiler/com.sun.tools.javac.launcher.Main.execute(Main.java:404)
at jdk.compiler/com.sun.tools.javac.launcher.Main.run(Main.java:179)
at jdk.compiler/com.sun.tools.javac.launcher.Main.main(Main.java:119)
[0.392s][info][class,load] jdk.internal.reflect.ClassFileConstants source: jrt:/java.base
[0.392s][info][class,load] jdk.internal.reflect.AccessorGenerator source: jrt:/java.base
[0.392s][info][class,load] jdk.internal.reflect.MethodAccessorGenerator source: jrt:/java.base
[0.392s][info][class,load] jdk.internal.reflect.ByteVectorFactory source: jrt:/java.base
[0.392s][info][class,load] jdk.internal.reflect.ByteVector source: jrt:/java.base
[0.392s][info][class,load] jdk.internal.reflect.ByteVectorImpl source: jrt:/java.base
[0.393s][info][class,load] jdk.internal.reflect.ClassFileAssembler source: jrt:/java.base
[0.393s][info][class,load] jdk.internal.reflect.UTF8 source: jrt:/java.base
[0.393s][info][class,load] jdk.internal.reflect.Label source: jrt:/java.base
[0.393s][info][class,load] jdk.internal.reflect.Label$PatchInfo source: jrt:/java.base
[0.393s][info][class,load] jdk.internal.reflect.MethodAccessorGenerator$1 source: jrt:/java.base
[0.393s][info][class,load] jdk.internal.reflect.ClassDefiner source: jrt:/java.base
[0.393s][info][class,load] jdk.internal.reflect.ClassDefiner$1 source: jrt:/java.base
[0.394s][info][class,load] jdk.internal.reflect.GeneratedMethodAccessor1 source: __JVM_DefineClass__
java.lang.Exception: #16
at Main.test(Main.java:9)
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:566)
at Main.main(Main.java:16)
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:566)
at jdk.compiler/com.sun.tools.javac.launcher.Main.execute(Main.java:404)
at jdk.compiler/com.sun.tools.javac.launcher.Main.run(Main.java:179)
at jdk.compiler/com.sun.tools.javac.launcher.Main.main(Main.java:119)
java.lang.Exception: #17
at Main.test(Main.java:9)
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:566)
at Main.main(Main.java:16)
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:566)
at jdk.compiler/com.sun.tools.javac.launcher.Main.execute(Main.java:404)
at jdk.compiler/com.sun.tools.javac.launcher.Main.run(Main.java:179)
at jdk.compiler/com.sun.tools.javac.launcher.Main.main(Main.java:119)
从结果可以看出,在第16次开始动态生成字节码,且jvm额外加载了很多类,但第16次调用过程还是本地调用,从第17次开始切换到动态实现。
Inflation 机制是可以通过参数(-Dsun.reflect.noInflation=true)来关闭的。这样一来,在反射调用一开始便会直接生成动态实现,而不会使用委派实现或者本地实现。
3 反射调用开销
首先,我们对一个空方法,使用反射调用的方式进行循环20亿次测试耗时,他会在每跑1亿数据打印一次耗时,这里采取将最后五个耗时取平均值的方式作为预热后的峰值性能。
import java.lang.reflect.Method;
public class Main {
public static void test(int i) {
}
public static void main(String[] args) throws Exception {
Class<?> clazz = Class.forName("Main");
Method method = clazz.getMethod("test", 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, 0);
}
}
}
打印结果:
160
231
188
187
191
186
194
188
193
188
184
186
211
184
185
188
184
183
183
184
最后五次取平均值约为184ms,将此值作为基准值。
因为方法接受的是个int类型的参数,这时候如果我将invoke方法的参数传为128,以上边同样的方式执行耗时为 383ms,约为基准值的2倍,为什么呢?
我们看下字节码
63: aload_2 // 加载Method对象
64: aconst_null
65: iconst_1
66: anewarray #13 //Object // 生成一个数组
69: dup
70: iconst_0
71: sipush 128
74: invokestatic #14 //Integer.valueOf // 将128自动装箱为Integer
77: aastore // 放入生成的数组中
78: invokevirtual #15 // 反射调用
- 由于 Method.invoke 是一个变长参数方法,在字节码层面它的最后一个参数会是 Object 数组(感兴趣的同学私下可以用 javap 查看)。Java 编译器会在方法调用处生成一个长度为传入参数数量的 Object 数组,并将传入参数一一存储进该数组中。
- 由于 Object 数组不能存储基本类型,Java 编译器会对传入的基本类型参数进行自动装箱。
这两个操作除了带来性能开销外,还可能占用堆内存,使得 GC 更加频繁。
另外,关于第二个自动装箱,Java 缓存了[-128, 127]中所有整数所对应的 Integer 对象。当需要自动装箱的整数在这个范围之内时,便返回缓存的 Integer,否则需要新建一个 Integer 对象。
因此,我们可以将这个缓存的范围扩大至覆盖 128(对应参数-Djava.lang.Integer.IntegerCache.high=128),便可以避免需要新建 Integer 对象的场景。
或者,我们可以在循环外缓存 128 自动装箱得到的 Integer 对象,并且直接传入反射调用中。这两种方法测得的结果差不多,耗时约为196毫秒,为基准的 1.06倍。
然后我们反过来看低一点,每次循环都会因为变长参数生成Object数组,既然我们这段程序中每次反射调用的参数个数是固定的,那个我们可以在循环外去建立一个Object数组,设置好参数,并交给反射调用。
import java.lang.reflect.Method;
public class Main {
public static void test(int i) {
}
public static void main(String[] args) throws Exception {
Class<?> clazz = Class.forName("Main");
Method method = clazz.getMethod("test", 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);
}
}
}
测试结果耗时约为247ms,约为集中的1.34倍,比之前更糟糕了,为什么呢?
这是因为如果在循环外新建数组,即时编译器无法确定这个数组会不会中途被更改,因此无法优化掉访问数组的操作,可谓是得不偿失。
我们再尝试去优化上一段代码:
- 关闭Inflation机制
- 关闭每次调用方法的检查权限
我们得到以下代码
import java.lang.reflect.Method;
public class Main {
public static void test(int i) {
}
public static void main(String[] args) throws Exception {
Class<?> clazz = Class.forName("Main");
Method method = clazz.getMethod("test", 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);
}
}
}
运行结果耗时为 154ms,为基准的0.83
至此基本榨干了反射调用的水分。
在这个例子中,之所以反射调用能够变得这么快,主要是因为即时编译器中的方法内联。在关闭了 Inflation 的情况下,内联的瓶颈在于 Method.invoke 方法中对 MethodAccessor.invoke 方法的调用。
在生产环境中,我们往往拥有多个不同的反射调用,对应多个 GeneratedMethodAccessor,也就是动态实现。
由于 Java 虚拟机的关于上述调用点的类型 profile(注:对于 invokevirtual 或者 invokeinterface,Java 虚拟机会记录下调用者的具体类型,我们称之为类型 profile)无法同时记录这么多个类,因此可能造成所测试的反射调用没有被内联的情况。
接下来我们修改一下代码污染一下Method.invoke方法的类型profile
import java.lang.reflect.Method;
public class Main {
public static void test(int i) {
}
public static void main(String[] args) throws Exception {
Class<?> clazz = Class.forName("Main");
Method method = clazz.getMethod("test", 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 = Main.class.getMethod("pollute1", int.class);
Method method2 = Main.class.getMethod("pollute2", int.class);
for (int i = 0; i < 2000; i++) {
method1.invoke(null, 0);
method2.invoke(null, 0); }
}
public static void pollute1(int i) { }
public static void pollute2(int i) { }
}
我们在之前循环之前调用下polluteProfile方法,该方法中循环反射调用另外两个方法,此程序再用之前的耗时计算方式计算为425ms, 是基准的2.3倍。
之所以这么慢,除了没有内联之外,另外一个原因是逃逸分析不再起效。这时候,我们便可以采用刚才 在循环外构造参数数组,并直接传递给反射调用。这样子测得的结果约为基准的 1.7 倍。
这里JVM受到-XX:TypeProfileWidth(类型轮廓)限制能够记录的类型数目,默认值为 2,用户无法设置,所以我们就需要尽量在程序结构避免出现这种情况。