如何让Groovy像Java一样快


介绍

作为一种可选类型的基于JVM的语言,Groovy提供的语法和构造特性使各种工具和应用程序的开发更加高效。Groovy的缺点是类型检查和转换的运行时开销,以及某些其他Groovy魔力。对于较低级别或频繁运行的代码,在某些情况下最好用纯Java编写,但是对于大多数代码,Groovy都有使您的代码运行得与Java一样快的选项。

本文中的技巧基于我编写和优化Moqui框架的经验,该框架目前大约有40k行Groovy代码。在普通的Java中,代码的大小很容易达到原来的两倍或三倍,而且通常也要复杂得多。除了框架API之外,Moqui最初是用100%Groovy编写的,框架API主要是接口,并且是用Java编写的。对于像Moqui这样的框架,使用Groovy有很大的好处,因为有很多复杂的功能需要实现,而且Groovy可以帮助代码保持简单和小巧。这使得框架更加灵活,更易于编写、改进和维护。由于没有资金支持的开源项目的资源有限,这些都是关键因素,但甚至适用于使用更大代码库的大型团队。

这有什么不好的地方吗?Moqui框架的早期发布速度很慢。简单的低级操作,如数据库查找,运行速度(在我的笔记本电脑上)约为每秒8,000次,更新速度约为每秒3,000次。那是在一些分析和优化之后。它们位于嵌入式数据库上,因此不涉及网络或序列化开销(有助于更好地了解框架开销)。从性能分析中我可以看到,Groovy类花费了大量时间进行类型检查和转换等工作,即使在不需要的情况下也是如此。

这对于开发和运行流量较低的应用程序是很好的,并且由于架构设计为跨多个应用程序服务器运行,因此可以添加更多硬件来补偿,但是硬件扩展的成本很高,无论是硬件相关成本,还是在较大数量的服务器之间协调的应用程序开销方面都是如此。

当前的Moqui框架代码同样运行在我的笔记本电脑上,每秒可以执行大约200,000次数据库查找操作,每秒可以进行大约50,000次更新。之前和之后的数字来自多次运行后的测试运行,因此JVM及时编译等等都完成了它们的任务。其他框架工具也要快得多(大约一个数量级),包括服务调用和屏幕渲染。

请注意,本文基于Groovy版本2.4.6。在Groovy的未来版本中,情况可能会发生变化,但更通用的提示和技术仍将适用。

工具

您需要的主要工具包括:

  1. 支持Groovy的IDE:我使用JetBrains的IntelliJ IDEA,它有非常好的Groovy支持,包括针对各种Groovy特定问题的检查器。虽然优化可以使用纯文本编辑器或只突出显示语法的编辑器来完成,但是在没有这些检查器的情况下进行一些更大的更改可能需要很长时间。在应用某些更改(如@CompileStatic,请参见下面的更多内容)时,甚至需要在编译代码之前进行各种其他代码更改,而使用简单的文本编辑器执行此操作会涉及过多的更改/构建周期。

  2. Java Profiler:我使用EJ Technologies的JProfiler。市面上有很多优秀的Java分析器,您只需要一些东西来告诉您每个方法需要多长时间才能运行,并允许您根据自己关心的内容包括/排除类和包。为了优化Groovy代码,您需要测量各种Groovy类(下面提到的一些),这样您就可以跟踪使用Groovy与使用Java的开销。

  3. Java反编译器:我使用JD-GUI(https://github.com/java-decompiler/jd-gui)。虽然IDEA为许多方法提供了内置的反编译器,但它不能完全反编译,因此您还需要一些可以让您查看带注释的字节码的工具。这是因为分析器会告诉您某个方法何时多次调用Groovy类型检查、转换等方法,但不会告诉您这些方法在方法中的哪个位置被调用。除非您真正了解Groovy做得很好的是什么,否则您无法仅通过查看代码来判断。在某些情况下,当Groovy发现需要进行类型转换时,比如赋值为null值,这是令人惊讶的。

步骤1:编写测试用例和概要文件

我使用的测试用例只是代码,它们循环数百次或数千次,以将特定操作与其他开销隔离开来。这些需要手动触发(在我的情况下是通过网页),这样您就可以运行它们,查看计时详细信息,并根据需要重新运行它们。在分析之前,您会想要运行它们几次,直到JIT编译器完成它的工作,并且运行时间变得一致为止。一旦您正在运行的应用程序处于该状态,您就可以连接分析器,再运行几次(再次运行,直到运行时间一致为止),然后才能在分析器中打开CPU测量并运行它们来收集时间数据。

如果启动应用程序,请附加分析器,一旦从分析器获得不一致的数字,就运行测试,并且不会模拟代码在生产中的实际运行方式。

第一遍的主要目标是拥有代码最重要部分的测试用例,并了解运行时间最长的部分。大多数分析器都会显示热点,以帮助缩小范围。这些是优化时首先要关注的。另一件需要查看的事情是调用树,以及运行重要的或时间敏感的代码需要多长时间。

这需要很好地了解您的代码库、它做什么以及如何做。从分析器获取关于什么是慢的信息通常归结为一种嗅觉测试,识别需要很长时间才能运行的代码,这些代码要么不做任何具体的事情,要么就是不应该花费这么多时间。

要注意什么在很大程度上取决于您的代码做什么以及如何做。在框架开发中,通常在较低级别的工具代码中,需要注意的一件大事是检查配置选项和其他确定要运行的代码路径的数据。您的代码需要能够快速访问并检查这些数据,这样选项和功能才不会使您的代码变得太慢,以至于当需要速度时,用户最终会找到另一个选项。

步骤2:使用@CompileStatic

虽然Groovy具有允许您保持代码动态键入的性能选项,但我使用它们从未得到过好的结果。对于Moqui框架,使用Indy编译器和运行时(对于Java invkedynamic)实际上大大降低了速度!

对于需要快速运行的代码来说,最佳选择是@CompileStatic注释。当您应用该注释时,您不能使用依赖于动态类型的Groovy特性,但幸运的是,大多数更有用的特性仍然可以很好地工作。

您需要声明泛型的类型和子类型,就像在普通Java中一样。在某些情况下,您还需要执行显式类型转换和强制转换。类型转换是最快的,而使用Groovy‘as’运算符是最方便的。根据特定代码挡路的运行量,您可能会在处理类型上投入或多或少的精力。当您真正关心性能时,请使用类型转换,并确保Groovy没有执行任何动态类型检查或转换。如果它起到了作用,您将在分析器中看到它的发生,并且您可以准确地确定使用反编译器的位置。

@CompileStatic注释可以添加到单个方法,也可以添加到整个类。在我使用@CompileStatic的第一次传递中,我只将它应用于(基于性能分析)在我的测试用例中被大量调用的方法。这对于使用Groovy特性的大型类很方便,这些特性不能与@CompileStatic一起使用,需要全部更改,但在某些情况下,在选定的方法上使用它不起作用。我发现的主要问题是构造函数。您很快就会看到一些奇怪的运行时错误,除了使用@CompileStatic将代码从构造函数拉出到单独的方法中,或者在整个类上使用@CompileStatic注释(这还有其他好处)之外,我一直没有找到修复这些错误的方法。

最后,我采用了主要使用@CompileStatic注释类的方法,并根据需要更新整个类以使用它。

步骤3:全面分析和优化

在应用@CompileStatic注释之前,您可能有太多的Groovy开销,以至于很难找到其他可以优化的代码。对于非常低级的代码,Groovy动态类型开销可能非常大,以至于其他优化几乎没有什么不同。

既然您已经将@CompileStatic应用于最重要的类和方法,您就可以真正开始分析和优化您的代码了,而且您还可以做更多真正有意义的更改。

需要注意的一件事是迭代。通常,迭代块中的代码是将运行较高次数的代码,特别是使用嵌套迭代时(可能在不同的方法中,等等)。对于大量运行迭代的代码块,迭代本身的开销是需要密切关注的。如果分析结果显示创建迭代器和调用hasNext()/Next()花费了大量时间,那么您可以通过使用数组或ArrayList显着提高性能。这可以通过使用简单的整数比较来节省创建迭代器和重复调用hasNext()的成本。当迭代挡路中的代码相当简单时,这会产生巨大的不同,你可能会看到挡路的代码运行速度要快几十倍。

另一件需要注意的事情是方法调用。方法调用在Java中相当快,但在低级、频繁运行的代码中,方法调用的开销很大。您将遇到这种开销的两个主要地方是高度嵌套的方法调用,它们来自过度结构化或在执行简单操作时通过太多泛型接口运行的代码,以及非常低级的代码,它们需要数据并通过getter方法获取数据。在许多情况下,getter方法是一种很好的实践,但对于使用内部数据(如配置数据)的低级代码,这些方法通常是私有类,因此将成员字段设为私有没有什么帮助,而且直接访问成员字段以节省方法调用开销也没有问题。请注意,要做到这一点,您需要确保Groovy没有使用自动的getter方法!在某些情况下,我甚至使用用Java编写的简单对象来保存配置和其他经常使用的数据。

无论是用Groovy还是Java编写,另一件需要注意的事情是包装基本类型(int/Integer、Boolean/Boolean等)的类以及装箱和取消装箱的成本。在大多数代码中,这都无关紧要,但在低级代码中,您将看到获取简单值或构造包装器对象的方法调用的开销,而这些开销很大,您可以通过仅使用基元类型来避免。

步骤4:Groovy特定优化

在查看分析器结果时,您可能会看到热点之间的各种Groovy方法调用。下面是一些常见Groovy方法的摘要,以及您可以对它们做些什么。

要在分析结果中查看这些,请确保没有过滤掉这些Groovy包和类:

  • org.codehaus.groovy.runtime.ScriptBytecodeAdapter

  • org.codehaus.groovy.runtime.typehandling.DefaultTypeTransformation

  • org.codehaus.groovy.reflection.ClassInfo

ScriptBytecodeAdapter.CastToType()

Groovy只要不知道值的类型,或者它确定的类型是Object,并且您将其赋给具有特定类型的字段,它就会调用CastToType()方法。在使用@CompileStatic时,这种情况会最小化,但即使Groovy似乎应该能够在编译时确定类型,这种情况仍然会发生。避免这些调用基本上需要显式类型转换,即使在Java中不需要这样做的情况下也是如此。在Groovy中您不必这样做,但是为了避免CastToType()调用的开销,您必须这样做。

这就是反编译器最有用的地方。您可以在分析器中看到这些调用的方法,但是通过查看代码来查找它们被调用的位置通常并不明显,有时甚至令人惊讶。

一种这样的情况是在分配空值时。例如,这会导致调用CastToType():

String myString = null

为了避免这种情况,您需要强制转换NULL,然后不会调用CastToType():

String myString = (String) null

另一种情况是从Map或Collection(如ArrayList)获取值时。令人惊讶的是,即使在使用泛型和子类型时,这种情况也会在Groovy中发生。此代码中的最后两条语句将导致对CastToType的调用:

Map<String, String> myMap = new HashMap<>()
ArrayList<String> myList = new ArrayList<>()

String mapValue = myMap.get("foo")
String listValue = myList.get(0)

使用显式类型强制转换时,Groovy不再调用CastToType():

String mapValue = (String) myMap.get("foo")
String listValue = (String) myList.get(0)

现在,这似乎是处理这件事的最好方式。这是不直观的,有点麻烦,但总体上很容易。棘手的部分是追踪这些事件发生的地点。希望这个关于提高性能的技巧在将来的Groovy版本中将不再需要,但目前它是它工作方式的一部分。

当您反编译并以字节码(而不是反编译的Java代码)结束时,这里是一个示例,展示了在NULL赋值中调用CastToType()时的情况:

// 12: aconst_null
// 13: ldc_w 1717
// 16: invokestatic 143org/codehaus/groovy/runtime/ScriptBytecodeAdapter:castToType(Ljava/lang/Object;Ljava/lang/Class;)Ljava/lang/Object;
// 19: checkcast 1717org/moqui/impl/entity/condition/EntityConditionImplBase

注意,除了上下文(前后查找引用)之外,您没有获得有关该语句的太多线索,但是您至少获得了JAVA期望的类型,并且如果您将其强制转换为该类型,则对CastToType()的调用就会消失(Groovy静电编译器不会生成它)。

当您显式地将Object或其他类强制转换为更具体的类,并且Groovy静电编译器确定原始类是其他类时,也会使用CastToType()方法。在这些情况下,执行其他一些手动转换不太可能更快。Groovy不支持Java中执行未检查类型转换的方法。在Java中,您会收到有关此问题的警告,但可以取消该警告。在Groovy中,它总是在运行时调用CastToType()来检查类型(与Java的默认“checkcast”字节码相比有点冗余)。

MATH和ScriptBytecodeAdapter.asType()

asType()方法用于将值强制为特定对象类型,并可能导致类型转换。如果没有@CompileStatic,这会被大量调用,并且是常见的分析热点。对于@CompileStatic,这主要在使用‘as’运算符时使用。

要展示一个看似简单但在Groovy中变得复杂的示例,请考虑以下两个语句(作为示例):

long startTimeNanos = System.nanoTime()
long startTime = startTimeNanos/1000000L as long

首先,甚至还需要“只要长”,这似乎很可笑。如果没有它,编译时会出现类似“[静电类型检查]-无法将java.math.BigDecimal类型的值赋给Long类型的变量”的错误。在Groovy中,所有数学运算都使用BigDecimal完成,除非有一个操作数是浮点型或双精度型。对于商业应用程序,这非常方便,并且结果比浮点数学更一致、更可靠。当试图像这样进行简单的基于基元类型的计算时,这是一件很痛苦的事情!简单的解决方案是告诉Groovy您想要一个很长的结果。为了方便起见,这是可以的;对于性能而言,实现这一点所经历的障碍令人惊叹。

下面是针对第二个语句从JD-GUI反编译字节码的示例:

// 7: invokestatic 1341java/lang/Long:valueOf(J)Ljava/lang/Long;
// 10: getstatic 1343org/moqui/impl/entity/EntityValueBase:$const$0Ljava/math/BigDecimal;
// 13: invokestatic 1348org/codehaus/groovy/runtime/dgmimpl/NumberNumberDiv:div(Ljava/lang/Number;Ljava/lang/Number;)Ljava/lang/Number;
// 16: getstatic 1349java/lang/Long:TYPELjava/lang/Class;
// 19: invokestatic 517org/codehaus/groovy/runtime/ScriptBytecodeAdapter:asType(Ljava/lang/Object;Ljava/lang/Class;)Ljava/lang/Object;
// 22: invokestatic 1353org/codehaus/groovy/runtime/typehandling/DefaultTypeTransformation:longUnbox(Ljava/lang/Object;)J

在这里,我们调用Long.value()来装箱基元long,调用Groovy类上的NumberNumberDiv.div()来进行除法,然后调用asType()来使用Long.class类型转换类型并对其进行舍入,然后调用LongUnbox()来返回基元。

解决方案是什么?将代码更改为执行一个双精度操作(Groovy将执行本机操作),然后调用Math.round()以取回LONG值:

long startTime = Math.round(startTimeNanos/1000000.0D)

来自Groovy编译器的字节码现在干净多了:

// 7: l2d
// 8: ldc2_w 1338
// 11: ddiv
// 12: invokestatic 1345java/lang/Math:round(D)J

现在,我们只有本地字节码调用,包括用于双除法的ddiv和对Math.round()的单个方法调用。性能差异是巨大的。

此示例涵盖的不仅仅是asType()调用,还演示了如何提高数学运算的效率(使用浮点和双精度数学技巧)。更广泛地说,为了避免asType()调用,寻找使用‘as’操作符的替代方法,并关注分析器和反编译器中导致它的其他东西。

DefaultTypeTransformation.booleanUnbox()

当您有一个用作布尔值的表达式,但表达式结果的数据类型不是布尔值时,将调用该方法。这在Groovy中是一个非常方便的特性,强制使用布尔值,但是在低级代码中可以避免性能损失。通常这是在‘if’或类似的表达式中,比如下面的代码,其中if等价于(myString!=NULL&&myString.length()>0):

String myString = ...
if (myString) { ... }

可以将它们更改为Compare to NULL,并在必要时检查是否为空(使用size()、length()等方法)。这样做比调用booleanUnbox()要快很多,如果只是执行NULL检查,速度会快得多。

ClassInfo.getMetaClass()、$getStaticMetaClass()

所有用Groovy实现的类都有一个Groovy在运行时使用的元类。这对于某些Groovy特性是必需的,但是即使您不使用这些语言特性,它也会在运行时到位并被检索。

您通常会将这些视为用Groovy编写的类的概要分析中的热点,这些类被构造了很多次。对于具有其他简单初始化的类,这些表示很大的开销。避免它们的唯一方法似乎是不使用Groovy,而是将这些特定的类转移到普通Java。对于某些类来说,这很容易做到并且值得去做,对于更复杂的类来说,用Groovy编写代码的价值可能会超过这种性能开销,所以这只是一些需要注意和接受的事情。

Groovy邮件列表中有关于禁用此功能的选项的建议和请求,按类禁用此功能的机制将是理想的。

结论

与普通Java相比,Groovy有很多优点,对于那些已经熟悉Java的人来说,它很容易逐步学习。Groovy的主要好处可以归结为开发人员的效率和便利性,这通常是以运行时性能为代价的。有了一点知识和努力,情况就不一定是这样了。Groovy代码可以和Java代码一样快地运行。

这就引出了一个问题:优化Groovy代码的额外努力是否抵消了它提供的开发效率?根据我的经验,答案显然是否定的,Groovy的效率仍然要高得多。即使用普通的Java性能分析和优化编写也是非常重要的,使用Groovy完成这项工作只有一些小问题,还有一些简单的额外事项需要注意。分析用Groovy编写的代码的大部分操作与使用纯Java代码执行的操作相同。通常情况下,最好先编写代码以使其正常工作,然后进行概要分析和优化,而不是试图在进行过程中进行优化。Groovy非常适合这种做法,因为初始开发所需的时间要短得多。一般而言,在编写和优化Groovy代码时,您几乎没有完成编写纯Java代码,仍然需要进行优化。再加上随着时间推移维护Java代码的更大努力,Groovy显然是更好的选择。

本文主要面向从事低级代码工作的人员。对于那些专注于应用程序并希望在很大程度上避免所有这些麻烦的人来说,看看Moqui Framework(http://www.moqui.org)。Moqui是一种用于快速、可伸缩的企业应用程序的现代框架,它为开发人员引入了更高效的实践,不仅解决了典型框架的持久层和Web层,还提供了强大的面向服务的逻辑层和与各种工具的本机集成。