虽然Java的强类型及动态类型系统让你可以写出表述性强,健壮的应用程序,但是它限制了框架API与用户类型协作的能力。为此,出现了很多使用了反射API的开源库,但却破坏了类型安全,因此Java的反射API并不总是与用户类型交互的最佳方式。为了保留用户类型,最好使用了运行时代码生成的方式。
程序代码生成是Java平台的固有特性
当编译Java应用程序时,Java编译器为字节码服务而不是可执行程序。字节码是Java特有的格式,本身并没有太大用处。为了执行字节码,它在运行时由JVM的实时编译器(JIT)转换为本机代码。尽管大多数Java开发人员都听说过即时编译,但它是平台的强项之一,您不需要知道关于它的任何细节,而您仍然可以编写伟大的Java程序。
为什么我们需要运行时代码生成?
Java中的类型既strong 又是static。那让我们跳过普遍存在的“动态类型与静态类型”讨论,并假设我们通常喜欢强静态类型。这种类型的一个优点是它们的表现力。对于每个变量,我们可以立即告诉我们允许调用其值的方法。
只要我们不强制类型化编译代码的跟踪,静态类型在编译期间甚至在启动我们的应用程序之前都会发现很多编程错误。这种安全对我们来说很方便。然而,对于那些编写Java框架和库的人来说,有时同样不方便。静态类型化意味着应用程序只能对其明确知道的类型调用方法。但这与框架的概念相矛盾,框架希望提供独立于特定用户域的功能。对于您自己开发的公司内部框架,直接依赖于特定的域模型仍然是一种选择。
随着正在进行的POJO革命,许多现代Java库和框架通过在Java应用程序运行时定义按需类来实现它们的魔力。总之,检查您的业务应用程序的堆栈跟踪,您肯定会发现大量运行时生成的类。除了实时编译的代码之外,运行时生成的类是运行应用程序的一部分,因此也是您关注的问题。
Java reflection 的反思
Java反射API确实具有可感知的运行时开销。然而,这种开销主要是通过查找方法来调用的。尽管方法查找是在内部缓存的,但是方法类的协定要求任何实例都是可访问的,这使得这些实例是可变的。这需要任何方法查找来返回这些缓存方法对象的浅副本,这样我们至少要避免在循环中重复创建这些副本。
糟糕的是,选择这个实现,我们只是破坏了Java备受赞誉的类型安全性,而不用Java编译器警告我们。
我们发现Java反射是一种与用户代码交互的好方法,但它是以丢失类型安全为代价的。为了保持类型安全,也许最好在运行时使用代码生成来创建给定用户类的子类,对于这些类,我们重写实现框架逻辑的任何方法,而不需要用户依赖框架类型。
总之,假设Spring框架必须依赖于所有用户的域类型。虽然这在逻辑上是不可能的,但是框架的依赖关系图将与框架的实际目的相矛盾!它是依赖框架功能的第三方代码。它不应该是反过来的。运行时类定义并不意味着要从懒惰的类型中获得一些权重。运行时代码生成解决了Java类型系统的一个重大缺陷。
初识代码生成器
总的来说,好的API并不是一个优秀的代码生成库的唯一条件。代码库的运行时性能可能是一个更重要的因素,尤其是当生成的代码在运行的程序中处于一个比较关键的位置的时候。关于代码生成库的性能,坊间有着诸多传闻,不过我还没找到关于任何一项技术的靠谱的基准测试。
在Java中进行微基准测试并不是一件容易的事情 。如果你要测量一个指定的代码块的执行时间,你通常不知道你测量的到底是什么。Java代码在执行的时候,JIT编译器通常都会介入,最极端的情况下,它可能会擦除掉被测量的代码。
然而在过去的几年里,有几个聪明的家伙想出了一些办法来欺骗JIT编译器,并基于这些想法实现了一些微基准测试的库。我个人最喜欢的是Java Microbenchmarking Harness,它是随着Open JDK发布的一款工具。
在进行数据测量之前,有必要先回答一个问题:基准测试的目标和关注点是什么?很明显,有些任务使用某个库处理起来可能会更高效些,而另一些任务则可能花的时间就要更长一点。
除此之外,代码生成库通常会牺牲创建类的时间来减少生成类的方法调用的时间。当我们在讨论下面这些数据的时候,应当时刻牢记这点。
看下这些数据吧
在记住我们前面说的东西的同时,我们先来看一个直接比较不同任务的运行时间的JMH基准测试的原始数据。下表中的数据是指每个操作所需要的纳秒数,空格中是采样的标准误差。
Byte Buddy | cglib | javassist | JDK proxy | |||||
implement interface with stub methods | 153.800 | (0.394) | 804.000 | (1.899) | 706.878 | (4.929) | 973.650 | (1.624) |
invoke a sub method | 0.001 | (0.000) | 0.002 | (0.000) | 0.009 | (0.000) | 0.005 | (0.000) |
extend class with super method invocation | 172.126 | (0.533) | 1480.525 | (2.911) | 625.778 | (1.954) | – | |
2290.246 | (7.034) | |||||||
invoke a super method | 0.002 | (0.000) | 0.019 | (0.000) | 0.027 | (0.000) | – | |
0.003 | (0.000) |
第一行显示的是库生成这18个不同接口的空实现所需的时间。在这些生成的运行时类的基础之上,第二行显示的是调用这个生成类实例中的方法所需要的时间。
在这次测试中,Byte Buddy以及cglib的性能最好,因为这两个库你都能将返回值硬编码到生成类中,而javassist和JDK代理都只允许注册一个相应的回调函数。
这样我们可以得出第一个粗略的结论,这就是运行时类的方法实现越具体的话性能越好。听起来显然应该是这样,但其实不然,因为JIT编译器可能会优化这两种方法的性能。
类继承的情况如何
上表中的第三行显示的是继承一个包含18个方法的类所需要的时间。这次并不是创建一个方法存根,而是重写了方法,并调用了它父类的实现。
你可能已经注意到了,Byte Buddy列出了两个测量值,而第二个斜体的数字明显要更大。两个数值代表的是实现父方法调用的两种不同的实现方式。
正如上周所提到的,JVM只允许在同一个实例内进行父方法的调用。因此,调用super方法的最简单的方式就是在拦截方法里进行父方法的调用,这个拦截方法在第一次测试的时候已经实现好了。
但这个方法的灵活性不够,比方说它并不能根据条件来进行调用。为了克服这一限制,Byte Buddy允许你创建一个类似内部类的东西。在本文的前一部分中我们就介绍过了这种方法,在那篇文章中我们生成了一个实现了Callable接口的代理类。
对于任何调用而言,内部类的实例是通过方法中的一个参数所对应的注解注入到拦截方法里的。正如你所看到的,这种创建了一个额外的类的方式,跟其它使用相同策略的库相比,调用super方法所消耗的时间大大减少了。
与此同时,为每个方法生成一个专门的类会带来生成子类的额外开销。cglib和javassist都选择了一种折中的方案来解决这一问题,它们省掉了创建额外类的开销,代价就是每次父方法调用都会增加额外的开销。
结束语:都是为了提升性能
这里有许多值得讨论的东西,不过与此同时,这也是个结束这次代码生成简介的重要时刻。我希望这次概述能帮助你认识到代码生成其实并没有什么神秘的,这并不是只有大型框架才能使用的。有一个顺手的库的话,即使是很小的项目,你也可以使用代码生成来完成切面关注的漂亮的API,而不用增加显式的依赖关系。
现在Java 8已经开始逐渐流行起来,它的新的元空间不再严格限制Java应用包含的类的数量了。有了这些之后,就没有什么能再束缚住你的手脚了,放手去干吧。
这里不得不说Byte Buddy的作者讲的非常好。
引用资料