equals() und hashCode(): Der Vertrag und häufige Fehler
1. Übersicht
equals() und hashCode() sind zwei Methoden, die einfach aussehen, aber falsch implementiert führen zu Duplikaten in HashSet und zu “verlorenen” Schlüsseln in HashMap. Diese Fehler werfen normalerweise keine Exception. Das Programm läuft immer noch, nur eben falsch.
Dieser Artikel präsentiert den Vertrag (contract), den Java für diese beiden Methoden vorsieht. Danach gehen wir auf häufige Fehler ein, jeden mit einer Reproduktions-Beispiel. Nach dem Lesen werden Sie beide Methoden korrekt implementieren können, wissen, wann Sie record verwenden sollten, und wie Sie Tests schreiben, um das Verhalten zu überprüfen.
2. Der Vertrag von equals()
Standardmäßig vergleicht Object.equals() Referenzen, also this == obj. Beim Überschreiben müssen Sie die folgenden fünf Eigenschaften für alle Referenzen ungleich null einhalten:
- Reflexiv (reflexive):
x.equals(x)ist immertrue. - Symmetrisch (symmetric):
x.equals(y)isttruegenau dann, wenny.equals(x)trueist. - Transitiv (transitive): Wenn
x.equals(y)undy.equals(z), dannx.equals(z). - Konsistent (consistent): Wenn das Objekt unveränderlich ist, muss das Aufrufen mehrmals das gleiche Ergebnis geben.
- Mit null:
x.equals(null)ist immerfalse.
Eine standardmäßige Implementierung für eine Klasse Customer sieht so aus:
import java.util.Objects;
public final class Customer {
private final String email;
private final String name;
public Customer(String email, String name) {
this.email = email;
this.name = name;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof Customer other)) return false;
return Objects.equals(email, other.email)
&& Objects.equals(name, other.name);
}
@Override
public int hashCode() {
return Objects.hash(email, name);
}
}
Erklärung Schritt für Schritt:
this == oist eine Abkürzung, wenn beide Referenzen auf dasselbe Objekt zeigen.instanceof Customer otherverwendet Pattern Matching (Java 16+). Dieser Ausdruck gibtfalsezurück, wennonull ist, daher ist die Eigenschaft “mit null” gewährleistet, ohne dass eine separate Überprüfung erforderlich ist.Objects.equalsist sicher mit null-Feldern.Objects.hashberechnet den Hash genau aus den Feldern, die inequals()verwendet werden.
Die Klasse wird als final deklariert. Der Grund wird in Punkt 6 deutlich.
3. Der Vertrag von hashCode()
hashCode() hat drei Regeln:
- Das mehrfache Aufrufen derselben unveränderlichen Objekt muss denselben Wert zurückgeben.
- Wenn
a.equals(b), danna.hashCode() == b.hashCode(). Dies ist die wichtigste Regel. - Zwei verschiedene Objekte dürfen denselben Hash-Code haben (Kollision). Allerdings hilft eine gute Hash-Verteilung, dass Collections schneller laufen.
Die umgekehrte Richtung von Regel 2 ist nicht wahr. Der gleiche Hash-Code bedeutet nicht, dass sie gleich sind.
Um zu sehen, warum Regel 2 wichtig ist, betrachten Sie, wie HashMap einen Schlüssel sucht. Zuerst verwendet es hashCode(), um einen Bucket auszuwählen. Dann verwendet es equals(), um die Elemente in diesem Bucket zu vergleichen. Wenn zwei Objekte “gleich” sind, aber unterschiedliche Hashes haben, befinden sie sich in verschiedenen Buckets, und equals() wird nie aufgerufen.
4. Fehler 1: equals() überschreiben, aber hashCode() vergessen
Dies ist ein klassischer Fehler. Angenommen, Sie lassen hashCode() aus der Klasse in Punkt 2 weg:
public class Customer {
private final String email;
public Customer(String email) { this.email = email; }
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof Customer other)) return false;
return email.equals(other.email);
}
// hashCode() fehlt
}
Set<Customer> customers = new HashSet<>();
customers.add(new Customer("an@example.com"));
customers.add(new Customer("an@example.com"));
System.out.println(customers.size());
System.out.println(customers.contains(new Customer("an@example.com")));
2
false
Zwei Objekte sind gleich nach equals(). Aber hashCode() ist immer noch die Standardvariante von Object, die sich normalerweise für jede Instanz unterscheidet. Das Ergebnis ist, dass HashSet zwei “doppelte” Elemente enthält, und contains() gibt false zurück. Dieses Ergebnis tritt fast sicher auf. Selten stimmen zwei Standard-Hashes überein, daher ist der Fehler noch schwerer reproduzierbar.
Hinweis: IDEs und Tools wie SonarQube warnen vor diesem Fehler. Schalten Sie diese Warnung nicht aus.
5. Fehler 2: Überladen statt Überschreiben
Dieser Fehler ist viel schwerer zu erkennen:
public class Order {
private final long id;
public Order(long id) { this.id = id; }
public boolean equals(Order other) { // FALSCH: Parameter ist Order, nicht Object
return other != null && id == other.id;
}
@Override
public int hashCode() { return Long.hashCode(id); }
}
Order a = new Order(1);
Order b = new Order(1);
System.out.println(a.equals(b)); // ruft equals(Order) auf
System.out.println(List.of(a).contains(b)); // ruft equals(Object) auf
true
false
equals(Order) ist eine neue Methode (überladen). Es überschreibt nicht equals(Object). Collections rufen immer equals(Object) auf, daher verwenden sie weiterhin den Referenzvergleich von Object.
Die Vorbeugung ist sehr einfach: @Override immer hinzufügen. Wenn Sie @Override zu der obigen Methode hinzufügen, meldet der Compiler sofort einen Fehler.
6. Fehler 3: Symmetrie durch Vererbung brechen
Betrachten Sie die Klasse Point und die Unterklasse ColorPoint, die ein Feld color hinzufügt:
public class Point {
protected final int x, y;
public Point(int x, int y) { this.x = x; this.y = y; }
@Override
public boolean equals(Object o) {
if (!(o instanceof Point p)) return false;
return x == p.x && y == p.y;
}
@Override
public int hashCode() { return Objects.hash(x, y); }
}
public class ColorPoint extends Point {
private final String color;
public ColorPoint(int x, int y, String color) { super(x, y); this.color = color; }
@Override
public boolean equals(Object o) {
if (!(o instanceof ColorPoint cp)) return false;
return super.equals(cp) && color.equals(cp.color);
}
@Override
public int hashCode() { return Objects.hash(x, y, color); }
}
Point p = new Point(1, 2);
ColorPoint cp = new ColorPoint(1, 2, "RED");
System.out.println(p.equals(cp));
System.out.println(cp.equals(p));
true
false
Symmetrie wurde gebrochen. Außerdem ist p.equals(cp) true, aber die Hashes der beiden Objekte sind unterschiedlich, daher wird auch Regel 2 aus Punkt 3 verletzt.
Es gibt keine Möglichkeit, ein aussagekräftiges Feld zu einer Unterklasse hinzuzufügen und gleichzeitig den Vertrag mit instanceof zu erfüllen. Sie haben drei Möglichkeiten:
- Deklarieren Sie die Klasse als
finaloder verwenden Sierecord. Dies ist die einfachste Methode. - Verwenden Sie
getClass() != o.getClass()anstelle voninstanceof. In diesem Fall sindPointundColorPointnie gleich. Dies erfüllt den Vertrag, aber eine Unterklasse ohne zusätzliche Felder (zum Beispiel ein Proxy) ist auch nicht gleich der Basisklasse. - Komposition bevorzugen: Lassen Sie
ColorPointeinenPointenthalten, anstattPointzu erben.
7. Fehler 4: Veränderliches Feld als Schlüssel
hashCode() wird berechnet, wenn das Objekt zu HashSet hinzugefügt wird. Wenn später ein an der Hash-Berechnung beteiligtes Feld sich ändert, bleibt das Objekt im alten Bucket:
public class Product {
private String sku;
public Product(String sku) { this.sku = sku; }
public void setSku(String sku) { this.sku = sku; }
@Override
public boolean equals(Object o) {
return o instanceof Product p && Objects.equals(sku, p.sku);
}
@Override
public int hashCode() { return Objects.hashCode(sku); }
}
Product product = new Product("SKU-1");
Set<Product> catalog = new HashSet<>();
catalog.add(product);
product.setSku("SKU-2");
System.out.println(catalog.contains(product));
System.out.println(catalog.remove(product));
System.out.println(catalog.size());
false
false
1
Das Objekt verbleibt im Set, aber Sie können es nicht finden und nicht löschen. Das Ergebnis ist ein kleines Speicherleck und falsche Logik.
Hinweis: Verwenden Sie nur unveränderliche (immutable) Felder in
equals()/hashCode(). Wenn Sie unvermeidlich ein veränderliches Feld verwenden müssen, ändern Sie das Objekt nicht, während es Schlüssel inHashMapoder Element inHashSetist. Der richtige Weg ist, das Objekt zu entfernen, es zu ändern und dann erneut hinzuzufügen.
8. Fehler 5: Fallen mit BigDecimal und Arrays
Einige Typen im JDK haben equals(), das nicht so funktioniert, wie Sie denken.
8.1. BigDecimal vergleicht auch die Genauigkeit (scale)
BigDecimal a = new BigDecimal("1.0");
BigDecimal b = new BigDecimal("1.00");
System.out.println(a.equals(b));
System.out.println(a.compareTo(b) == 0);
false
true
BigDecimal.equals() betrachtet 1.0 und 1.00 als unterschiedlich, weil die scale unterschiedlich ist. Wenn Ihre Money-Klasse BigDecimal in equals() verwendet, normalisieren Sie die scale zunächst. Beispielsweise rufen Sie amount.setScale(2, RoundingMode.HALF_UP) im Konstruktor auf. Beachten Sie, dass stripTrailingZeros() auch normalisiert, aber es kann zu negativer scale führen, zum Beispiel bei 100.
8.2. Arrays vergleichen nur Referenzen
Arrays überschreiben equals() nicht. Daher gibt new int[]{1, 2}.equals(new int[]{1, 2}) false zurück. Wenn das Feld ein Array ist, verwenden Sie Arrays.equals() und Arrays.hashCode(). Diese Falle gilt auch für record, wie Sie im nächsten Punkt sehen werden.
9. Verwenden Sie record, um die meisten Fehler zu vermeiden
Ab Java 16 generiert record automatisch equals(), hashCode() und toString() basierend auf allen Komponenten:
public record OrderLine(String productSku, int quantity) {}
var line1 = new OrderLine("SKU-1", 2);
var line2 = new OrderLine("SKU-1", 2);
System.out.println(line1.equals(line2));
System.out.println(line1.hashCode() == line2.hashCode());
true
true
record ist eine final-Klasse, die Felder sind final, und die beiden Methoden werden korrekt gemäß dem Vertrag generiert. Die Fehler bei Punkten 4, 5 und 6 verschwinden. Der Fehler bei Punkt 7 verschwindet fast auch, es sei denn, die Komponente ist ein veränderliches Objekt, beispielsweise eine mutable List.
Zwei Punkte, die Sie beim Verwenden von record beachten sollten:
- Eine Komponente, die ein Array ist, wird immer noch nach Referenz verglichen. Verwenden Sie
Listanstelle von Arrays. - Eine
BigDecimal-Komponente hat immer noch das scale-Problem. Normalisieren Sie im compact constructor:
public record Money(BigDecimal amount, String currency) {
public Money {
amount = amount.setScale(2, RoundingMode.HALF_UP);
}
}
Mit dieser Deklaration ist new Money(new BigDecimal("1.0"), "VND") gleich new Money(new BigDecimal("1.00"), "VND"), weil beide auf scale 2 normalisiert werden.
10. Testen von equals() und hashCode()
Sie sollten Tests für das Hauptverhalten haben. Hier ist ein Test mit JUnit 5 und AssertJ für die Klasse Customer aus Punkt 2:
import org.junit.jupiter.api.Test;
import java.util.HashSet;
import java.util.Set;
import static org.assertj.core.api.Assertions.assertThat;
class CustomerTest {
@Test
void equalCustomers_shouldHaveSameHashCode_andDeduplicateInSet() {
var a = new Customer("an@example.com", "An");
var b = new Customer("an@example.com", "An");
assertThat(a).isEqualTo(b);
assertThat(b).isEqualTo(a);
assertThat(a.hashCode()).isEqualTo(b.hashCode());
Set<Customer> set = new HashSet<>(Set.of(a));
assertThat(set).contains(b);
}
@Test
void customer_shouldNotEqualNullOrOtherType() {
var a = new Customer("an@example.com", "An");
assertThat(a).isNotEqualTo(null);
assertThat(a).isNotEqualTo("an@example.com");
}
}
Der erste Test überprüft Symmetrie, die Regel “gleich dann gleicher Hash” und das tatsächliche Verhalten bei der Verwendung in HashSet. Der zweite Test überprüft den Fall null und den Fall eines anderen Typs.
Alle Fälle manuell zu schreiben, ist ziemlich aufwändig. Die EqualsVerifier-Bibliothek überprüft den gesamten Vertrag mit nur einer Zeile:
<dependency>
<groupId>nl.jqno.equalsverifier</groupId>
<artifactId>equalsverifier</artifactId>
<version>3.16.1</version>
<scope>test</scope>
</dependency>
@Test
void equalsContract() {
EqualsVerifier.forClass(Customer.class).verify();
}
Wenn Sie ein Feld in hashCode() vergessen, ein Feld nicht als final verwenden oder eine Klasse nicht als final verwenden, aber instanceof verwenden, schlägt der Test fehl mit einer Meldung, die genau angibt, welche Regel verletzt wird.
11. Wann Sie NICHT überschreiben sollten
Nicht jede Klasse sollte equals()/hashCode() überschreiben:
- Klassen mit natürlicher Identität pro Instanz, wie
Thread,PaymentServiceoder Spring Beans. Zwei Services sind zwei unterschiedliche Objekte, und Referenzvergleich ist richtig. - JPA entity: Vermeiden Sie die Verwendung aller Felder. Verwenden Sie auch nicht naiv
@Idzum Selbstgenerieren, da dieidvor dem Persistnullist, wodurch sich der Hash nach dem Speichern ändert. Eine gängige Methode ist der Vergleich anhand eines unveränderlichen Business-Keys. Eine andere Methode ist der Vergleich nachid, wenn er nicht null ist, undhashCode()gibt eine Konstante pro Klasse zurück, beispielsweisegetClass().hashCode(). - Lombok
@Dataauf entity oder Klassen mit bidirektionalen Beziehungen: Diese Annotation generiertequals()/hashCode()mit allen Feldern. Dies kannStackOverflowErrorverursachen, wenn zwei Entities sich gegenseitig referenzieren, oder unbeabsichtigtes Lazy Loading auslösen.
12. Fazit
Der Vertrag zwischen equals() und hashCode() dreht sich um eine Kernregel: Zwei gleiche Objekte müssen denselben Hash-Code haben. Die meisten praktischen Fehler stammen aus fünf Gründen:
hashCode()vergessen zu überschreiben;equals(Order)überladen stattequals(Object)zu überschreiben;- Vererbung zerstört Symmetrie;
- veränderliche Felder als Schlüssel verwenden;
- Fallen mit
BigDecimalund Arrays.
Bei neuem Code sollten Sie record für Value Objects bevorzugen. Bei normalen Klassen deklarieren Sie final, fügen immer @Override hinzu und verwenden nur unveränderliche Felder. Fügen Sie schließlich einen Test mit EqualsVerifier hinzu, damit das Tool den Vertrag für Sie überprüft.