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 immer true.
  • Symmetrisch (symmetric): x.equals(y) ist true genau dann, wenn y.equals(x) true ist.
  • Transitiv (transitive): Wenn x.equals(y) und y.equals(z), dann x.equals(z).
  • Konsistent (consistent): Wenn das Objekt unveränderlich ist, muss das Aufrufen mehrmals das gleiche Ergebnis geben.
  • Mit null: x.equals(null) ist immer false.

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 == o ist eine Abkürzung, wenn beide Referenzen auf dasselbe Objekt zeigen.
  • instanceof Customer other verwendet Pattern Matching (Java 16+). Dieser Ausdruck gibt false zurück, wenn o null ist, daher ist die Eigenschaft “mit null” gewährleistet, ohne dass eine separate Überprüfung erforderlich ist.
  • Objects.equals ist sicher mit null-Feldern.
  • Objects.hash berechnet den Hash genau aus den Feldern, die in equals() verwendet werden.

Die Klasse wird als final deklariert. Der Grund wird in Punkt 6 deutlich.

3. Der Vertrag von hashCode()

hashCode() hat drei Regeln:

  1. Das mehrfache Aufrufen derselben unveränderlichen Objekt muss denselben Wert zurückgeben.
  2. Wenn a.equals(b), dann a.hashCode() == b.hashCode(). Dies ist die wichtigste Regel.
  3. 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 final oder verwenden Sie record. Dies ist die einfachste Methode.
  • Verwenden Sie getClass() != o.getClass() anstelle von instanceof. In diesem Fall sind Point und ColorPoint nie 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 ColorPoint einen Point enthalten, anstatt Point zu 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 in HashMap oder Element in HashSet ist. 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 List anstelle 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, PaymentService oder 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 @Id zum Selbstgenerieren, da die id vor dem Persist null ist, 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 nach id, wenn er nicht null ist, und hashCode() gibt eine Konstante pro Klasse zurück, beispielsweise getClass().hashCode().
  • Lombok @Data auf entity oder Klassen mit bidirektionalen Beziehungen: Diese Annotation generiert equals()/hashCode() mit allen Feldern. Dies kann StackOverflowError verursachen, 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 statt equals(Object) zu überschreiben;
  • Vererbung zerstört Symmetrie;
  • veränderliche Felder als Schlüssel verwenden;
  • Fallen mit BigDecimal und 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.

javaequalshashcodecollectionsrecord