Groovy Record Performance

Author: Paul King
Published: 2023-05-09 11:39PM (Last updated: 2023-05-10 07:57PM)


We highly recommend the excellent JEP Café series on the @java YouTube channel. Features arriving on the JDK are likely to be features you will be able to use in Groovy soon too!

In JEP Café Episode 8, José Paumard looks at a number of topics related to Records including the performance of some of the generated methods like hashCode and equals. It compares the performance of those methods for Java, Kotlin data classes and Lombok’s @Data classes.

Let’s do a similar comparison adding in Scala case classes and Groovy records. For Groovy, we’ll cover native records and emulated records (which you can use on JDK8 and above if you are still stuck on older JDK versions).

Our domain

We’ll use the example about 7½ minutes into the JEP Café episode. It is an aggregate of five Strings which together form a kind of aggregate label.

Java record

Here is the Java version:

public record JavaRecordLabel(String x0, String x1, String x2, String x3, String x4) { }

Java class with Lombok’s @Data

For Lombok, the default equivalent will look like this:

@Data
public class LombokDataLabel {
    final String x0, x1, x2, x3, x4;
}

But to get behavior closer to records, we can configure Lombok in a different way like this:

@Data
@EqualsAndHashCode(doNotUseGetters = true)
public class LombokDirectDataLabel {
    final String x0, x1, x2, x3, x4;
}

We’ll discuss this in more detail later.

Kotlin data class

Here is the Kotlin equivalent:

data class KotlinDataLabel (
    val x0: String, val x1: String, val x2: String, val x3: String, val x4: String)

Scala case class

Here is the Scala equivalent:

case class ScalaCaseLabel(x0: String, x1: String, x2: String, x3: String, x4: String)

Groovy record

Here is the Groovy equivalent:

record GroovyRecordLabel(String x0, String x1, String x2, String x3, String x4) { }

This will produce bytecode similar to a native Java record when run on a version of the JDK supporting records. On earlier JDK versions, it will produce an emulated record. We are going to use JDK17 for our examples. We can force the Groovy compiler to produce emulated code with JDK17 by applying a record option annotation as shown here:

@RecordOptions(mode = RecordTypeMode.EMULATE)
record GroovyEmulatedRecordLabel(String x0, String x1, String x2, String x3, String x4) { }

Performance of hashCode

Our benchmark code was written in Java for all cases and used JMH. It called the hashCode method of a static instance for each of the different cases. The full source code is in a record-performance repo on GitHub.

Here is an example of setting up the static instance (X0 .. X4 are String constants):

private static final JavaRecordLabel JAVA_RECORD_LABEL = new JavaRecordLabel(X0, X1, X2, X3, X4);

Here is an example of the benchmark test:

@Benchmark
public void hashcodeJavaRecord(Blackhole bh) {
    bh.consume(JAVA_RECORD_LABEL.hashCode());
}

We used 3 warmup iterations and 10 benchmark iterations:

jmh {
    warmupIterations = 3
    iterations = 10
    fork = 1
    timeUnit = 'ns'
    benchmarkMode = ['avgt']
}

Results

The tests were run using GitHub actions on various platforms. The results show average time taken in nanoseconds, so a smaller Score means faster.

Using 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

Using 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

Using 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

Graphing the results

We can already see some variance in the results across platforms. So, let’s average the results across the three platforms, which gives us the following chart:

hashcodeTimes

Next we’ll look at some of the reasons behind these differences and some other considerations which can help you speed up hashCode or justify why you might want to choose a slower version as a trade-off for other useful properties.

Discussion

It is always dangerous to draw too many conclusions from microbenchmarks. We don’t always know if we are comparing apples with apples, or what else was running on the machine when the benchmarks were executed, or how changing the benchmark slightly might alter the result. Certainly for hashCode, the result is impacted by the number of and types of our record components. Even the particular data instances (arbitrary Strings in our case) will impact the speed of that method.

But what does this really tell us? For Groovy users, it is good to know that the hashCode method is as good or better than Java records. That isn’t too surprising since the Groovy bytecode is almost identical to the Java bytecode for most parts of records.

For Lombok and the other languages, the hashCode method is slower but only by a few (or into the 10s of) nanoseconds. Do we really care about how fast this particular method is? Certainly if we are storing a lot of our label instances into hashed collections, it could matter, but otherwise, not so much; we’ll rarely call this method directly.

But speed is only one of properties we’d like in a good hashCode method. Another is minimal collisions. We can after all return the constant 0 or -1 from our hashCode and that would be very fast but hopeless in terms of collisions.

Hashing algorithm

For Scala case classes, the Murmur3 hashing algorithm is currently used which is slightly slower that what Java uses but claims to have improved collision resistance. If you are using large collections or records with many components, this tradeoff might be worth considering.

You can use Scala’s algorithm directly in Groovy with a record definition like this:

record GroovyRecordScalaMurmur3Label(String x0, String x1, String x2, String x3, String x4) {
    int hashCode() {
        ScalaRunTime._hashCode(new Tuple5<>(x0, x1, x2, x3, x4))
    }
}

And this has almost identical performance to the native Scala example from our earlier bar chart.

If you want a smaller dependency than the Scala runtime jar, you could use the 32-bit Murmur3 algorithm from Guava or write your own combiner to combine hashes produced by Apache Commons Codec’s Murmur3 algorithm on the bytes of each String component. In my tests, both of these alternatives ended up being slower than borrowing Scala’s algorithm, but I didn’t try to optimise my implementation.

If you want to diver deeper on this topic, check out:

JDK version support

One difference worth pointing out is that the Groovy, Lombok and other languages work on earlier JDKs. As the GitHub action workflow configuration shows, the example in this blog post are tested on JDK 8, 11 and 17.

matrix:
  java: [8,11,17]

The Java record examples are tested in JDK 17 (technically requires 16+). This is good to know if you are stuck on earlier versions but should become less of an issue over time.

Caching

A nice Groovy feature provided by some of Groovy’s transforms is caching, which is exactly what you might want to do for immutable classes (like records). In fact, in Groovy, caching is turned on by default for the hashCode and toString methods for @Immutable classes, but we leave it off by default for records for Java compatibility.

Let’s turn on caching for the hashCode method with Groovy:

@EqualsAndHashCode(useGetters = false, cache = true)
record GroovyRecordWithCacheLabel(String x0, String x1, String x2, String x3, String x4) { }

By default, Groovy records behave like Java records. By supplying the @EqualsAndHashCode annotation, we effectively get the code for an emulated record instead of the normal record bytecode. To be as close to records as possible but with caching turned on, we enable cache and disable useGetters. We’ll discuss the latter in more detail in the next subsection.

Now, let’s change our Java and Groovy benchmark code to simulate some code that uses hashCode multiple times. For our purposes, we’ll just sum 5 calls to hashCode:

@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());
}

And we can do the same for Groovy. Here are the results of our new benchmark:

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

As expected, the effect of caching is clearly visible. We could certainly write our own caching with an explicit hashCode method in Java and perhaps call into Objects.hash or similar, but it’s not as nice as having the option of a declarative approach.

As a side note, we could add @Memoized to the hashCode method in our earlier GroovyRecordScalaMurmur3Label example to turn on caching when using that algorithm.

Supporting JavaBean-like behavior

One other "feature" of Java (and Groovy) records is the ability to override the record component "getters". You could for instance, write a 3-String label record in Java that always returns its x1 component in uppercase:

public record JavaRecordLabelUpper(String x0, String x1, String x2) {
    public String x1() { return x1.toUpperCase(); }
}

Now using the x1() getter method will give you the uppercase version. Just be aware though that hashCode (and equals) don’t use the getter but access the field directly.

So, while all the components might be equal in the following example, the hashcode (and the record as a whole) won’t be equal:

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);

This is exactly as expected from the record-related parts of the JLS specification and is a reasonable design decision given that records are handling the use case of "a simple aggregate of values". Indeed, records step away from many of the JavaBean conventions, so we might expect some differences, yet not using the getter might still seem strange to some folks.

The JLS specification elaborates further, stating that the above JavaRecordLabelUpper class might be considered bad style. The rationale is in terms of a record r2 derived from the components of record r1:

R r2 = new R(r1.c1(), r1.c2(), ..., r1.cn());

For any well-behaved record class, r1.equals(r2) should be true, which won’t be the case for JavaRecordLabelUpper.

Accessing the component through its getter is slower but would preserve the above property. Both the LombokDataLabel and ScalaCaseLabel implementations use the getter. This accounts for some of the reduced speed of those implementations.

Groovy records default to Java behavior here but allow you to use the getters for hashCode (and equals and toString) if you so desire. It will be slower but now preserves traditional JavaBean-like getter behavior.

Here is what the code would look like:

@EqualsAndHashCode
record GroovyRecordUpperGetter(String x0, String x1, String x2) {
    String x1() { x1.toUpperCase() }
}

The explicit @EqualsAndHashCode annotation tells the compiler to provide Groovy’s default generated hashCode bytecode which does use getters rather than the special record bytecode which doesn’t. It ends up being the same hashing algorithm but uses the getters to access the components.

And now our tests pass (with assertEquals instead of 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());

Summary

Groovy records have good hashCode performance. There are times when you might want to enable caching. On rare occasions, you might want to also consider swapping the hashing algorithm or enabling getters, but if you need to, Groovy makes that easy too.

Performance of equals

For this benchmark, the equals method of a static instance was called passing in a second static instance.

Here is an example of our benchmark code:

@Benchmark
public void equalsGroovyRecord(Blackhole bh) {
    bh.consume(GROOVY_RECORD_LABEL.equals(GROOVY_RECORD_LABEL_2));
}

Results

As before, the tests were run using GitHub actions on various platforms. The results show average time taken in nanoseconds, so a smaller Score means faster.

Using 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

Using 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

Using 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

Graphing the results

Like before, we’ll average the results across the three platforms:

equalsTimes

Discussion

We saw for hashCode, that using getters retained JavaBean-like expectations but with additional costs of calling that method. That impact is doubly worse for equals since we call the getters for this and the instance we are comparing against. This explains a significant part of the slowness for the LombokDataLabel and ScalaCaseLabel implementations.

Like we discussed for hashCode, you would not normally modify the getters for records and record-like classes. But for Java and Groovy which allow you to, it might come as a surprise. Groovy follows Java behavior here by default but allows you to enable access via getters if you desire.

The JLS has an example of a SmallPoint record in section 8.10.3 which is discussed as bad style because with Java records the last statement prints false. If we enable getters, the last statement now prints true as shown in this Groovy equivalent of that example:

@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
    }
}

Never-the-less, for this particular example, it might be better style to leave the normal field access in place and provide something like a compact canonical constructor to truncate the points during construction.

Conclusion

We have looked at a few aspects of the performance of Groovy records and compared them to other languages. Groovy’s default behavior piggybacks directly on Java’s behavior but Groovy has many declarative options to tweak the generated code if needed.