4年前 (2020-10-30)  Java系列 |   抢沙发  659 
文章评分 0 次,平均分 0.0

在编译时不知道类的情况下,从Java类读取getter的最快方法是什么?Java框架经常这样做——很多。而且,它可以直接影响他们的表现。所以,让我们对不同的方法进行基准测试,比如反射方法句柄代码生成

反射用例

假设我们有一个简单的Person类,它有一个名称和地址:

public class Person {
   ...

   public String getName() {...}
   public Address getAddress() {...}

}

我们希望使用其中一种框架,例如:

  • XStreamJAXBJackson将实例序列化为XML或JSON
  • JPA/Hibernate在数据库中存储人员
  • OptaPlanner指定地址(如果他们是游客或无家可归者)

Java反射性能测试,反射真的慢吗?

这些框架都不知道Person类。所以,他们不能简单地调用person.getName():

// Framework code
   public Object executeGetter(Object object) {
      // Compilation error: class Person is unknown to the framework
      return ((Person) object).getName();
   }

相反,代码使用反射、方法句柄或代码生成。

但是,这种代码被称为可怕的东西:

  • 如果Hibernate在一个不同的数据库中插入1000次,则可能会调用Hibernate/2000次:
  • 1000次调用Person.getName()
  • 再调用1000次的Person.getAddress()
  • 类似地,如果您向XML或JSON编写1000个不同的人,XStreamJAXBJackson可能会有2000个调用。

显然,当这样的代码每秒被调用x次时,它的性能很重要。

基准测试

使用JMH,我在一个64位8核inteli7-4790台式机上运行了一组微基准测试,使用openjdk1.8.0_111操作系统。JMH基准测试运行了三个fork,5次1秒的预热迭代,20次1秒的测量迭代。所有的预热成本都消失了;将迭代的长度增加到5秒对这里报告的数字几乎没有影响。

该基准测试的源代码可在GitHub存储库中找到。

http://github.com/ge0ffrey/ge0ffrey-presentations/tree/master/code/fasterreflection

TL;DR结果

  • Java反射很慢。
  • Java MethodHandles也很慢。
  • 生成的代码javax.tools.JavaCompiler很快。
  • LambdaMetafactory相当快。

注意:这些观察是基于我使用的工作负载进行基准测试的用例。您的里程数可能会有所不同!

所以,魔鬼就在细节上。让我们通过实现来确认我应用了典型的魔术技巧,比如setAccessible(true)

启动位置

直接访问:基线

我用了普通的person.getName()呼叫为基线:

public final class MyAccessor {

    public Object executeGetter(Object object) {
        return ((Person) object).getName();
    }
}

每次操作大约需要2.6纳秒:

Benchmark           Mode  Cnt  Score   Error  Units
===================================================
DirectAccess        avgt   60  2.590 ± 0.014  ns/op

直接访问自然是运行时最快的方法,无需启动开销。但是,它在编译时导入了Person,所以每个框架都不能使用它。

反射

对于一个框架来说,在运行时理解getter的最明显的方法是通过Java反射来理解它:

public final class MyAccessor {

  private final Method getterMethod;

  public MyAccessor() {

      getterMethod = Person.class.getMethod("getName");

      // Skip Java language access checking during executeGetter()

      getterMethod.setAccessible(true);

  }

  public Object executeGetter(Object bean) {

      return getterMethod.invoke(bean);

  }

}

添加setAccessible(true)调用可以使这些反射调用更快,但即使这样,每次调用也需要5.5纳秒。

Benchmark           Mode  Cnt  Score   Error  Units
===================================================
DirectAccess        avgt   60  2.590 ± 0.014  ns/op
Reflection          avgt   60  5.275 ± 0.053  ns/op

反射比直接访问慢104%,这意味着它的速度是直接访问的两倍。热身也需要更长的时间。

这对我来说并不奇怪,因为当我使用抽样和一个简单的旅行推销员问题来描述OptaPlanner的980个城市时,反射成本就像一个痛苦的拇指一样突出:

Java反射性能测试,反射真的慢吗?

方法句柄 MethodHandles

在Java 7中引入了对调用ED7指令的支持。根据Javadoc,它是对底层方法的类型化、直接可执行的引用。听起来很快,对吧?

public final class MyAccessor {

  private final MethodHandle getterMethodHandle;

  public MyAccessor() {

      MethodHandles.Lookup lookup = MethodHandles.lookup();

      // findVirtual() matches signature of Person.getName()
      getterMethodHandle = lookup.findVirtual(Person.class, "getName", MethodType.methodType(String.class))

          // asType() matches signature of MyAccessor.executeGetter().asType(MethodType.methodType(Object.class, Object.class));

  }

  public Object executeGetter(Object bean) {

     return getterMethodHandle.invokeExact(bean);

  }
}

不幸的是,在jdk中处理反射甚至比open8慢。每次操作需要6.1纳秒,因此比直接访问慢136%。

Benchmark           Mode  Cnt  Score   Error  Units
===================================================
DirectAccess        avgt   60  2.590 ± 0.014  ns/op
Reflection          avgt   60  5.275 ± 0.053  ns/op
MethodHandle        avgt   60  6.100 ± 0.079  ns/op

使用查找lookup.unreflectGetter(字段)而不是lookup.findVirtual(……)无显著性差异。我希望MethodHandle在未来的Java版本中能够像直接访问一样快。

静态方法句柄

我还用MethodHandle在静态字段中运行了一个基准测试。JVM可以在静态字段上发挥更大的作用。Aleksey和John O'Hara正确地指出,最初的基准测试没有正确地使用静态字段,所以我修复了这个问题。修正结果如下:

Benchmark           Mode  Cnt  Score   Error  Units
===================================================
DirectAccess        avgt   60  2.590 ± 0.014  ns/op
MethodHandle        avgt   60  6.100 ± 0.079  ns/op
StaticMethodHandle  avgt   60  2.635 ± 0.027  ns/op

是的,静态MethodHandle与直接访问一样快,但它仍然没有用,除非我们想编写这样的代码:

public final class MyAccessors {

 private static final MethodHandle handle1; // Person.getName()

 private static final MethodHandle handle2; // Person.getAge()

 private static final MethodHandle handle3; // Company.getName()

 private static final MethodHandle handle4; // Company.getAddress()

 private static final MethodHandle handle5; // ...

 private static final MethodHandle handle6;

 private static final MethodHandle handle7;

 private static final MethodHandle handle8;

 private static final MethodHandle handle9;

 ...

 private static final MethodHandle handle1000;

}

如果我们的框架处理一个有四个getter的域类层次结构,它将填充前四个字段。但是,如果它处理100个域类,每个类有20个getter,总计2000个getter它将由于缺少静态字段而崩溃

另外,如果我写这样的代码,即使是一年级的学生也会来告诉我我做得不对。静态字段不应用于实例变量。

通过 javax.tools.JavaCompiler 生成代码

在Java中,可以在运行时编译和运行生成的Java代码。所以和javax.tools.JavaCompilerAPI,我们可以在运行时生成直接访问代码:

public abstract class MyAccessor {

  // Just a gist of the code, the full source code is linked in a previous section

  public static MyAccessor generate() {

      final String String fullClassName = "x.y.generated.MyAccessorPerson$getName";

      final String source = "package x.y.generated;\n"

              + "public final class MyAccessorPerson$getName extends MyAccessor {\n"

              + "    public Object executeGetter(Object bean) {\n"

              + "        return ((Person) object).getName();\n"

              + "    }\n"

              + "}";

      JavaFileObject fileObject = new ...(fullClassName, source);

      JavaCompiler compiler = ToolProvider.getSystemJavaCompiler();

      ClassLoader classLoader = ...;

      JavaFileManager javaFileManager = new ...(..., classLoader)

      CompilationTask task = compiler.getTask(..., javaFileManager, ..., singletonList(fileObject));

      boolean success = task.call();

      ...

      Class compiledClass = classLoader.loadClass(fullClassName);

      return compiledClass.newInstance();

  }

  // Implemented by the generated subclass

  public abstract Object executeGetter(Object object);
}

完整的源代码要长得多,可以在GitHub存储库中使用。有关如何使用javax.tools.JavaCompiler,请参阅本文或本文的第2页。在Java8中,它需要工具.jar在类路径上,这在JDK安装中自动存在。在Java9中,它需要模块java.编译器在模块路径中。此外,还需要采取适当的谨慎措施,以免产生类列表.mf文件,并使用正确的类加载器。

此外javax.tools.JavaCompiler,类似的方法可以使用ASMCGLIB,但是这些方法推断Maven依赖关系,并且可能有不同的性能结果。

在任何情况下,生成的代码与直接访问一样快:

Benchmark           Mode  Cnt  Score   Error  Units
===================================================
DirectAccess        avgt   60  2.590 ± 0.014  ns/op
JavaCompiler        avgt   60  2.726 ± 0.026  ns/op

所以,当我在OptaPlanner中再次运行旅行推销员问题时,这次使用代码生成来访问计划变量,总的来说,分数计算速度快了18%。而且,分析(使用采样)看起来也更好:

Java反射性能测试,反射真的慢吗?

请注意,在正常的用例中,由于实际复杂的分数计算的大量CPU需求,性能增益几乎无法检测。

在运行时生成代码的一个缺点是,它会导致显著的引导开销,特别是如果生成的代码不是批量编译的话。所以,我仍然希望有朝一日MethodHandles能够像直接访问一样快,这样可以避免引导开销和依赖性痛苦。

LambdaMetafactory

Reddit上,我收到了一个使用LambdaMetafactory的有力建议:

Java反射性能测试,反射真的慢吗?

由于缺少文档和StackOverflow问题,让lambdametfactory处理非静态方法非常困难,但它确实有效:

public final class MyAccessor {

 private final Function getterFunction;

 public MyAccessor() {

     MethodHandles.Lookup lookup = MethodHandles.lookup();

     CallSite site = LambdaMetafactory.metafactory(lookup,

             "apply",

             MethodType.methodType(Function.class),

             MethodType.methodType(Object.class, Object.class),

             lookup.findVirtual(Person.class, "getName", MethodType.methodType(String.class)),

             MethodType.methodType(String.class, Person.class));

     getterFunction = (Function) site.getTarget().invokeExact();

 }

 public Object executeGetter(Object bean) {

     return getterFunction.apply(bean);

 }
}

而且,看起来不错:LambdaMetafactory几乎和直接访问一样快。它只比直接访问慢33%,比反射好得多。

Benchmark           Mode  Cnt  Score   Error  Units
===================================================
DirectAccess        avgt   60  2.590 ± 0.014  ns/op
Reflection          avgt   60  5.275 ± 0.053  ns/op
LambdaMetafactory   avgt   60  3.453 ± 0.034  ns/op

当我在OptaPlanner中再次运行旅行推销员问题时,这次使用lambdametafactor访问计划变量时,总分计算速度比以前快了9%。但是,分析(使用采样)仍然显示了很多executeGetter()时间,但仍然小于反射时间。

在非科学的测量中,元空间成本似乎是每lambda约2kb,它通常会被垃圾收集。

引导成本

运行时成本最重要,因为每秒检索数千个实例的getter并不少见。但是,引导成本也很重要,因为我们需要为域层次结构中的每个getter创建一个MyAccessor,例如Person.getName(), Person.getAddress(), Address.getStreet(), Address.getCity().

反射和方法句柄具有可忽略的引导成本。对于LambdaMetafactory,它仍然可以接受。我的机器每秒创建大约25k访问器。但是对于Java编译器,它不是——我的机器每秒只创建200个访问器。

Benchmark                    Mode  Cnt        Score        Error  Units
=======================================================================
Reflection Bootstrap         avgt   60      268.510 ±     25.271  ns/op //    0.3µs/op
MethodHandle Bootstrap       avgt   60     1519.177 ±     46.644  ns/op //    1.5µs/op
JavaCompiler Bootstrap       avgt   60  4814526.314 ± 503770.574  ns/op // 4814.5µs/op
LambdaMetafactory Bootstrap  avgt   60    38904.287 ±   1330.080  ns/op //   39.9µs/op

这个基准测试不做缓存或大容量复杂化。

结论

在本次调查中,反射和(可用)MethodHandles的速度是openjdk8中直接访问的两倍。生成的代码和直接访问一样快,但这很痛苦。LambdaMetafactory几乎和直接访问一样快。

Benchmark           Mode  Cnt  Score   Error  Units
===================================================
DirectAccess        avgt   60  2.590 ± 0.014  ns/op
Reflection          avgt   60  5.275 ± 0.053  ns/op // 104% slower
MethodHandle        avgt   60  6.100 ± 0.079  ns/op // 136% slower
StaticMethodHandle  avgt   60  2.635 ± 0.027  ns/op //   2% slower
JavaCompiler        avgt   60  2.726 ± 0.026  ns/op //   5% slower
LambdaMetafactory   avgt   60  3.453 ± 0.034  ns/op //  33% slower
 

除特别注明外,本站所有文章均为老K的Java博客原创,转载请注明出处来自https://javakk.com/733.html

关于

发表评论

表情 格式

暂无评论

登录

忘记密码 ?

切换登录

注册