Groovy 记录性能
作者:Paul King
发布日期:2023-05-09 11:39PM(最后更新日期:2023-05-10 07:57PM)
在 JEP Café 第 8 集中,José Paumard 讨论了一些与记录相关的主题,包括一些生成方法(如 hashCode
和 equals
)的性能。它比较了 Java、Kotlin 数据类和 Lombok 的 @Data 类中这些方法的性能。
让我们做一个类似的比较,添加 Scala 案例类和 Groovy 记录。对于 Groovy,我们将涵盖原生记录和模拟记录(如果您仍然停留在较旧的 JDK 版本上,您可以在 JDK8 及更高版本上使用这些记录)。
我们的领域
我们将使用 JEP Café 剧集中大约 7 分 30 秒处的例子。它是一个由五个字符串组成的聚合,它们共同形成了一种聚合标签。
Java 记录
以下是 Java 版本
public record JavaRecordLabel(String x0, String x1, String x2, String x3, String x4) { }
使用 Lombok 的 @Data 的 Java 类
对于 Lombok,默认的等效项将如下所示
@Data
public class LombokDataLabel {
final String x0, x1, x2, x3, x4;
}
但为了获得更接近记录的行为,我们可以通过以下方式配置 Lombok
@Data
@EqualsAndHashCode(doNotUseGetters = true)
public class LombokDirectDataLabel {
final String x0, x1, x2, x3, x4;
}
我们将在稍后详细讨论这一点。
Kotlin 数据类
以下是 Kotlin 等效项
data class KotlinDataLabel (
val x0: String, val x1: String, val x2: String, val x3: String, val x4: String)
Scala 案例类
以下是 Scala 等效项
case class ScalaCaseLabel(x0: String, x1: String, x2: String, x3: String, x4: String)
Groovy 记录
以下是 Groovy 等效项
record GroovyRecordLabel(String x0, String x1, String x2, String x3, String x4) { }
当在支持记录的 JDK 版本上运行时,这将生成类似于原生 Java 记录的字节码。在较早的 JDK 版本上,它将生成模拟记录。我们将使用 JDK17 作为我们的示例。我们可以通过应用如这里所示的记录选项注释来强制 Groovy 编译器生成模拟代码,使用 JDK17。
@RecordOptions(mode = RecordTypeMode.EMULATE)
record GroovyEmulatedRecordLabel(String x0, String x1, String x2, String x3, String x4) { }
hashCode
的性能
我们的基准代码是用 Java 为所有情况编写的,并使用 JMH。它调用了每个不同情况的静态实例的 hashCode
方法。完整的源代码位于 GitHub 上的 record-performance
仓库 中。
以下是如何设置静态实例的示例(X0
.. X4
是字符串常量)
private static final JavaRecordLabel JAVA_RECORD_LABEL = new JavaRecordLabel(X0, X1, X2, X3, X4);
以下是如何进行基准测试的示例
@Benchmark
public void hashcodeJavaRecord(Blackhole bh) {
bh.consume(JAVA_RECORD_LABEL.hashCode());
}
我们使用了 3 次热身迭代和 10 次基准测试迭代
jmh {
warmupIterations = 3
iterations = 10
fork = 1
timeUnit = 'ns'
benchmarkMode = ['avgt']
}
结果
测试是在各种平台上使用 GitHub Actions 运行的。结果显示以纳秒为单位的平均时间,因此较小的分数表示更快。
使用 ubuntu-latest
Benchmark Mode Cnt Score Error Units HashCodeBenchmark.hashcodeGroovyEmulatedRecord avgt 10 3.130 ± 0.015 ns/op HashCodeBenchmark.hashcodeGroovyRecord avgt 10 2.814 ± 0.003 ns/op HashCodeBenchmark.hashcodeJavaRecord avgt 10 2.813 ± 0.001 ns/op HashCodeBenchmark.hashcodeKotlinDataLabel avgt 10 5.213 ± 0.016 ns/op HashCodeBenchmark.hashcodeLombokDirectDataLabel avgt 10 5.427 ± 0.071 ns/op HashCodeBenchmark.hashcodeLombokDataLabel avgt 10 18.328 ± 0.006 ns/op HashCodeBenchmark.hashcodeScalaCaseLabel avgt 10 16.901 ± 0.007 ns/op
使用 windows-latest
Benchmark Mode Cnt Score Error Units HashCodeBenchmark.hashcodeGroovyEmulatedRecord avgt 10 2.948 ± 0.005 ns/op HashCodeBenchmark.hashcodeGroovyRecord avgt 10 3.410 ± 0.005 ns/op HashCodeBenchmark.hashcodeJavaRecord avgt 10 3.407 ± 0.004 ns/op HashCodeBenchmark.hashcodeKotlinDataLabel avgt 10 6.635 ± 0.005 ns/op HashCodeBenchmark.hashcodeLombokDirectDataLabel avgt 10 8.520 ± 0.017 ns/op HashCodeBenchmark.hashcodeLombokDataLabel avgt 10 16.172 ± 0.026 ns/op HashCodeBenchmark.hashcodeScalaCaseLabel avgt 10 19.150 ± 0.048 ns/op
使用 macos-latest
Benchmark Mode Cnt Score Error Units HashCodeBenchmark.hashcodeGroovyEmulatedRecord avgt 10 2.999 ± 0.194 ns/op HashCodeBenchmark.hashcodeGroovyRecord avgt 10 2.765 ± 0.741 ns/op HashCodeBenchmark.hashcodeJavaRecord avgt 10 2.990 ± 0.280 ns/op HashCodeBenchmark.hashcodeKotlinDataLabel avgt 10 7.103 ± 0.123 ns/op HashCodeBenchmark.hashcodeLombokDirectDataLabel avgt 10 6.494 ± 0.063 ns/op HashCodeBenchmark.hashcodeLombokDataLabel avgt 10 15.100 ± 0.108 ns/op HashCodeBenchmark.hashcodeScalaCaseLabel avgt 10 20.327 ± 0.085 ns/op
绘制结果图
我们已经看到了一些跨平台结果的差异。因此,让我们将三个平台的结果平均起来,这将得出以下图表
接下来,我们将研究这些差异背后的一些原因,以及一些其他考虑因素,这些因素可以帮助您加速 hashCode
,或者为选择速度较慢的版本作为其他有用属性的权衡提供理由。
讨论
从微基准测试中得出太多结论总是很危险的。我们并不总是知道我们是否是在比较苹果和苹果,或者在执行基准测试时机器上还运行着什么,或者轻微地改变基准测试可能会如何改变结果。当然对于 hashCode
而言,结果会受到记录组件数量和类型的的影响。甚至特定的数据实例(我们的例子中的任意字符串)也会影响该方法的速度。
但这实际上告诉我们什么呢?对于 Groovy 用户来说,了解到 hashCode
方法与 Java 记录一样好或更好是一件好事。这并不令人惊讶,因为 Groovy 字节码在大多数情况下与 Java 字节码几乎相同。
对于 Lombok 和其他语言来说,hashCode
方法更慢,但仅仅慢了几纳秒(或十纳秒)。我们真的在乎这种特定方法的速度吗?当然,如果我们把很多标签实例存储到散列集合中,这可能会很重要,但除此之外,并不重要;我们很少会直接调用这种方法。
但是速度只是我们期望在好的 hashCode
方法中拥有的属性之一。另一个是碰撞最小。毕竟,我们可以从 hashCode
中返回常量 0
或 -1
,这将非常快,但在碰撞方面却毫无用处。
散列算法
对于 Scala 案例类,目前使用的是 Murmur3 散列算法,它比 Java 使用的算法稍微慢一些,但 声称 它提高了碰撞抵抗能力。如果您使用的是大型集合或包含多个组件的记录,这种权衡可能值得考虑。
您可以使用 Scala 的算法直接在 Groovy 中使用以下记录定义
record GroovyRecordScalaMurmur3Label(String x0, String x1, String x2, String x3, String x4) {
int hashCode() {
ScalaRunTime._hashCode(new Tuple5<>(x0, x1, x2, x3, x4))
}
}
这与我们前面条形图中的原生 Scala 示例的性能几乎相同。
如果您希望使用比 Scala 运行时 jar 更小的依赖项,您可以使用 32 位 Murmur3 算法,该算法来自 Guava,或者编写自己的组合器来组合由 Apache Commons Codec 的 Murmur3 算法 在每个字符串组件的字节上生成的散列值。在我的测试中,这两种替代方案都比借用 Scala 的算法慢,但我没有尝试优化我的实现。
如果您想深入研究这个主题,请查看
-
这篇关于 优化 HashMap 的性能 的精彩概述文章,
-
以及这篇关于 优化散列策略 及其对碰撞的影响的文章,
-
Murmur3 算法的原始 C++ 实现。
JDK 版本支持
值得指出的一点差异是 Groovy、Lombok 和其他语言可以在较早的 JDK 上运行。正如 GitHub Actions 工作流配置所示,本文中的示例是在 JDK 8、11 和 17 上测试的。
matrix:
java: [8,11,17]
Java 记录示例在 JDK 17 上进行了测试(技术上需要 16+)。如果您被困在较早的版本上,这需要了解,但随着时间的推移,这个问题应该会逐渐减少。
缓存
Groovy 提供的某些转换中一个很好的特性是缓存,这正是您可能希望对不可变类(如记录)进行的操作。实际上,在 Groovy 中,对于 @Immutable
类的 hashCode
和 toString
方法,默认情况下会启用缓存,但对于记录,我们会为了与 Java 的兼容性而默认将其关闭。
让我们在 Groovy 中为 hashCode
方法启用缓存
@EqualsAndHashCode(useGetters = false, cache = true)
record GroovyRecordWithCacheLabel(String x0, String x1, String x2, String x3, String x4) { }
默认情况下,Groovy 记录的行为与 Java 记录相同。通过提供 @EqualsAndHashCode
注释,我们实际上获得了模拟记录的代码,而不是正常的记录字节码。为了尽可能接近记录,但同时启用缓存,我们启用了 cache
并禁用了 useGetters
。我们将在下一节中详细讨论后者。
现在,让我们更改我们的 Java 和 Groovy 基准代码,以模拟一些多次使用 hashCode
的代码。为了我们的目的,我们将简单地对 hashCode
的 5 次调用求和
@Benchmark
public void hashcodeJavaRecord(Blackhole bh) {
bh.consume(JAVA_RECORD_LABEL.hashCode()
+ JAVA_RECORD_LABEL.hashCode()
+ JAVA_RECORD_LABEL.hashCode()
+ JAVA_RECORD_LABEL.hashCode()
+ JAVA_RECORD_LABEL.hashCode());
}
我们也可以对 Groovy 做同样的事情。以下是我们新的基准测试结果
Benchmark Mode Cnt Score Error Units HashCodeCacheBenchmark.hashcodeGroovyCacheRecord avgt 10 4.296 ± 0.108 ns/op windows-latest HashCodeCacheBenchmark.hashcodeGroovyCacheRecord avgt 10 4.787 ± 0.151 ns/op ubuntu-latest HashCodeCacheBenchmark.hashcodeGroovyCacheRecord avgt 10 5.465 ± 0.045 ns/op macos-latest HashCodeCacheBenchmark.hashcodeJavaRecord avgt 10 21.956 ± 0.023 ns/op windows-latest HashCodeCacheBenchmark.hashcodeJavaRecord avgt 10 33.820 ± 0.750 ns/op ubuntu-latest HashCodeCacheBenchmark.hashcodeJavaRecord avgt 10 32.837 ± 1.136 ns/op macos-latest
正如预期的那样,缓存的效果显而易见。我们当然可以使用 Java 中的显式 hashCode
方法编写我们自己的缓存,并且可能调用 Objects.hash
或类似的方法,但它不像使用声明式方法那样好。
顺便说一句,我们可以向我们前面 GroovyRecordScalaMurmur3Label
示例中的 hashCode
方法添加 @Memoized
,以便在使用该算法时启用缓存。
支持类似 JavaBean 的行为
Java(和 Groovy)记录的另一个“特性”是能够覆盖记录组件的“getter”。例如,您可以在 Java 中编写一个 3 个字符串的标签记录,该记录始终以大写形式返回其 x1
组件
public record JavaRecordLabelUpper(String x0, String x1, String x2) {
public String x1() { return x1.toUpperCase(); }
}
现在,使用 x1()
getter 方法将为您提供大写版本。但是请注意,hashCode
(和 equals
)不会使用 getter,而是直接访问字段。
因此,虽然在以下示例中所有组件可能都是相等的,但哈希码(以及整个记录)将不会相等
private static final JavaRecordLabelUpper JAVA_UPPER_1
= new JavaRecordLabelUpper("a", "b", "c");
private static final JavaRecordLabelUpper JAVA_UPPER_2
= new JavaRecordLabelUpper("a", "B", "c");
...
assertEquals(JAVA_UPPER_1.x0(), JAVA_UPPER_2.x0());
assertEquals(JAVA_UPPER_1.x1(), JAVA_UPPER_2.x1());
assertEquals(JAVA_UPPER_1.x2(), JAVA_UPPER_2.x2());
assertNotEquals(JAVA_UPPER_1.hashCode(), JAVA_UPPER_2.hashCode());
assertNotEquals(JAVA_UPPER_1, JAVA_UPPER_2);
这完全符合 JLS 规范中与记录相关的部分,并且考虑到记录正在处理“简单值聚合” 的用例,这是一个合理的設計決策。实际上,记录偏离了许多 JavaBean 约定,因此我们可能会期望出现一些差异,但仍然不使用 getter 可能对某些人来说似乎很奇怪。
JLS 规范进一步阐述,指出上面的 JavaRecordLabelUpper
类可能被认为是不好的风格。其理由是关于从记录 r1
的组件派生的记录 r2
R r2 = new R(r1.c1(), r1.c2(), ..., r1.cn());
对于任何行为良好的记录类,r1.equals(r2)
应该为真,而对于 JavaRecordLabelUpper
来说则不会。
通过其 getter 访问组件更慢,但会保留上述属性。LombokDataLabel
和 ScalaCaseLabel
实现都使用 getter。这解释了这些实现速度减慢的部分原因。
Groovy 记录在此处默认使用 Java 行为,但允许您在需要时将 getter 用于 hashCode
(以及 equals
和 toString
)。这将更慢,但现在保留了传统的类似 JavaBean 的 getter 行为。
以下是代码的外观
@EqualsAndHashCode
record GroovyRecordUpperGetter(String x0, String x1, String x2) {
String x1() { x1.toUpperCase() }
}
显式的 @EqualsAndHashCode
注释告诉编译器提供 Groovy 的默认生成的 hashCode
字节码,它使用 getter 而不是特殊的记录字节码,后者不使用 getter。它最终使用相同的散列算法,但使用 getter 来访问组件。
现在我们的测试通过了(使用 assertEquals
而不是 assertNotEquals
)
private static final GroovyRecordUpperGetter GROOVY_UPPER_GETTER_1
= new GroovyRecordUpperGetter("a", "b", "c");
private static final GroovyRecordUpperGetter GROOVY_UPPER_GETTER_2
= new GroovyRecordUpperGetter("a", "B", "c");
...
assertEquals(GROOVY_UPPER_GETTER_1.hashCode(), GROOVY_UPPER_GETTER_2.hashCode());
总结
Groovy 记录具有良好的 hashCode
性能。有时您可能希望启用缓存。在极少数情况下,您可能还需要考虑交换散列算法或启用 getter,但如果需要,Groovy 也能轻松做到这一点。
equals
的性能
对于此基准测试,静态实例的 equals
方法被调用,传递了第二个静态实例。
以下是如何进行基准测试的示例
@Benchmark
public void equalsGroovyRecord(Blackhole bh) {
bh.consume(GROOVY_RECORD_LABEL.equals(GROOVY_RECORD_LABEL_2));
}
结果
和以前一样,测试是在各种平台上使用 GitHub Actions 运行的。结果显示以纳秒为单位的平均时间,因此较小的分数表示更快。
使用 ubuntu-latest
Benchmark Mode Cnt Score Error Units EqualsBenchmark.equalsGroovyEmulatedRecord avgt 10 2.615 ± 0.005 ns/op EqualsBenchmark.equalsGroovyRecord avgt 10 0.603 ± 0.001 ns/op EqualsBenchmark.equalsJavaRecord avgt 10 0.686 ± 0.154 ns/op EqualsBenchmark.equalsKotlinDataLabel avgt 10 3.617 ± 0.002 ns/op EqualsBenchmark.equalsLombokDirectDataLabel avgt 10 3.617 ± 0.002 ns/op EqualsBenchmark.equalsLombokDataLabel avgt 10 24.115 ± 0.014 ns/op EqualsBenchmark.equalsScalaCaseLabel avgt 10 24.130 ± 0.045 ns/op
使用 windows-latest
Benchmark Mode Cnt Score Error Units EqualsBenchmark.equalsGroovyEmulatedRecord avgt 10 2.216 ± 0.004 ns/op EqualsBenchmark.equalsGroovyRecord avgt 10 0.511 ± 0.002 ns/op EqualsBenchmark.equalsJavaRecord avgt 10 0.511 ± 0.001 ns/op EqualsBenchmark.equalsKotlinDataLabel avgt 10 3.066 ± 0.004 ns/op EqualsBenchmark.equalsLombokDirectDataLabel avgt 10 3.068 ± 0.005 ns/op EqualsBenchmark.equalsLombokDataLabel avgt 10 21.451 ± 0.021 ns/op EqualsBenchmark.equalsScalaCaseLabel avgt 10 21.442 ± 0.024 ns/op
使用 macos-latest
Benchmark Mode Cnt Score Error Units EqualsBenchmark.equalsGroovyEmulatedRecord avgt 10 1.943 ± 0.116 ns/op EqualsBenchmark.equalsGroovyRecord avgt 10 0.612 ± 0.013 ns/op EqualsBenchmark.equalsJavaRecord avgt 10 0.579 ± 0.021 ns/op EqualsBenchmark.equalsKotlinDataLabel avgt 10 2.727 ± 0.068 ns/op EqualsBenchmark.equalsLombokDirectDataLabel avgt 10 2.734 ± 0.096 ns/op EqualsBenchmark.equalsLombokDataLabel avgt 10 21.206 ± 2.789 ns/op EqualsBenchmark.equalsScalaCaseLabel avgt 10 20.673 ± 0.766 ns/op
绘制结果图
与之前一样,我们将对三个平台的结果进行平均
讨论
我们看到,对于hashCode
,使用 getter 保留了 JavaBean 式的预期,但会额外增加调用该方法的成本。对于equals
,这种影响会加倍,因为我们为this
和要比较的实例都调用了 getter。这解释了LombokDataLabel
和ScalaCaseLabel
实现缓慢的一个重要原因。
正如我们在hashCode
中讨论的那样,你通常不会修改记录和类似记录的类的 getter。但是对于允许你修改 getter 的 Java 和 Groovy 来说,这可能出乎意料。Groovy 默认情况下遵循 Java 的行为,但允许你在需要时通过 getter 来启用访问。
JLS 在第8.10.3 节中提供了一个SmallPoint
记录的例子,它被认为是糟糕的风格,因为在 Java 记录中,最后一条语句会打印false
。如果我们启用 getter,最后一条语句现在会打印true
,如 Groovy 中这个等效的例子所示
@EqualsAndHashCode
record SmallPoint(int x, int y) {
int x() { this.x < 100 ? this.x : 100 }
int y() { this.y < 100 ? this.y : 100 }
static main(args) {
var p1 = new SmallPoint(200, 300)
var p2 = new SmallPoint(200, 300)
println p1 == p2 // prints true
var p3 = new SmallPoint(p1.x(), p1.y())
println p1 == p3 // prints true
}
}
尽管如此,对于这个特定的例子,也许更好的风格是保留正常的字段访问,并提供类似于紧凑的规范构造函数,以便在构造期间截断这些点。
结论
我们已经查看了 Groovy 记录性能的几个方面,并将其与其他语言进行了比较。Groovy 的默认行为直接依托于 Java 的行为,但 Groovy 拥有许多声明性选项,可以在需要时调整生成的代码。