finsh Reflect

master
Omooo 5 years ago
parent 150a28a992
commit 2ea6d0d622
  1. 24
      blogs/Interview/Java.md
  2. 230
      blogs/Java/深入理解反射.md
  3. BIN
      images/JVM/Method.invoke.png

@ -1,24 +0,0 @@
---
深入理解 Java 之 反射
---
### 目录
1. 反射的定义以及基础使用
2. JVM 是如何实现反射调用的?
3. 反射调用的开销在哪?
4. 如何优化反射性能?
5. 参考
### 反射的定义以及基础使用
### 参考
[JVM是如何实现反射的?](https://time.geekbang.org/column/article/12192)
[Java反射原理简析](http://fanyilun.me/2015/10/29/Java%E5%8F%8D%E5%B0%84%E5%8E%9F%E7%90%86/)
[关于反射调用方法的一个log](https://www.iteye.com/blog/rednaxelafx-548536)

@ -17,6 +17,17 @@
5. 如何优化反射性能开销?
6. 参考
### 前言
本文主要参考 [JVM 是如何实现反射的?](https://time.geekbang.org/column/article/12192),但是这篇文章需要购买才能阅读,有兴趣的可以支持一下原作者。
但是笔者在测试的过程中发现两个问题:
1. 关闭 Inflation 机制并不会减少反射调用耗时
2. 增加类型 Profile 默认数量并不会减少反射调用耗时
可能是我的测试方法有误,有理解的小伙伴可以拉我一把哇~
### 反射的定义以及基本使用
反射是 Java 语言中一个相当重要的特性,它允许正在运行的 Java 程序观测,甚至是修改程序的动态行为。表现为如下两点:
@ -470,13 +481,230 @@ java.lang.Exception: # 17
在默认情况下,方法的反射调用为委派实现,委派给本地实现来进行方法调用。再调用超过 15 次之后,委派实现便会将委派对象切换至动态实现。这个动态实现的字节码是自动生成的,它将直接使用 invoke 指令来调用目标方法。
反射调用的 Inflation 机制是可以通过参数(-Dsun.reflect.noInflation=true)来关闭的,这样一来,在反射调用一开始便会直接生成动态实现,而不会使用委派实现或者本地实现。
### 反射性能开销体现在哪?
在刚才的例子中,我们先后进行了 Class.forName,Class.getMethod 以及 Method.invoke 三个操作。其中,Class.forName 会调用本地方法,Class.getMethod 则会遍历该类的公有方法。如果没有匹配到,它还将遍历父类的公有方法,可想而知,这两个操作都非常耗时。
值得注意的是,以 getMethod 为代表的查找方法操作,会返回查找得到结果的一份拷贝。因此,我们应当避免在热点代码中使用返回 Method 数组的 getMethods 或者 getDeclaredMethods 方法,以减少不必要的堆空间消耗。
在实践中,我们往往会在应用程序中缓存 Class.forName 和 Class.getMethod 的结果,因此
在实践中,我们往往会在应用程序中缓存 Class.forName 和 Class.getMethod 的结果,因此,下面我们就只关注反射调用本身的性能开销。
```java
public class ReflectDemo {
public void doSth(int i) {
}
public static void main(String[] args) throws Exception {
Class<?> clazz = ReflectDemo.class;
Constructor constructor = clazz.getConstructor();
Object object = constructor.newInstance();
Method method = clazz.getMethod("doSth", int.class);
ReflectDemo demo = new ReflectDemo();
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;
}
// 直接调用
demo.doSth(2333);
// 反射调用
// method.invoke(object, 2333);
}
}
}
```
取最后五个记录的平均值,作为预热后的峰值性能,一亿次的直接调用耗时为 94ms(macOS + JDK11)然后把 94 作为基准值。
改为反射调用,传入 2333 作为反射调用的参数,测得的结果约为基准值的 3.2 倍(301ms)。
除了反射调用外,还额外做了两个操作:
第一,由于 Method.invoke 是一个变长参数方法,在字节码层面它的最后一个参数会是 Object 数组。Java 编译器会在方法调用处生成一个长度为传入参数数量的 Object 数组,并将传入参数一一存储进该数组中。
第二,由于 Object 数组不能存储基本类型,Java 编译器会对传入的基本数据类型进行自动装箱。
这两个操作除了带来性能开销外,还可能占用堆内存,使得 GC 更加频繁。
使用 -Xlog:gc 参数,打印 GC 信息,可以看到在疯狂的 GC:
```
// java -Xlog:gc ReflectDemo.java
// ...
[7.671s][info][gc] GC(108) Pause Young (Normal) (G1 Evacuation Pause) 301M->2M(499M) 1.087ms
[7.736s][info][gc] GC(109) Pause Young (Normal) (G1 Evacuation Pause) 301M->2M(499M) 1.132ms
[7.819s][info][gc] GC(110) Pause Young (Normal) (G1 Evacuation Pause) 301M->2M(499M) 1.219ms
[7.891s][info][gc] GC(111) Pause Young (Normal) (G1 Evacuation Pause) 301M->2M(499M) 1.159ms
[7.960s][info][gc] GC(112) Pause Young (Normal) (G1 Evacuation Pause) 301M->2M(499M) 1.172ms
```
那么,如何消除这部分开销呢?
关于第二个自动装箱,Java 缓存了 [-128, 127] 中所有整数所对应的 Integer 对象。当需要自动装箱的整数在这个范围之内时,便返回缓存的 Integer,否则需要新建一个 Integer 对象。
因此,我们可以使用已经缓存的 Integer 对象或者扩大 Integer 对象。以这种方式测得的结果约为基准的 2.4 倍(222ms)。
现在我们再来看看因变长参数生成的 Object 数组,既然每个反射调用对应的参数个数是固定的,那么我们可以选择在循环外新建一个 Object 数组,设置好参数并直接交给反射调用,代码如下:
```java
public class ReflectDemo {
public void doSth(int i) {
}
public static void main(String[] args) throws Exception {
Class<?> clazz = ReflectDemo.class;
Constructor constructor = clazz.getConstructor();
Object object = constructor.newInstance();
Method method = clazz.getMethod("doSth", int.class);
// 在循环外构造参数数组
Object[] arg = new Object[1];
arg[0] = 2333;
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(object, arg);
}
}
}
```
测试的结果反而更加糟糕了,为基准值的 3.5 倍(331ms)。
再解决了自动装箱之后查看运行时的 GC 状况时,你会发现这段程序并不会触发 GC。其原因在于,原本的反射调用被内联了,从而使得即时编译器中的逃逸分析将原本新建的 Object 数组判断为不逃逸的对象。
如果一个对象不逃逸,那么即时编译器可以选择栈分配甚至是虚拟分配,也就是不占用堆空间。
如果在循环外新建数组,即时编译器无法确定这个数组会不会中途被更改,因此无法优化掉访问数组的操作,可谓是得不偿失。
前面我们提到过,可以关闭反射调用的 Inflation 机制,从而取消委派实现,直接使用动态实现。此外,每次反射调用都会检查目标方法权限,而这个检查同样可以在 Java 代码里关闭,在关闭了这权限检查机制之后,代码如下:
```java
public class ReflectDemo {
public void doSth(int i) {
}
public static void main(String[] args) throws Exception {
Class<?> clazz = ReflectDemo.class;
Constructor constructor = clazz.getConstructor();
Object object = constructor.newInstance();
Method method = clazz.getMethod("doSth", 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(object, 23);
}
}
}
```
测得的结果约为基准值的 2.2 倍(204ms)。
首先,在这个例子中,之所以反射调用能够变得那么快,主要是因为即时编译器中的方法内联。在关闭了 Inflation 的情况下,内联的瓶颈在于 Method.invoke 方法中对 MethodAccessor.invoke 方法的调用。
![Method.invoke.png](https://i.loli.net/2019/09/16/3ZhN8Of95zujI1P.png)
在生产环境中,我们往往拥有多个不同的反射调用,对应多个 GeneratedMethodAccessor,也就是动态实现。
由于 Java 虚拟机的关于上述调用点的类型 profile(注:对于 invokevirtual 或者 invokeinterface,Java 虚拟机会记录调用者的具体类型,我们称之为类型 profile)无法同时记录这么多个类,因此可能造成所测试的反射调用没有被内联的情况。
```java
public class ReflectDemo {
public void doSth(int i) {
}
public static void main(String[] args) throws Exception {
Class<?> clazz = ReflectDemo.class;
Constructor constructor = clazz.getConstructor();
Object object = constructor.newInstance();
Method method = clazz.getMethod("doSth", 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(object, 23);
}
}
public static void polluteProfile() throws Exception {
Class<?> clazz = ReflectDemo.class;
Constructor constructor = clazz.getConstructor();
Object object = constructor.newInstance();
Method method1 = clazz.getMethod("target1", int.class);
Method method2 = clazz.getMethod("target2", int.class);
for (int i = 0; i < 2000; i++) {
method1.invoke(object, 0);
method2.invoke(object, 0);
}
}
public void target1(int i) {
}
public void target2(int i) {
}
}
```
这时测试的结果为基准值的 7.2 倍(679ms)。也就是说,只要耽误了 Method.invoke 方法的类型 profile,性能开销便从 2.2 上升到 7.2 倍。
之所以这么慢,除了没有方法内联之外,另一个原因是逃逸分析不再生效。这个时候便可以在循环外构造参数数组,并直接传递给反射调用,这样子测的结果为基准值的 5.8 倍(548ms)。
除此之外,我们还可以提高 Java 虚拟机关于每个调用能够记录的类型数目(对应虚拟机参数 -XX:TypeProfileWidth,默认值为 2,这里设置为 8)。
#### 总结
影响反射调用耗时有以下原因:
1. 方法表查找
2. 构建 Object 数组以及可能存在的自动装拆箱操作
3. 运行时权限检查
4. 方法内联/逃逸分析
### 如何优化反射性能开销?
1. 尽量避免反射调用虚方法
2. 关闭运行时权限检查
3. 可能需要增大基本数据类型对应的包装类缓存
4. 关闭 Inflation 机制
5. 提高 JVM 关于每个调用能够记录的类型数目
### 参考

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Loading…
Cancel
Save