JVM系列之:JVM是如何实现反射的

简介

Java 反射机制是在运行状态中,对于任意一个类,都能够知道这个类的所有属性和方法;对于任意一个对象,都能够调用它的任意一个方法和属性;这种动态获取信息以及动态调用对象的方法的功能称为 Java 语言的反射机制。

在 Java 环境中运行时,对于任意一个类,能否知道这个类有哪些属性和方法。对于任意一个对象,能否调用它的任意一个方法。

Java 反射机制主要提供了以下功能:

  • 在运行时判断任意一个对象所属的类。
  • 在运行时构造任意一个类的对象。
  • 在运行时判断任意一个类所具有的成员变量和方法。
  • 在运行时调用任意一个对象的方法。

反射的作用

  1. 动态的加载类,动态的获取类的信息(属性,方法,构造器)
  2. 动态的构造对象
  3. 动态调用类和对象的任意方法,构造器
  4. 获取泛型信息
  5. 处理注解
  6. 动态代理

反射调用的实现

源码分析

首先我们来看一个反射代码示例:

public class Student {
  private String name;
  private int age;

  public Student(){
    System.out.println("构造方法");
  }

  private Student(int age){
    this.age = age;
    System.out.println(age);
  }

  public void print(int num) {
    System.out.println("第" + num + "次,测试输出");
  }
}

public class ReflectionTest {

  public static void main(String[] args)
      throws IllegalAccessException, InvocationTargetException, InstantiationException, NoSuchMethodException {
    Class c = Student.class;

    Constructor<Student> constructor = c.getConstructor();
    Student student = constructor.newInstance();

    Method method = c.getDeclaredMethod("print",int.class);
    method.setAccessible(true);
    method.invoke(student,1);
  }
}

上述代码我们通过反射来实现方法调用,即 Method.invoke,那么它是怎么实现的呢?通过查看源码可知:

public final class Method extends Executable {
  ....
     @CallerSensitive
    public Object invoke(Object obj, Object... args)
        throws IllegalAccessException, IllegalArgumentException,
           InvocationTargetException
    {
        if (!override) {
            if (!Reflection.quickCheckMemberAccess(clazz, modifiers)) {
                Class<?> caller = Reflection.getCallerClass();
                checkAccess(caller, clazz, obj, modifiers);
            }
        }
        MethodAccessor ma = methodAccessor;             // read volatile
        if (ma == null) {
            ma = acquireMethodAccessor();
        }
        return ma.invoke(obj, args);
    } 
  ....
}

继续向下阅读源码,可知创建 MethodAccessor 实例的是 ReflectionFactory,核心代码为:

public class ReflectionFactory {
  private static boolean noInflation = false;
  private static int inflationThreshold = 15;

  private static void checkInitted() {
    if (!initted) {
      AccessController.doPrivileged(new PrivilegedAction<Void>() {
        public Void run() {
          if (System.out == null) {
            return null;
          } else {
            String var1 = System.getProperty("sun.reflect.noInflation");
            if (var1 != null && var1.equals("true")) {
              ReflectionFactory.noInflation = true;
            }

            var1 = System.getProperty("sun.reflect.inflationThreshold");
            if (var1 != null) {
              try {
                ReflectionFactory.inflationThreshold = Integer.parseInt(var1);
              } catch (NumberFormatException var3) {
                throw new RuntimeException("Unable to parse property sun.reflect.inflationThreshold", var3);
              }
            }

            ReflectionFactory.initted = true;
            return null;
          }
        }
      });
    }
  }  

  public MethodAccessor newMethodAccessor(Method var1) {
    checkInitted();
    if (noInflation && !ReflectUtil.isVMAnonymousClass(var1.getDeclaringClass())) {
      return (new MethodAccessorGenerator()).generateMethod(var1.getDeclaringClass(), var1.getName(), var1.getParameterTypes(), var1.getReturnType(), var1.getExceptionTypes(), var1.getModifiers());
    } else {
      NativeMethodAccessorImpl var2 = new NativeMethodAccessorImpl(var1);
      DelegatingMethodAccessorImpl var3 = new DelegatingMethodAccessorImpl(var2);
      var2.setParent(var3);
      return var3;
    }
  }
}

上述测试用例结合源码分析,我们可以知道下述这些内容:

MethodAccessor 实现有两个版本,一个是 Java 实现的,另一个是 native code 实现的。Java 实现的版本在初始化时需要较多时间,但长久来说性能较好;native 版本正好相反,启动时相对较快,但运行时间长了之后速度就比不过 Java 版了。

这里引申出一个新的概念:inflation 机制。具体指:当反射被频繁调用时,动态生成一个类来做直接调用的机制,可以加速反射调用。

MethodAccessor 实现为什么会有两个版本?

主要是对性能的权衡,JDK1.4 开始引入如下优化措施:让 Java 方法在被反射调用时,开头若干次使用 native 版,等反射调用次数超过阈值时则生成一个专用的 MethodAccessor 实现类,生成其中的 invoke()方法的字节码,以后对该 Java 方法的反射调用就会使用 Java 版。

上面看到了 ReflectionFactory.newMethodAccessor()生产MethodAccessor的逻辑,在“开头若干次”时用到的DelegatingMethodAccessorImpl代码如下:

class DelegatingMethodAccessorImpl extends MethodAccessorImpl {
  private MethodAccessorImpl delegate;

  DelegatingMethodAccessorImpl(MethodAccessorImpl var1) {
    this.setDelegate(var1);
  }

  public Object invoke(Object var1, Object[] var2) throws IllegalArgumentException, InvocationTargetException {
    return this.delegate.invoke(var1, var2);
  }

  void setDelegate(MethodAccessorImpl var1) {
    this.delegate = var1;
  }
}

该方法相当于一个转换层,方便在 native 与 Java 版的 MethodAccessor 之间实现切换。

接着来看一看 NativeMethodAccessorImpl 实现:

class NativeMethodAccessorImpl extends MethodAccessorImpl {
  private final Method method;
  private DelegatingMethodAccessorImpl parent;
  private int numInvocations;

  NativeMethodAccessorImpl(Method var1) {
    this.method = var1;
  }

  public Object invoke(Object var1, Object[] var2) throws IllegalArgumentException, InvocationTargetException {
    if (++this.numInvocations > ReflectionFactory.inflationThreshold() && !ReflectUtil.isVMAnonymousClass(this.method.getDeclaringClass())) {
      MethodAccessorImpl var3 = (MethodAccessorImpl)(new MethodAccessorGenerator()).generateMethod(this.method.getDeclaringClass(), this.method.getName(), this.method.getParameterTypes(), this.method.getReturnType(), this.method.getExceptionTypes(), this.method.getModifiers());
      this.parent.setDelegate(var3);
    }

    return invoke0(this.method, var1, var2);
  }

  void setParent(DelegatingMethodAccessorImpl var1) {
    this.parent = var1;
  }

  private static native Object invoke0(Method var0, Object var1, Object[] var2);
}

每次 NativeMethodAccessorImpl.invoke()方法被调用时,都会增加一个调用次数计数器,看超过阈值 inflationThreshold 没有;一旦超过,则调用 MethodAccessorGenerator.generateMethod()来生成 Java 版的 MethodAccessor 的实现类,并且改变 DelegatingMethodAccessorImpl 所引用的 MethodAccessor 为 Java 版。后续经由 DelegatingMethodAccessorImpl.invoke() 调用到的就是Java版的实现了。

我们简单看一下 MethodAccessorGenerator 的 generateMethod 方法实现:

JVM系列之:JVM是如何实现反射的_第1张图片

该方法主要是通过 ClassFileAssembler 类来生成字节码,这种方式进行方法调用效率比 native 高,但换来的是要生成 class 文件,相当于拿空间换时间。不仅如此,由于生成字节码十分耗时,仅调用一次的话,反而是 native 实现要快上 3 到 4 倍。所以才会有个阈值限定,超过该阈值才采用字节码的方式。

该方法中的 generateName 方法会生成字节码文件的文件名和路径。

private static synchronized String generateName(boolean var0, boolean var1) {
    int var2;
    if (var0) {
      if (var1) {
        var2 = ++serializationConstructorSymnum;
        return "sun/reflect/GeneratedSerializationConstructorAccessor" + var2;
      } else {
        var2 = ++constructorSymnum;
        return "sun/reflect/GeneratedConstructorAccessor" + var2;
      }
    } else {
      var2 = ++methodSymnum;
      return "sun/reflect/GeneratedMethodAccessor" + var2;
    }
  }

那么如何查看 GeneratedMethodAccessor1.class 呢?

这里简单介绍一下如何操作,详细内容可以参考我的上篇文章,使用如下命令:

$ cd /Library/Java/JavaVirtualMachines/jdk-9.0.4.jdk/Contents/Home/bin/
$ jhsdb hsdb

打开 hsdb 可视化窗口后,在命令行窗口执行 jps 命令查看 pid。点击 File->Attach to…输入pid 点击 Tools -> Class Browser GeneratedMethodAccessor 点击 save class。(关于 hsdb 的使用,后续会有文章来介绍,详细记录了个人的踩坑经历)

最终会在 jdk/bin 目录下生成一个名为 jdk 的文件夹,在里面存放了 GeneratedMethodAccessor1.class。

使用 javap 查看字节码内容,如下图所示:

JVM系列之:JVM是如何实现反射的_第2张图片

具体源码为:

package jdk.internal.reflect;

import InvokeTest;
import java.lang.reflect.InvocationTargetException;

public class GeneratedMethodAccessor1 extends MethodAccessorImpl {
  public Object invoke(Object paramObject, Object[] paramArrayOfObject) throws InvocationTargetException {
    try {
      if (paramArrayOfObject.length != 1)
        throw new IllegalArgumentException(); 
      Object object = paramArrayOfObject[0];
      if (object instanceof Integer) {
      
      } else {
        throw new IllegalArgumentException();
      } 
      try {
        InvokeTest.printException((object instanceof Byte) ? ((Byte)object).byteValue() : ((object instanceof Character) ? ((Character)object).charValue() : ((object instanceof Short) ? ((Short)object).shortValue() : "JD-Core does not support Kotlin")));
        return null;
      } catch (Throwable throwable) {
        throw new InvocationTargetException(null);
      } 
    } catch (ClassCastException|NullPointerException classCastException) {
      throw new IllegalArgumentException(null.toString());
    } 
  }
}

测试案例

案例一

首先从类加载的层面来看个 demo。

    Class c = Student.class;

    Constructor<Student> constructor = c.getConstructor();
    Student student = constructor.newInstance();

    Method method = c.getDeclaredMethod("print", int.class);
    method.setAccessible(true);
    for (int i = 0; i < 18; i++) {
      method.invoke(student, i + 1);
    }

来我们加上虚拟机参数 -verbose:class 启动执行如下:

JVM系列之:JVM是如何实现反射的_第3张图片

我们发现执行到第 15 次的时候会又多加载一部分类,这说明从第前 15 次和后面的反射调用方式是不同的。

上述测试用例结合源码分析,我们可以知道下述这些内容:

MethodAccessor 实现有两个版本,一个是 Java 实现的,另一个是 native 实现的。需要注意的是inflationThreshold 的值是15,也就是说前15次是使用的 native 版本,之后使用的是 java 版本。

可以在启动命令里加上 -Dsun.reflect.noInflation=true,就会 RefactionFactorynoInflation 属性就变成 true 了,这样不用等到 15 次调用后,程序一开始就会用 java 版的 MethodAccessor 了。

或者换种测试方法,在启动命令里加上 -Dsun.reflect.inflationThreshold=10 -verbose:class,执行后可以发现在第10次输出后就会多加载一部分类。

案例二

在上文学习 JVM 处理异常时我们提到栈轨迹这个新概念,该操作会逐一访问当前线程的 Java 栈帧,并且记录下各种调试信息,包括栈帧所指向方法的名字,方法所在的类名、文件名,以及在代码中的第几行触发该异常。

接下来我们将利用异常和栈轨迹来演示反射具体的调用逻辑,查看如下 demo。

//不要包名
public class InvokeTest {

  public static void printException(int num) {
    new Exception("#" + num).printStackTrace();
  }

  public static void main(String[] args)
      throws ClassNotFoundException, NoSuchMethodException, InvocationTargetException, IllegalAccessException {
    Class<?> cl = Class.forName("InvokeTest");
    Method method = cl.getMethod("printException", int.class);
    method.invoke(null, 1);
  }
}

//接着执行编译和解析命令,JDK8
$ javac InvokeTest.java
$ java InvokeTest   
  java.lang.Exception: #1
        at InvokeTest.printException(InvokeTest.java:14)
        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 InvokeTest.main(InvokeTest.java:21)

可以看到,反射调用先是调用了 Method.invoke,然后进入 Java 实现(DelegatingMethodAccessorImpl),再然后进入本地实现(NativeMethodAccessorImpl),最后到达目标方法。

这两个实现类我们在上文源码分析已经讲述过,这里就不做过多叙述了。

结合源码分析,当反射连续调用超过 15次,类加载过程会有变化,那么调用链路会发生什么改变呢?查看如下 demo。

public class InvokeTest {

  public static void printException(int num) {
    new Exception("#" + num).printStackTrace();
  }

  public static void main(String[] args)
      throws ClassNotFoundException, NoSuchMethodException, InvocationTargetException, IllegalAccessException {
    Class<?> cl = Class.forName("InvokeTest");
    Method method = cl.getMethod("printException", int.class);
    for (int i = 1; i < 20; i++) {
      method.invoke(null, i);
    }
  }
} 

//接着执行编译和解析命令,JDK8
$ javac InvokeTest.java
$ java InvokeTest   
 
....
  java.lang.Exception: #15
        at InvokeTest.printException(InvokeTest.java:14)
        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 InvokeTest.main(InvokeTest.java:22)
java.lang.Exception: #16
        at InvokeTest.printException(InvokeTest.java:14)
        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 InvokeTest.main(InvokeTest.java:22)
java.lang.Exception: #17
        at InvokeTest.printException(InvokeTest.java:14)
        at sun.reflect.GeneratedMethodAccessor1.invoke(Unknown Source)
        at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
        at java.lang.reflect.Method.invoke(Method.java:498)
        at InvokeTest.main(InvokeTest.java:22)

在上述结果中我们可以发现,当第 17次调用时才使用 GeneratedMethodAccessor1 字节码文件,在案例一中我们发现在第 15次和第 16次之间的类加载多了很多内容,那么在案例二中为什么不是第 16次使用字节码文件呢?

前 15次调用的是 NativeMethodAccessorImpl 文件中的 invoke 方法。

  public Object invoke(Object var1, Object[] var2) throws IllegalArgumentException, InvocationTargetException {
    if (++this.numInvocations > ReflectionFactory.inflationThreshold() && !ReflectUtil.isVMAnonymousClass(this.method.getDeclaringClass())) {
      MethodAccessorImpl var3 = (MethodAccessorImpl)(new MethodAccessorGenerator()).generateMethod(this.method.getDeclaringClass(), this.method.getName(), this.method.getParameterTypes(), this.method.getReturnType(), this.method.getExceptionTypes(), this.method.getModifiers());
      this.parent.setDelegate(var3);
    }

    return invoke0(this.method, var1, var2);
  }

如果 debug 调试一下的话,可以发现当第 16次调用时,if 判断条件为 true,条件方法体中的代码实现和那些多出来的类有关,当类加载完毕后,会继续执行下面的 invoke0 方法,所以案例二中第 16次方法调用链路与前15次没有差异,等到来到第 17次,根本才会使用上次生成的字节码文件。

下图是 debug 调试过程中,第 17次调用的内容显示:

JVM系列之:JVM是如何实现反射的_第4张图片

反射调用的开销

在上述测试案例二中,我们先后进行了 Class.forName,Class.getMethod 以及 Method.invoke 三个操作。其中,Class.forName 会调用本地方法,Class.getMethod 则会遍历该类的公有方法。如果没有匹配到,它还将遍历父类的公有方法。可想而知,这两个操作都非常费时。

值得注意的是,通过查看源码可知,以 getMethod 为代表的查找方法操作,会执行 copyMethod 来拷贝结果。因此,我们应当避免在热点代码中使用返回 Method 数组的 getMethods 或者 getDeclaredMethods 方法,以减少不必要的堆空间消耗。

如上述案例所示,Class.forName 和 Class.getMethod 本身使用次数并不多,在实践中,我们往往会在应用程序中缓存 Class.forName 和 Class.getMethod 的结果。因此,下面我就只关注反射调用本身的性能开销。

接下来我们通过最直观(并不严谨)的方式来比较直接调用和反射调用的性能差距.它会将反射调用循环二十亿次。此外,它还将记录下每跑一亿次的时间。(测试机为 Mac,JDK8)

将取最后五个记录的平均值,作为预热后的峰值性能。

首先我们来执行直接调用所需耗时。

public class InvokeCapabilityTest {

  public static void target(int i) { // 空方法
  }

  public static void main(String[] args) throws Exception {
    normalTest();
  }

  public static void normalTest() {
    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;
      }
      InvokeCapabilityTest.target(128);
    }
  }
}

最后得出的结果是一亿次直接调用耗费的时间大约在为 95 ms,如果注释 InvokeCapabilityTest.target(128),执行上述代码,发现输出的时间大概也是 95ms。其原因在于这段代码属于热循环,同样会触发即时编译。并且,即时编译会将对 Test.target 的调用内联进来(方法内联指的是编译器在编译一个方法时,将某个方法调用的目标方法也纳入编译范围内,并用其返回值替代原方法调用这么个过程。后续会专门介绍内联这一知识),从而消除了调用的开销。

接着执行反射调用的耗时。

public class InvokeCapabilityTest {

  public static void target(int i) { // 空方法
  }

  public static void main(String[] args) throws Exception {
    invokeTest();
  }

  public static void invokeTest() throws Exception {
    Class<?> klass = Class.forName("com.msdn.java.hotspot.invoke.InvokeCapabilityTest");
    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);
    }
  }
}

反射调用耗时大约为 275ms,测得的结果约为 2.9倍。我们来看一下反射调用前的字节码操作。

 				50: getstatic     #12                 // Field java/lang/System.out:Ljava/io/PrintStream;
        53: lload         5
        55: lload_2
        56: lsub
        57: invokevirtual #13                 // Method java/io/PrintStream.println:(J)V
        60: lload         5
        62: lstore_2
        63: aload_1
        64: aconst_null
        65: iconst_1
        66: anewarray     #14                 // class java/lang/Object
        69: dup
        70: iconst_0
        71: sipush        128
        74: invokestatic  #15                 // Method java/lang/Integer.valueOf:(I)Ljava/lang/Integer;
        77: aastore
        78: invokevirtual #16                 // Method java/lang/reflect/Method.invoke:(Ljava/lang/Object;[Ljava/lang/Object;)Ljava/lang/Object;
        81: pop

分析结果如下:

1、查看源码可知 Method.invoke 是一个变长参数方法,在字节码层面它的最后一个参数会是 Object 数组。Java 编译器会在方法调用处生成一个长度为传入参数数量的 Object 数组,并将传入参数一一存储进该数组中。

2、由于 Object 数组不能存储基本类型,Java 编译器会对传入的基本类型参数进行自动装箱。

反射调用前的字节码操作除了带来性能开销外,还可能占用堆内存,使得 GC 更加频繁。我们可以修改 JVM 参数为 -XX:+PrintGCDetails -XX:+PrintGCDateStamps,然后执行程序查看程序 GC 日志,这里只截取部分输出结果:

2022-01-13T21:57:14.880-0800: [GC (Allocation Failure) [PSYoungGen: 267264K->0K(257536K)] 268202K->938K(432640K), 0.0004327 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
2022-01-13T21:57:14.926-0800: [GC (Allocation Failure) [PSYoungGen: 257024K->0K(247296K)] 257962K->938K(422400K), 0.0004015 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
2022-01-13T21:57:14.972-0800: [GC (Allocation Failure) [PSYoungGen: 246784K->0K(237568K)] 247722K->938K(412672K), 0.0006462 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
2022-01-13T21:57:15.017-0800: [GC (Allocation Failure) [PSYoungGen: 237056K->0K(285184K)] 237994K->938K(460288K), 0.0006034 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
2022-01-13T21:57:15.070-0800: [GC (Allocation Failure) [PSYoungGen: 284672K->0K(273920K)] 285610K->938K(449024K), 0.0004569 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
2022-01-13T21:57:15.120-0800: [GC (Allocation Failure) [PSYoungGen: 273408K->0K(263168K)] 274346K->938K(438272K), 0.0005023 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
287

简要介绍一下输出内容:

1、在方括号中”PSYoungGen:”后面的”267264K->0K(257536K)”代表的是”GC前该内存区域已使用的容量->GC后该内存区域已使用的容量(该内存区域总容量)”

2、在方括号之外的”268202K->938K(432640K)”代表的是”GC前Java堆已使用容量->GC后Java堆已使用容量(Java堆总容量)”

3、再往后的”0.0004327 secs”代表该内存区域GC所占用的时间,单位是秒。

性能优化一

针对上述代码,我们可以如何修改,从而提升性能,减少 GC 的频繁调用呢?

在已知 invoke 方法第二个参数为 Object 数组,Java 会对传入的基本数据类型进行自动装箱的前提下,我们尝试避免在循环体内多次对基本数据类型进行装箱处理。

关于 int 转 Integer 的操作,Java 缓存了[-128, 127]中所有整数所对应的 Integer 对象。当需要自动装箱的整数在这个范围之内时,便返回缓存的 Integer,否则需要新建一个 Integer 对象。

还是上述代码示例,如果我们将 128 放在循环外定义,如下所示:

long current = System.currentTimeMillis();
Integer num = new Integer(128);
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, num);
}

上述优化后的代码测得的结果大约为 222ms。

通过上述措施对性能有一点优化,接着查看修改后的代码的 GC 日志。最后发现程序不会触发 GC,为什么呢?网上的说法是这样的,原本的反射调用被内联了,从而使得即时编译器中的逃逸分析将原本新建的 Object 数组判定为不逃逸的对象。如果一个对象不逃逸,那么即时编译器可以选择栈分配甚至是虚拟分配,也就是不占用堆空间。但是,个人并不认同这种说法,首先 JDK8 默认会开启逃逸分析,如果关闭逃逸分析,那么不管是哪种方式都会触发 GC的,方法内联与逃逸分析并无绝对的因果关系。接下来我们再来看两个案例,主要是针对 invoke 第二个参数的赋值处理。

情况一:

//修改 JVM参数-Djava.lang.Integer.IntegerCache.high=128
method.invoke(null, 128);

该种情况下,测试得到的结果大约为 290ms,再输出 GC 日志,结果发现并没有触发 GC,为什么呢?因为增加 Integer 的缓存范围,避免了在循环中重复创建 Integer 对象。

情况二:

long current = System.currentTimeMillis();
Object[] args = new Object[1];
args[0] = 128;
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, args);
}

这种情况是在在循环体外自定义好数组对象,当作 invoke 的参数, 时耗大约为 340ms,性能反而更糟糕了。注意,这里也没有触发 GC。为什么呢?编译器在进行代码分析时,无法确定这个数组会不会中途被更改,因此无法优化掉访问数组的操作,导致性能变差。不过因为已经在循环体外创建了对象,所以并没有触发 GC。

关于这三种情况性能和 GC 的分析大致如此,对此进行一下总结:

1、基于如下前提:如果一个对象不逃逸,那么即时编译器可以选择栈分配甚至是虚拟分配,也就是不占用堆空间。(如果不存在逃逸行为,即时编译器可以对该对象进行如下优化:同步消除、标量替换和栈上分配。关于逃逸行为的分析基有两种:方法逃逸和线程逃逸,这里就不详细介绍了。)

2、不管是否发生逃逸,如果循环中不需要频繁创建对象,那么是否占用堆空间,都不会触发 GC的。

3、方法内联可以提升性能,方法内联和逃逸分析没有绝对的因果关系。

性能优化二

在前文我们提到 Method.invoke 方法基于 MethodAccessor 有两种实现方式:一个是 Java 实现的,另一个是 native code 实现的。长久来说,Java 生成字节码的方式性能更好。在我们演示案例中有介绍到 -Dsun.reflect.noInflation=true 参数,使用该参数,则不需要等到第17次反射调用才使用字节码文件,而是一开始就使用字节码文件。

此外,每次反射调用都会检查目标方法的权限,在 Java 正常的方法调用是不会检查权限的。

那么我们接着优化一进行优化。

//JVM参数配置:-Dsun.reflect.noInflation=true

Class<?> klass = Class.forName("com.msdn.java.hotspot.invoke.InvokeCapabilityTest");
Method method = klass.getMethod("target", int.class);
method.setAccessible(true);// 关闭权限检查
long current = System.currentTimeMillis();
Integer num = new Integer(128);
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, num);
}

执行上述代码,测得的结果大约为 151ms,性能进一步提升。

至此,关于反射调用的优化措施全部介绍完毕,接下来我们对反射调用的性能开销和对应的优化措施进行总结。

1、Method.invoke 中的第二个参数是一个可以变长度的 Object 数组,数组中存放的都是对象类型。如果我们存入参数是基本类型,可以提前装箱,减少性能损耗。

2、可以关闭反射调用的 inflation 机制(具体操作为增加 JVM参数:-Dsun.reflect.noInflation=true ),从而取消本地实现,并且直接使用 Java 字节码实现。注意:如果反射调用次数过低,则没必要关闭 inflation 机制。

3、每次反射调用都会检查目标方法的权限,而这个检查同样可以在 Java 代码里关闭。

扩展

通常来说,使用反射 API 的第一步便是获取 Class 对象。在 Java 中常见的有这么三种。

  1. 使用静态方法 Class.forName 来获取。
  2. 调用对象的 getClass() 方法。
  3. 直接用类名 +“.class”访问。对于基本类型来说,它们的包装类型(wrapper classes)拥有一个名为“TYPE”的 final 静态字段,指向该基本类型对应的 Class 对象。

除此之外,Class 类和 java.lang.reflect 包中还提供了许多返回 Class 对象的方法。例如,对于数组类的 Class 对象,调用 Class.getComponentType() 方法可以获得数组元素的类型。

一旦得到了 Class 对象,我们便可以正式地使用反射功能了。下面我列举了较为常用的几项。

  1. 使用 newInstance() 来生成一个该类的实例。它要求该类中拥有一个无参数的构造器。
  2. 使用 isInstance(Object) 来判断一个对象是否该类的实例,语法上等同于 instanceof 关键字(JIT 优化时会有差别,我会在本专栏的第二部分详细介绍)。
  3. 使用 Array.newInstance(Class,int) 来构造该类型的数组。
  4. 使用 getFields()/getConstructors()/getMethods() 来访问该类的成员。除了这三个之外,Class 类还提供了许多其他方法,详见[4]。需要注意的是,方法名中带 Declared 的不会返回父类的成员,但是会返回私有成员;而不带 Declared 的则相反。

当获得了类成员之后,我们可以进一步做如下操作。

  1. 使用 Constructor/Field/Method.setAccessible(true) 来绕开 Java 语言的访问限制。
  2. 使用 Constructor.newInstance(Object[]) 来生成该类的实例。
  3. 使用 Field.get/set(Object) 来访问字段的值。
  4. 使用 Method.invoke(Object, Object[]) 来调用方法。

字节码使用附录

打印内联决策。这使您可以查看哪些方法被内联。

-XX:+UnlockDiagnosticVMOptions -XX:+PrintInlining

示例:

sun.reflect.GeneratedMethodAccessor1::invoke (124 bytes)
  @ 84   java.lang.Integer::intValue (5 bytes)   accessor
    @ 98   com.msdn.java.hotspot.invoke.InvokeCapabilityTest::target (1 bytes)   inline (hot)
      !              @ 6   sun.reflect.GeneratedMethodAccessor1::invoke (124 bytes)   inline (hot)
        \-> TypeProfile (6243/6243 counts) = sun/reflect/GeneratedMethodAccessor1
        @ 84   java.lang.Integer::intValue (5 bytes)   accessor
          @ 98   com.msdn.java.hotspot.invoke.InvokeCapabilityTest::target (1 bytes)   inline (hot)
            @ 15   sun.reflect.Reflection::quickCheckMemberAccess (10 bytes)   inline (hot)
              @ 1   sun.reflect.Reflection::getClassAccessFlags (0 bytes)   (intrinsic)
                @ 6   java.lang.reflect.Modifier::isPublic (12 bytes)   inline (hot)
                  @ 56   sun.reflect.DelegatingMethodAccessorImpl::invoke (10 bytes)   inline (hot)
                    \-> TypeProfile (5458/5458 counts) = sun/reflect/DelegatingMethodAccessorImpl
                    !                @ 6   sun.reflect.GeneratedMethodAccessor1::invoke (124 bytes)   inline (hot)
                      \-> TypeProfile (6161/6161 counts) = sun/reflect/GeneratedMethodAccessor1
                      @ 84   java.lang.Integer::intValue (5 bytes)   accessor
                        @ 98   com.msdn.java.hotspot.invoke.InvokeCapabilityTest::target (1 bytes)   inline (hot)

如上述结果中的 inline,即表示 target 被内联到 GeneratedMethodAccessor1 的 invoke 方法中。

逃逸分析的相关命令

  • 开启逃逸分析:-XX:+DoEscapeAnalysis,在 JDK1.8 中是默认开启的
  • 关闭逃逸分析:-XX:-DoEscapeAnalysis
  • 显示分析结果:-XX:+PrintEscapeAnalysis

GC日志输出命令

-verbose:gc 是稳定版本

-XX:+PrintGC 是非稳定版本

两者功能一样,都用于垃圾收集时的信息打印。

参考文献

GC日志查看分析

求你了,GC 日志打印别再瞎配置了

详解 JVM 逃逸分析

逃逸分析

深入理解Java虚拟机:(十三)方法内联

你可能感兴趣的:(深入学习JVM,java,jvm,java虚拟机)