Groovy 记录性能

作者:Paul King
发布日期:2023-05-09 11:39PM(最后更新日期:2023-05-10 07:57PM)


我们强烈推荐 JEP Café 系列,该系列在 @java YouTube 频道上发布。即将在 JDK 上推出的功能很可能也是您很快就能在 Groovy 中使用的功能!

在 JEP Café 第 8 集中,José Paumard 讨论了一些与记录相关的主题,包括一些生成方法(如 hashCodeequals)的性能。它比较了 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

绘制结果图

我们已经看到了一些跨平台结果的差异。因此,让我们将三个平台的结果平均起来,这将得出以下图表

hashcodeTimes

接下来,我们将研究这些差异背后的一些原因,以及一些其他考虑因素,这些因素可以帮助您加速 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 的算法慢,但我没有尝试优化我的实现。

如果您想深入研究这个主题,请查看

JDK 版本支持

值得指出的一点差异是 Groovy、Lombok 和其他语言可以在较早的 JDK 上运行。正如 GitHub Actions 工作流配置所示,本文中的示例是在 JDK 8、11 和 17 上测试的。

matrix:
  java: [8,11,17]

Java 记录示例在 JDK 17 上进行了测试(技术上需要 16+)。如果您被困在较早的版本上,这需要了解,但随着时间的推移,这个问题应该会逐渐减少。

缓存

Groovy 提供的某些转换中一个很好的特性是缓存,这正是您可能希望对不可变类(如记录)进行的操作。实际上,在 Groovy 中,对于 @Immutable 类的 hashCodetoString 方法,默认情况下会启用缓存,但对于记录,我们会为了与 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 访问组件更慢,但会保留上述属性。LombokDataLabelScalaCaseLabel 实现都使用 getter。这解释了这些实现速度减慢的部分原因。

Groovy 记录在此处默认使用 Java 行为,但允许您在需要时将 getter 用于 hashCode(以及 equalstoString)。这将更慢,但现在保留了传统的类似 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

绘制结果图

与之前一样,我们将对三个平台的结果进行平均

equalsTimes

讨论

我们看到,对于hashCode,使用 getter 保留了 JavaBean 式的预期,但会额外增加调用该方法的成本。对于equals,这种影响会加倍,因为我们为this 和要比较的实例都调用了 getter。这解释了LombokDataLabelScalaCaseLabel 实现缓慢的一个重要原因。

正如我们在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 拥有许多声明性选项,可以在需要时调整生成的代码。