1. Sicherer Vergleich von Entitäten wichtiger als „Schönheit“
Wenn Sie zuvor equals()/hashCode() nur geschrieben haben, weil die IDE es beharrlich vorschlägt, dann herzlichen Glückwunsch: Sie treten in den Club der Menschen ein, die ORM nicht in der Theorie, sondern in der Produktion zum ersten Mal begegnen. Für Hibernate sind diese beiden Methoden keine Kosmetik, sondern das Fundament dafür, wie sich ein Objekt im Code verhält, besonders im Umgang mit Collections, Set/Map, Hilfsmethoden und gewöhnlichen „Eindeutigkeitsprüfungen“ im Speicher. Der Persistence Context lebt dabei nach eigenen Regeln: Der First-Level-Cache hält selbst einen verwalteten Objekt pro Zeile, aber wie sich verschiedene Java-Instanzen derselben Entität in Ihrem Code verhalten, ist bereits die Domäne von equals()/hashCode().
In einem normalen Java-Programm kann man lange ohne ein benutzerdefiniertes equals() leben: Referenzen verglichen – und gut. Aber in der Persistenzwelt trifft man ständig auf Situationen, in denen derselbe „Artikel“ (eine Zeile in product) zu unterschiedlichen Zeiten als zwei verschiedene Java-Objekte existieren kann. Und wenn sich diese beiden Objekte in Ihrem Code wie „verschieden“ verhalten, fangen Sie an, Merkwürdigkeiten zu erleben: Duplikate, Unmöglichkeit, ein Element aus einer Collection zu entfernen, „nicht gefunden“ in einem HashSet, usw. (Die detaillierten Schmerzen mit Set und orphanRemoval besprechen wir, wenn die Gleichheit auf Proxy und Collections trifft, und heute bereiten wir die Grundlage vor.)
2. Vertrag von equals()/hashCode() für Collections
Von außen sieht equals() aus wie „na ja, man muss es vergleichen“, und hashCode() wie „irgendeine Zahl, egal“. In Wirklichkeit betrachten Java-Collections (insbesondere hash-basierte) diese Methoden als einen rechtlichen Vertrag: Sie haben etwas versprochen – die Collection hat sich darauf optimiert – und wenn Sie das Versprechen brechen, ist die Collection nicht verpflichtet, sich Ihre gute Absicht „zu erraten“.
Das Hauptversprechen von equals() ist einfach: Der Vergleich soll stabil und logisch sein. Wenn a.equals(b) wahr ist, dann soll es bei einer erneuten Prüfung wahr bleiben, solange Sie das, was an der Identität beteiligt ist nicht geändert haben. equals() muss auch korrekt mit null umgehen und keine unerwarteten Ausnahmen werfen. Das Versprechen von hashCode() ist noch strenger: Wenn zwei Objekte nach equals() gleich sind, muss ihr hashCode() übereinstimmen. Und wünschenswert (nicht „muss“, aber äußerst wünschenswert) ist, dass sich hashCode() nicht ändert, solange das Objekt innerhalb eines HashSet/HashMap liegt.
Um dies zu festigen, ist es praktisch, eine kleine Tabelle im Kopf zu behalten:
| Eigenschaft | Was bedeutet das auf menschlich | Warum Hibernate-Entitäten besonders empfindlich sind |
|---|---|---|
| equals() muss stabil sein | „Ich ändere nicht jede Minute die Regeln, wer ich bin“ | Entitäten ändern Felder (Name, Status, Preis), und wenn Sie sie in die Gleichheit einbeziehen – brechen Sie selbst die Stabilität |
| hashCode() muss bei gleichen Objekten übereinstimmen | „Wenn wir gleich sind – haben wir einen ‚Barcode‘ für HashSet“ | Eine Entität kann id erst nach persist/flush erhalten, und wenn hashCode von id abhängt, wird der Barcode „neu gedruckt“ |
| hashCode() sollte in der Collection nicht springen | „In eine Zelle gelegt – liege unter derselben Adresse“ | Hash-Collections suchen das Objekt anhand des Hashs. Ändert sich der Hash → ändert sich das „Fach“ → das Objekt wird „unsichtbar“ |
3. Anti-Pattern: Vergleich „nach allen Feldern“
Ein Anfänger hat oft einen sehr ehrlichen Impuls: „Wenn zwei Artikel in allen Feldern übereinstimmen, dann sind sie gleich.“ Für DTOs ist das oft in Ordnung: Ein DTO ist ein Daten-Snapshot, und seine „Gleichheit nach Inhalt“ ergibt tatsächlich Sinn. Aber eine Entität ist kein Snapshot, sondern ein lebendiges Objekt, das sich ändert, teilweise geladen sein kann, Beziehungen, Collections und technische Felder enthalten kann.
Der einfachste Fehlschlag von „alle Felder“ ist bei hashCode() sichtbar. Wenn Sie ein veränderliches Feld einbezogen haben, wird das Objekt nach Änderung dieses Feldes zu einem „anderen“ für das HashSet. Das klingt abstrakt, aber schauen wir uns ein minimales Beispiel an (hier ist Product vereinfacht, ohne Annotationen, die Logik ist dieselbe):
import java.util.HashSet;
import java.util.Set;
// Demonstration, wie HashSet auf hashCode()/equals() basiert
Set<String> set = new HashSet<>();
// Füge Element in Set ein (es gelangt basierend auf dem Hash in den Eimer)
set.add("SKU-1");
// Prüfung funktioniert, weil Hash und equals bei String stabil sind
System.out.println(set.contains("SKU-1")); // true
Stellen Sie sich nun vor, dass Sie statt eines Strings ein Objekt haben und dessen hashCode() von name abhängt. Sie haben es in ein HashSet eingefügt, dann name geändert, und das HashSet beginnt, das Objekt zu „verlieren“. In der Praxis sieht das aus wie „Was für ein Unsinn, ich habe es gerade erst hinzugefügt!“. Und das ist einer der demoralisierendsten Bugs: Sie verstehen nicht, was kaputt ist – die Geschäftslogik oder die Collection – und schuld ist … hashCode().
Hier ist ein solches Anti-Pattern für eine Entität (wir schreiben es absichtlich „schlecht“, damit Sie es als rote Flagge erkennen):
import java.util.Objects;
// innerhalb von Product
@Override
public int hashCode() {
// Schlecht: name kann geändert werden -> hashCode instabil -> HashSet/HashMap können Objekt „verlieren“
return Objects.hash(name);
}
Das Problem liegt nicht in Objects.hash(...). Das Problem liegt in der Idee: name ist keine Identität. Es ist ein Anzeigefeld. Es wird sich ändern (Tippfehler werden korrigiert, Marketing benennt um, Lokalisierung kommt hinzu). Wenn Sie es zu einem Teil der Identität gemacht haben, verwandeln Sie das Objekt bei jeder normalen Änderung in einen „anderen Menschen“ mitten im Leben.
4. Generated id und Fallstricke der Gleichheit nach id
Wenn ein Entwickler zum ersten Mal @Id @GeneratedValue sieht, hat er einen sehr starken Impuls: „Super, hier ist der Identifikator – wir vergleichen danach“. Und bei gespeicherten Entitäten sieht das sogar logisch aus. Aber es gibt ein unangenehmes Detail: Bei einer neuen Entität ist vor dem Speichern id normalerweise null.
Wenn Sie equals() so schreiben:
import java.util.Objects;
@Override
public boolean equals(Object other) {
// Dies ist bewusst ein „schlechtes“ Beispiel: es gibt keinen Schutz gegen id == null
if (!(other instanceof OrderItem item)) return false;
// Falle: null == null -> true, also sind zwei NEUE Entitäten plötzlich „gleich“
return Objects.equals(id, item.id);
}
Dann wären zwei neue Entitäten gleich, weil Objects.equals(null, null) true zurückgibt. Domänenbezogen ist das in der Regel absurd: Zwei verschiedene Auftragspositionen sind plötzlich „gleich“, nur weil beide es noch nicht in die Datenbank geschafft haben.
Fast unmittelbar danach tritt der zweite Fehlschlag auf: Wenn Sie hashCode() auf Basis von id berechnen, wird es bei einer neuen Entität auf null berechnet, und nach persist/flush wird es auf den tatsächlichen Wert berechnet. Das heißt, hashCode ändert sich „mitten im Leben“, und hash-basierte Collections beginnen sich so zu verhalten, als ob das Objekt verschwunden wäre.
Diese Situation lässt sich praktisch als kleine Zeitachse darstellen:
flowchart TD
%% Idee: Nach persist/flush erscheint id, und hashCode „springt“ in einen anderen Eimer
A["Neue Entität: id=null"] --> B["hashCode() berechnet auf null"]
B --> C["persist/flush"]
C --> D["id=42"]
D --> E["hashCode() jetzt anders"]
E --> F["HashSet/HashMap: ich sehe dich nicht"]
Der Punkt: Generated id ist gut als Identität, nachdem es erschienen ist. Aber bis zu diesem Moment kann es nicht die einzige Stütze für equality sein – sonst erhalten Sie eine „Gleichheit durch Abwesenheit“.
5. Strategie Geschäftsschlüssel (Business Key)
Wenn Sie „business key“ hören, denken Sie nicht „irgendein Feld, das irgendwie wichtig ist“. Ein Business Key ist ein Merkmal, an dem ein Objekt als „dasselbe“ erkannt werden kann, unabhängig von der technischen id. In unserem Projekt Commerce Persistence Lab sind das zum Beispiel Product.sku, Customer.email, PurchaseOrder.orderNumber. Wichtige Bedingung: Ein solcher Schlüssel muss eindeutig und stabil sein. Wenn er „sich manchmal ändert“, dann hört er auf, eine sichere Grundlage für hashCode() zu sein.
An dieser Stelle ist eine Metapher nützlich. Ein Business Key ist wie eine Passnummer (oder zumindest eine Ausweisnummer), nicht wie die Farbe einer Jacke. Die Jacke kann man wechseln. Den Pass (im idealen Modell) nicht. Wenn Sie die Identität auf die Farbe der Jacke stützen, wird die Person nach einem Einkaufsbummel zu einem „anderen“. Mit name/status/price ist es ähnlich.
Für Product mit sku ist der Geschäftsschlüssel ideal. Dann können equals() und hashCode() auf Basis von sku erstellt werden, und das Objekt wird vor und nach dem Speichern vorhersagbar sein:
import java.util.Objects;
@Override
public boolean equals(Object o) {
// Schneller Weg: Referenzvergleich
if (this == o) return true;
// Schutz vor Vergleich mit Objekt eines anderen Typs
if (!(o instanceof Product other)) return false;
// Vergleiche NUR den stabilen eindeutigen Geschäftsschlüssel
return Objects.equals(sku, other.sku);
}
@Override
public int hashCode() {
// Hash ebenfalls auf Basis desselben Geschäftsschlüssels (wichtig für den Vertrag)
return Objects.hash(sku);
}
Beachten Sie ein wichtiges (und langweiliges) praktisches Detail: Wir berühren keine Beziehungen, keine Collections, keine „schönen“ Felder. Wir vergleichen ein einziges Feld. Ja, das sieht zu einfach aus, aber gerade die Einfachheit ist hier die Qualität.
Und noch ein Detail: Wenn Sie sku als Schlüssel gewählt haben, müssen Sie ihn als unveränderlich behandeln. Im Studienprojekt ist das am einfachsten banal zu gewährleisten: keinen öffentlichen setSku(...) machen. Wenn das Geschäft es erfordert, „sku zu ändern“, dann ist das ein separates Migrations-/Ersetzungsszenario, kein gewöhnlicher Setter.
6. Strategie: Nur gespeicherte sind gleich
Es kommt vor, dass eine Entität keinen guten Geschäftsschlüssel hat. Zum Beispiel ist OrderItem eine Child-Entität, und ihre „domänenspezifische Eindeutigkeit“ wird oft nicht durch ein einzelnes einfaches Feld ausgedrückt, sondern durch den Kontext von Bestellung + Produkt + Reihenfolge + etwas anderem. Zu versuchen, daraus einen Geschäftsschlüssel für equals() zu bauen, ist ein häufiger Weg dazu, dass equals() plötzlich beginnt, den Graphen zu durchlaufen, Beziehungen zu ziehen und sich in Mini-Geschäftslogik zu verwandeln.
In solchen Fällen gibt es eine sehr pragmatische Strategie: Nur die Entitäten als gleich betrachten, die bereits eine id haben (d.h. sie existieren tatsächlich als Zeile in der DB). Neue, noch nicht gespeicherte Objekte betrachten wir als unterschiedlich, selbst wenn sie „ähnlich“ sind. Das ist nicht romantisch, aber sehr sicher.
Die Schlüsselelemente dieses Ansatzes: In equals() vergleichen wir id, aber nur wenn es nicht null ist, und in hashCode() geben wir einen stabilen Wert zurück, der sich nach der Zuweisung von id nicht ändert. Die einfachste Variante ist eine Konstante.
import java.util.Objects;
@Override
public boolean equals(Object o) {
// Schneller Weg: Referenzvergleich
if (this == o) return true;
// Schutz vor Vergleich mit Objekt eines anderen Typs
if (!(o instanceof OrderItem other)) return false;
// Wichtig: Neue Entitäten (id == null) NICHT als gleich betrachten
return id != null && Objects.equals(id, other.id);
}
@Override
public int hashCode() {
// Stabiler Hash: hängt nicht von generated id ab und „springt“ nicht nach persist/flush
return 31;
}
Ja, das bedeutet, dass alle OrderItem denselben hashCode() haben. Das HashSet wird nicht zerfallen, es wird nur häufiger equals() innerhalb eines „Eimer“-Segments aufrufen. In Lehr- und den meisten Anwendungsszenarien ist das in Ordnung: Korrektheit ist wichtiger als Mikrooptimierung, und Leistungsprobleme behandeln wir zuerst mit SQL und Fetching, nicht mit „Super-Hashes“.
Der Hauptsinn dieses Ansatzes ist: Sie brechen keine Collections durch Änderung von id und machen zwei neue Entitäten nicht „gleich“ aufgrund von null.
7. Vereinbarung im Commerce Persistence Lab
Im Studienprojekt ist es wichtig, nicht den „einzig richtigen Weg für alle Zeiten“ zu erfinden, sondern eine Vereinbarung zu treffen, die Vorhersagbarkeit bietet und nicht mit dem Verhalten von Hibernate in Konflikt gerät. Wir haben Entitäten, bei denen der Geschäftsschlüssel natürlich und bequem ist, und Entitäten, bei denen er entweder fehlt oder gefährlich ist (Beziehungen und Graphen nach sich ziehen würde).
Daher sieht die sinnvolle Verteilung so aus: Bei Product, Customer, PurchaseOrder stützen wir uns auf stabile eindeutige Schlüssel (sku, email, orderNumber). Bei technischen „internen“ Entitäten wie OrderItem und der Link-Entity ProductCategoryAssignment verwenden wir die Strategie „nur nach id gleich, wenn sie bereits existiert“. Das bringt zwei sehr praktische Vorteile: Aggregatwurzeln können „domänenspezifisch“ verglichen werden, und interne Elemente verwandeln equality nicht in einen Graphdurchlauf.
Das ist die Arbeitsvereinbarung des Projekts: Jetzt ist es wichtig, sie nicht im laufenden Betrieb zu ändern, sondern zu verstehen, wie sie Proxy-Runtime und Collections übersteht.
Es ist praktisch, dies in Form einer kleinen Entscheidungstabelle festzuhalten:
| Entität | Was wir als Identität betrachten | Warum das in unserem Kurs sicherer ist |
|---|---|---|
| Product | sku | Eindeutig, stabil, domänenverständlich |
| Customer | Eindeutig im Modell, leicht überprüfbar | |
| PurchaseOrder | orderNumber | Eindeutig und normalerweise unveränderlich |
| OrderItem | id (nur wenn nicht ) |
Die Position hat keinen bequemen Single-Field-Schlüssel |
| ProductCategoryAssignment | id (nur wenn nicht ) |
Wir wollen nicht in equality einbeziehen |
Wichtig: Das funktioniert gut nur, wenn wir die Eindeutigkeit der Schlüssel tatsächlich auf Schemaebene unterstützen (bei uns ist das Teil der Projektanforderungen durch Flyway-Migrationen). ORM sollte nicht „auf’s Wort glauben“: Wenn Sie sagen, dass sku eindeutig ist, sollte die Datenbank das auch wissen.
Vorlagen für equals()/hashCode()
Wenn Studenten hören „schreibt equals/hashCode manuell“, erwarten sie oft 30 Zeilen und Magie. In Wirklichkeit ist eine gute Variante fast immer kurz. Je weniger Logik, desto geringer die Wahrscheinlichkeit, dass equality versehentlich von Lazy Loading abhängt, das Verhalten nach einem Update ändert oder sich mit einem Bug „anfreundet“.
Für eine Business-Key-Entität (z.B. Product) halten wir die Vorlage maximal langweilig und direkt:
import java.util.Objects;
// innerhalb von Product
@Override
public boolean equals(Object o) {
// Schneller Weg: Referenzvergleich
if (this == o) return true;
// Schutz vor Vergleich mit Objekt eines anderen Typs
if (!(o instanceof Product other)) return false;
// Vergleiche NUR den stabilen eindeutigen Geschäftsschlüssel
return Objects.equals(sku, other.sku);
}
@Override
public int hashCode() {
// Hash ebenfalls auf Basis desselben Geschäftsschlüssels (wichtig für den Vertrag)
return Objects.hash(sku);
}
Für eine Entität ohne bequemen Business Key (z.B. OrderItem) halten wir die zweite Vorlage:
import java.util.Objects;
// innerhalb von OrderItem
@Override
public boolean equals(Object o) {
// Schneller Weg: Referenzvergleich
if (this == o) return true;
// Schutz vor Vergleich mit Objekt eines anderen Typs
if (!(o instanceof OrderItem other)) return false;
// Wichtig: Neue Entitäten (id == null) NICHT als gleich betrachten
return id != null && Objects.equals(id, other.id);
}
@Override
public int hashCode() {
// Stabiler Hash: hängt nicht von generated id ab und „springt“ nicht nach persist/flush
return 31;
}
Wenn Sie bemerkt haben, gibt es hier bewusst keine Versuche, den Hash zu „verbessern“. Uns ist wichtiger, dass er sich nicht ändert und nicht von mutable-Feldern abhängt. Wenn ein Anfänger versucht, hashCode() „schön“ zu machen und mehr Felder einzubeziehen, macht er es normalerweise versehentlich instabil. In einem Hibernate-Projekt ist das fast immer schlechter als etwas häufigere Vergleiche innerhalb eines HashSet.
8. Typische Fehler beim Schreiben von equals() und hashCode() für Entitäten
In diesem Thema sind Fehler besonders tückisch: Das Projekt kann kompilieren, Tests können bestehen, Daten in der DB sind korrekt – und dann fangen Sie plötzlich „Geister“ in Collections oder seltsame Duplikate. Das liegt nicht daran, dass Java „kaputt“ ist, sondern weil der Vertrag von equals()/hashCode() sehr streng ist und ORM-Entitäten leicht seine Verletzung provozieren.
Fehler Nr. 1: „Ich vergleiche alle Felder, um fair zu sein“.
Das verwandelt Identität fast immer in eine Abhängigkeit von mutable-Zustand. Felder wie name, status, price, description ändern sich im Geschäft ständig, und wenn sie an hashCode() beteiligt sind, ändert das Objekt seine „Adresse“ in der Hash-Collection. Ergebnis: contains()/remove() verhalten sich, als ob das Objekt verschwunden wäre.
Fehler Nr. 2: equals() nach id über Objects.equals(id, other.id) ohne Null-Prüfung.
Zwei neue Entitäten haben id == null und werden plötzlich gleich. Das ist eine der häufigsten Fallen: Es sieht logisch aus, bis Sie zwei Objekte vor dem Speichern erstellen. Richtig ist, nur die Entitäten als gleich nach id zu betrachten, deren id bereits nicht null ist.
Fehler Nr. 3: hashCode() hängt von generated id ab.
Vor persist/flush gibt es keine id, danach schon. Wenn hashCode auf Basis von id berechnet wird, ändert er sich. Hash-Collections verzeihen das nicht. Selbst wenn es bei Ihnen „irgendwie funktioniert“, das ist eher Glück als Garantie. Stabilität ist hier wichtiger als schöne Zahlen.
Fehler Nr. 4: equals() und hashCode() stützen sich auf verschiedene Felder.
Zum Beispiel vergleicht equals() sku, aber hashCode berechnet nach id. Das ist ein direkter Vertragsbruch: Zwei Objekte können gleich sein, aber unterschiedliche Hashes haben. Dann verhält sich das HashSet wie ein Wachmann, der das Gesicht mit dem Pass vergleicht, aber den Ausweis nach dem Nachnamen in der Liste sucht: Es geht um die Person, aber das System stimmt nicht überein.
Fehler Nr. 5: Ein Geschäftsschlüssel wurde gewählt, aber er ist tatsächlich nicht stabil.
Wenn Sie email als Identität wählen und dann erlauben, es mit einem gewöhnlichen Setter zu ändern, geraten Sie wieder in das Problem „hashCode ändert sich“. Ein Geschäftsschlüssel taugt nur, wenn Sie wirklich bereit sind, ihn als Identität zu betrachten und vor versehentlicher Änderung zu schützen.
GO TO FULL VERSION