在我们聊起JVM是如何实现反射的之前,我们先来说一下什么是反射。
反射:反射就是在运行过程中获取类的信息,并能调用类的方法。
反射是Java语言中一个相当重要的特性,它运行正在运行的Java语言程序观测,甚至是修改程序的状态行为。
举例来说,我们可以通过class对象枚举该类中的所有方法,我们还可以通过java的反射包里的Method.setAccessible绕过java语言的访问权限,在私有方法类以外的地方调用里面的方法。
说一个反射在我们开发中很常见的情况吧,在我们使用IntelliJ Idea进行开发的时候,我们使用.的时候,会自动告诉我们可以调用什么方法,这就是开发中一种十分常见的反射的效果。
在我们使用Web开发中,SpringIOC的依赖反转就是依赖于Java的反射机制,但我们也同时可以感受到,反射是一种很浪费性能的事情,在Oracle官方也特意提到了其对于性能消耗过大的事情。
接下来就来说一下反射的实现机制,以及性能糟糕的原因。
反射调用的实现
首先我们来看看方法的反射调用是如何实现的,就是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);
}
}
我们可以通过上面的方法发现,它实际上将反射的业务委派给了MethodAccessor来实现,MethodAccessor是一个接口,它有两个具体的接口实现:一个通过本地方法来实现反射调用,另一个则使用了委派模式。为了方便记忆,我便用了“本地使用”和“委派实现”来指代这两者。
每个Method实例的第一次反射调用都会生成一个委派实现,它所委派的具体实现便是一个本地实现。本地实现非常容易理解。当进入了JVM内部之后,我们便拥有了Method实例指向具体的方法地址。这时候,反射调用就会将参数都准备好,自动填充到对应的方法内。
那我们使用委派实现的具体作用是什么呢,直接交给本地不好吗?
其实,Java的反射机制还设立了另一种动态生成字节码的实现,简称动态实现,并且委派实现的意义就是在于,可以在本地实现和动态实现中切换。
在这里我说一下,动态实现的总体速度是比本地实现快上几十倍的,但是问题在于,生成字节码然后解码的过程倒是很浪费资源,所以,如果你就调用一次方法去反射,得不偿失啊。
所以JVM就规定了反射次数的一个规范:当调用invoke方法<15次,就本地实现,当≥15次,就用动态生成字节码的方法反射。
举一个反射调用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次反射调用invoke的时候,虚拟机加载了额外的类,这就是动态实现的证明,并且可以看出,在后面的实现,都是依赖于动态实现去进行反射调用。
这种规范,被我们定义为Inflation
反射调用的开销
下面,我们就来说一下反射带给我们额外性能的开销。我们在刚才的例子中,使用到了
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对象,可以将缓存的范围也扩大。