-
Bug
-
Resolution: Unresolved
-
P4
-
21, 24
-
Microsoft Windows [Version 10.0.26100.4946]
OpenJDK Runtime Environment Temurin-24.0.2+12 (build 24.0.2+12)
-
generic
-
generic
Java 16 record classes generate a hashCode() implementation that:
- Uses method handles to perform some reflection
- Invokes Objects::hash on record components to compute the hash code value
I've found this to perform significantly worse compared to hand-written (or Eclipse-generated) hashCode() implementations. A simple JMH benchmark illustrates the difference:
// ---------------------------------------------------
import org.openjdk.jmh.annotations.Benchmark;
import org.openjdk.jmh.annotations.Fork;
import org.openjdk.jmh.annotations.Measurement;
import org.openjdk.jmh.annotations.Scope;
import org.openjdk.jmh.annotations.State;
import org.openjdk.jmh.annotations.Warmup;
@Fork(value = 1)
@Warmup(iterations = 3, time = 3)
@Measurement(iterations = 7, time = 3)
public class RecordHashCodeBenchmark {
@State(Scope.Thread)
public static class BenchmarkState {
KeyClass k1 = new KeyClass(1, "a");
KeyRecord k2 = new KeyRecord(1, "a");
}
@Benchmark
public int classHashCode(BenchmarkState state) {
return state.k1.hashCode();
}
@Benchmark
public int recordHashCode(BenchmarkState state) {
return state.k2.hashCode();
}
private static final record KeyRecord(Object key1, Object key2) {}
private static final class KeyClass {
private final Object key1;
private final Object key2;
KeyClass(Object key1, Object key2) {
this.key1 = key1;
this.key2 = key2;
}
@Override
public int hashCode() {
final int prime = 31;
int result = 1;
result = prime * result + ((key1 == null) ? 0 : key1.hashCode());
result = prime * result + ((key2 == null) ? 0 : key2.hashCode());
return result;
}
@Override
public boolean equals(Object obj) {
if (this == obj)
return true;
if (obj == null)
return false;
if (getClass() != obj.getClass())
return false;
KeyClass other = (KeyClass) obj;
if (key1 == null) {
if (other.key1 != null)
return false;
}
else if (!key1.equals(other.key1))
return false;
if (key2 == null) {
if (other.key2 != null)
return false;
}
else if (!key2.equals(other.key2))
return false;
return true;
}
@Override
public String toString() {
return "KeyClass [key1=" + key1 + ", key2=" + key2 + "]";
}
}
}
// ---------------------------------------------------
This performs as follows on my machine:
# JMH version: 1.37
# VM version: JDK 24.0.2, OpenJDK 64-Bit Server VM, 24.0.2+12
# VM invoker: C:\Program Files\Java\jdk-24.0.2+12\bin\java.exe
Benchmark Mode Cnt Score Error Units
RecordHashCodeBenchmark.classHashCode thrpt 7 1115874313.008 ± 22812994.956 ops/s
RecordHashCodeBenchmark.recordHashCode thrpt 7 282739491.955 ± 6590532.033 ops/s
Adding a third component to the record / class still produces a difference:
Benchmark Mode Cnt Score Error Units
RecordHashCodeBenchmark.classHashCode thrpt 7 700717040.704 ± 15753956.255 ops/s
RecordHashCodeBenchmark.recordHashCode thrpt 7 189052898.730 ± 4026557.417 ops/s
Perhaps the existing implementation relies on an assumption that escape analysis, inlining, and loop unrolling would kick in, making the two logically equivalent implementations perform the same? Apparently, this is the case for GraalVM JDK 24.0.2:
https://www.reddit.com/r/java/comments/1n1wafs/comment/nb6kjc6/
I'm not sure if this should be considered a problem in hotspot's JIT logic, or in javac's byte code generation for records.
Original discovery and motivation:
- https://github.com/jOOQ/jOOQ/issues/18935
A discussion and some further analyses:
- https://www.reddit.com/r/java/comments/1n1wafs/records_are_suboptimal_as_keys_in_hashmaps_or_as/
- Uses method handles to perform some reflection
- Invokes Objects::hash on record components to compute the hash code value
I've found this to perform significantly worse compared to hand-written (or Eclipse-generated) hashCode() implementations. A simple JMH benchmark illustrates the difference:
// ---------------------------------------------------
import org.openjdk.jmh.annotations.Benchmark;
import org.openjdk.jmh.annotations.Fork;
import org.openjdk.jmh.annotations.Measurement;
import org.openjdk.jmh.annotations.Scope;
import org.openjdk.jmh.annotations.State;
import org.openjdk.jmh.annotations.Warmup;
@Fork(value = 1)
@Warmup(iterations = 3, time = 3)
@Measurement(iterations = 7, time = 3)
public class RecordHashCodeBenchmark {
@State(Scope.Thread)
public static class BenchmarkState {
KeyClass k1 = new KeyClass(1, "a");
KeyRecord k2 = new KeyRecord(1, "a");
}
@Benchmark
public int classHashCode(BenchmarkState state) {
return state.k1.hashCode();
}
@Benchmark
public int recordHashCode(BenchmarkState state) {
return state.k2.hashCode();
}
private static final record KeyRecord(Object key1, Object key2) {}
private static final class KeyClass {
private final Object key1;
private final Object key2;
KeyClass(Object key1, Object key2) {
this.key1 = key1;
this.key2 = key2;
}
@Override
public int hashCode() {
final int prime = 31;
int result = 1;
result = prime * result + ((key1 == null) ? 0 : key1.hashCode());
result = prime * result + ((key2 == null) ? 0 : key2.hashCode());
return result;
}
@Override
public boolean equals(Object obj) {
if (this == obj)
return true;
if (obj == null)
return false;
if (getClass() != obj.getClass())
return false;
KeyClass other = (KeyClass) obj;
if (key1 == null) {
if (other.key1 != null)
return false;
}
else if (!key1.equals(other.key1))
return false;
if (key2 == null) {
if (other.key2 != null)
return false;
}
else if (!key2.equals(other.key2))
return false;
return true;
}
@Override
public String toString() {
return "KeyClass [key1=" + key1 + ", key2=" + key2 + "]";
}
}
}
// ---------------------------------------------------
This performs as follows on my machine:
# JMH version: 1.37
# VM version: JDK 24.0.2, OpenJDK 64-Bit Server VM, 24.0.2+12
# VM invoker: C:\Program Files\Java\jdk-24.0.2+12\bin\java.exe
Benchmark Mode Cnt Score Error Units
RecordHashCodeBenchmark.classHashCode thrpt 7 1115874313.008 ± 22812994.956 ops/s
RecordHashCodeBenchmark.recordHashCode thrpt 7 282739491.955 ± 6590532.033 ops/s
Adding a third component to the record / class still produces a difference:
Benchmark Mode Cnt Score Error Units
RecordHashCodeBenchmark.classHashCode thrpt 7 700717040.704 ± 15753956.255 ops/s
RecordHashCodeBenchmark.recordHashCode thrpt 7 189052898.730 ± 4026557.417 ops/s
Perhaps the existing implementation relies on an assumption that escape analysis, inlining, and loop unrolling would kick in, making the two logically equivalent implementations perform the same? Apparently, this is the case for GraalVM JDK 24.0.2:
https://www.reddit.com/r/java/comments/1n1wafs/comment/nb6kjc6/
I'm not sure if this should be considered a problem in hotspot's JIT logic, or in javac's byte code generation for records.
Original discovery and motivation:
- https://github.com/jOOQ/jOOQ/issues/18935
A discussion and some further analyses:
- https://www.reddit.com/r/java/comments/1n1wafs/records_are_suboptimal_as_keys_in_hashmaps_or_as/