The equals() and hashCode() contracts
The five equals properties, the hashCode rule, how HashMap uses hash then equals, and the real ways it breaks: missing hashCode, mutable keys, subclass symmetry, JPA entities and Lombok.
On this page
🤝 What are the equals() and hashCode() contracts?
They are two promises every Java class makes through java.lang.Object. equals must behave like a real equality relation (reflexive, symmetric, transitive, consistent, and false for null), and hashCode must return the same number for any two objects that are equal. HashMap, HashSet and every cache in your system rely on those promises. Break them and a map.get(key) returns null for a key you put in two lines earlier.
For this lesson, we'll focus on what the two contracts say, how HashMap uses them step by step, the four classic ways to break them, and the production traps: mutable keys, JPA entities and Lombok. This is the most-asked "Object class" question in Java interviews, because it connects a small method you write every week to a data structure you use every hour. A candidate who can explain the bucket lookup convinces me they understand both.
Below the line (out of scope)
- Ordering with
ComparableandComparator: Comparing objects in Java - HashMap resizing, treeification and concurrency in full depth (Collections module)
- Records beyond their generated methods: Records in Java
The 30-second answer
equals defines logical equality and must be reflexive, symmetric, transitive, consistent and return false for null. hashCode must be stable while the fields used by equals don't change, and equal objects must have equal hash codes (unequal objects may collide). HashMap uses hashCode to pick a bucket, then equals to find the key inside it, so if you override equals without hashCode, lookups go to the wrong bucket and miss. Use immutable fields in both, prefer instanceof in final classes (or records, which generate both), and never put a key in a map and then mutate it.
What you’ll learn
- State the five equals properties and the hashCode contract (equal objects, equal hash codes)
- Walk through a
HashMaplookup:h ^ (h >>> 16),(n - 1) & hash, then stored hash,==andequals - Explain why overriding
equalswithouthashCode, or mutating a key, makes entries disappear - Choose between
instanceofandgetClass()and explain the subclass symmetry problem - Know what
Objects.hashcosts, where the identity hash lives, and what records generate - Write safe equality for JPA entities and avoid Lombok's default
@EqualsAndHashCodethere
Interview radar
Asked very often: What is the contract between equals and hashCode, and what breaks if you override only equals?
Senior rounds probe: Why instanceof versus getClass() breaks symmetry with subclasses, why mutable keys get lost in a HashMap, and how JPA entities should implement equality.
Production angle: A cache or HashSet that keeps growing with "duplicate" entries, or a key that can never be removed after a field changed.
01Picture it first: a library with numbered shelves
Think of a library that shelves books by a number computed from the title. To find a book, you compute the number, walk to that shelf, then read the spines on that shelf one by one until the title matches. The number is hashCode. Reading the spines is equals.
Now you can see the rule. If two copies of the same book could produce different shelf numbers, you'd walk to the wrong shelf and never find the book, even though it's in the building. Two different books sharing a shelf is fine; you read a few extra spines. That's exactly why equal objects must have equal hash codes, but unequal objects are allowed to collide.
The diagram below shows the same thing inside a HashMap with 16 buckets.
A HashMap lookup: hashCode is spread and masked to pick bucket 2, then each node in that bucket is checked by hash value and equals until the matching key is found
02The two contracts, written out
Object.equals javadoc lists five properties. For non-null references x, y, z:
| Property | Rule | What breaks it in practice |
|---|---|---|
| Reflexive | x.equals(x) is true |
almost never broken (NaN-style fields) |
| Symmetric | x.equals(y) ⇔ y.equals(x) |
subclass adds a field and uses instanceof |
| Transitive | x.equals(y) and y.equals(z) ⇒ x.equals(z) |
"equal to a parent if fields match" hacks |
| Consistent | repeated calls give the same answer if nothing changed | equality based on a clock, network, or a lazily loaded field |
| Non-null | x.equals(null) is false |
casting before checking |
And hashCode has three rules:
- Called repeatedly on the same object, it returns the same
int, as long as the fields used byequalsdon't change. It may differ between JVM runs. - If
x.equals(y), thenx.hashCode() == y.hashCode(). This is the one that matters. - Unequal objects may share a hash code, but fewer collisions mean faster hash tables.
It helps to see all four combinations of equals and hashCode at once, because only one of them is illegal.
A two by two grid of equals true or false against same or different hashCode: equal with the same hash is required, equal with different hashes breaks the contract, unequal with the same hash is a legal collision, and unequal with different hashes is the normal case
Here's a correct pair for a small value class, the way I'd write it by hand:
public final class Sku {
private final String code;
private final int sizeCm;
public Sku(String code, int sizeCm) {
this.code = Objects.requireNonNull(code);
this.sizeCm = sizeCm;
}
@Override
public boolean equals(Object o) {
if (this == o) return true; // cheap identity shortcut
if (!(o instanceof Sku other)) return false; // also handles null
return sizeCm == other.sizeCm && code.equals(other.code);
}
@Override
public int hashCode() {
return 31 * code.hashCode() + sizeCm; // same fields as equals
}
}
Notice three things. The instanceof pattern (Java 16+) handles null for free, because null instanceof Sku is false. The class is final, which is what makes instanceof safe here (more on that below). And hashCode uses exactly the fields equals uses, never more. Using fewer is legal but causes collisions; using more breaks rule 2.
03How HashMap uses them under the hood
When you call map.get(key) on a JDK 21 HashMap, this happens:
- Hash and spread.
HashMap.hash(key)computesh = key.hashCode()and returnsh ^ (h >>> 16). The XOR folds the high 16 bits into the low ones, because only the low bits pick the bucket. Anullkey gets hash0. - Pick a bucket. The index is
(n - 1) & hash, wherenis the table length, always a power of two (16 by default). With 16 buckets, only the low 4 bits matter. - Walk the bucket. For each node, compare the stored hash first (
node.hash == hash), then identity (node.key == key), and only thenkey.equals(node.key). The stored-hash check skips mostequalscalls. - Trees for bad buckets. When one bucket reaches 8 nodes and the table has at least 64 buckets, it converts to a red-black tree, so a worst-case bucket costs O(log n) instead of O(n).
That flow explains every bug in the rest of this lesson. hashCode decides where to look. equals decides what matches. Both have to agree.
Try it yourself first
This class overrides equals but not hashCode. What does the last line print?
static final class Sku {
private final String code;
Sku(String code) { this.code = code; }
@Override
public boolean equals(Object o) {
return o instanceof Sku other && code.equals(other.code);
}
// no hashCode()
}
Map<Sku, Integer> stock = new HashMap<>();
stock.put(new Sku("TSHIRT-M"), 40);
System.out.println(stock.get(new Sku("TSHIRT-M"))); // null
The two Sku objects are equals, but hashCode is still Object's identity hash, a different random-looking number for each instance. The lookup goes to a different bucket (or the same bucket with a different stored hash) and never reaches equals. It prints null almost every time. "Almost" is worse than "always": with a 1-in-16 chance of landing in the same bucket and a tiny chance of an identical hash, the bug can hide in tests.
Many candidates say "if you override equals without hashCode, the code won't compile" or "the map throws". Neither is true. It compiles, runs and silently misses, and you can even end up with two "equal" keys in the same map. IDEs and static analysis warn about it (EqualsHashCode in Error Prone, Sonar rule S1206), which is one reason those tools exist.
04Real world: the key that got lost
Here's the bug that costs people an afternoon. A key goes into a HashSet, then someone mutates a field used by hashCode.
Try it yourself first
Predict all three values before reading the comment.
static final class Cart {
final List<String> items = new ArrayList<>();
@Override public boolean equals(Object o) {
return o instanceof Cart c && items.equals(c.items);
}
@Override public int hashCode() { return items.hashCode(); }
}
Set<Cart> activeCarts = new HashSet<>();
Cart cart = new Cart();
activeCarts.add(cart); // stored in the bucket for hash of []
cart.items.add("book"); // hash changes, object does not move
System.out.println(activeCarts.contains(cart)); // false
System.out.println(activeCarts.remove(cart)); // false
System.out.println(activeCarts.size()); // 1
The set still holds the cart (you'll see it when you iterate), but contains and remove compute the new hash and look in a different bucket. An empty ArrayList hashes to 1, so the cart was filed in bucket 1 of 16; with "book" in it the hash becomes 3029768, which maps to bucket 6. The entry is stranded. In a long-running service this is a memory leak with no stack trace: sessions, carts or listeners that can never be removed. The figure below shows the before and after.
Before mutation, the Cart sits in bucket 1 matching its hash; after adding an item its hash points to bucket 6, so contains and remove look there and miss the stranded entry in bucket 1
I've seen this exact shape in a WebSocket gateway: connection objects were kept in a HashSet, and hashCode included a lastSeen timestamp that a heartbeat updated. Disconnects called set.remove(conn), which silently returned false. The set grew by a few thousand dead connections an hour until the heap filled up. The fix was to base equals and hashCode on the immutable connection ID only.
The rule I give teams: fields used in equals and hashCode should be final. If the class must be mutable, base equality on an immutable identifier, or don't use it as a key.
05Advanced: subclasses, hashing cost, identity hash, records, JPA and Lombok
instanceof vs getClass, and why subclasses break symmetry
Suppose Point(x, y) uses instanceof Point in equals, and ColorPoint extends Point adds a color field and checks instanceof ColorPoint:
Point p = new Point(1, 2);
ColorPoint cp = new ColorPoint(1, 2, "red");
System.out.println(p.equals(cp)); // true (cp is a Point, x and y match)
System.out.println(cp.equals(p)); // false (p is not a ColorPoint)
Symmetry is broken. If you "fix" it by letting ColorPoint ignore colour when comparing to a plain Point, you break transitivity instead: red(1,2) equals (1,2) equals blue(1,2), but red doesn't equal blue. Effective Java (Item 10) states the conclusion: there is no way to extend an instantiable class, add a value field, and keep the equals contract with instanceof.
Your options:
| Approach | Symmetric? | Cost |
|---|---|---|
instanceof in a final class (or record) |
yes | can't subclass; this is my default |
getClass() != o.getClass() |
yes | a subclass that adds no state is never equal to its parent, which breaks Liskov substitution and breaks Hibernate proxies |
Composition: ColorPoint has a Point |
yes | a bit more code, the cleanest design |
Objects.hash vs the hand-written 31 * h
Objects.hash(code, sizeCm) is convenient, and for most classes you should use it. Know what it costs: it's a varargs call, so it allocates an Object[] (about 24 to 32 bytes for 2 or 3 fields with compressed oops) and boxes every primitive (sizeCm outside -128 to 127 becomes a new 16-byte Integer) on every call. The JIT's escape analysis can sometimes remove those allocations, but you can't rely on it.
The hand-written form, 31 * code.hashCode() + sizeCm, allocates nothing. Why 31? It's an odd prime, so multiplying doesn't throw away low bits the way an even multiplier would, and 31 * h can be computed as (h << 5) - h. String.hashCode uses the same polynomial and caches the result in a field, so hashing the same String key twice is nearly free.
For a DTO hashed a few times per request, Objects.hash is fine. For a key hashed millions of times per second (a cache key, a dedup key in a stream job), write it by hand or cache it in a field when the class is immutable. That turns an allocation per lookup into zero.
Where the identity hash code lives
If you don't override hashCode, Object.hashCode() returns the identity hash code, the same value System.identityHashCode(obj) gives you. A common myth is that it's the memory address. It isn't, and it can't be: the garbage collector moves objects, and the hash must stay the same.
On 64-bit HotSpot, the identity hash is generated lazily the first time it's asked for (by default from a thread-local xor-shift random generator) and stored in 31 bits of the object's mark word, the first 8 bytes of the header. From then on it travels with the object through every GC move. If the object is locked when the hash is needed, the hash lives in the displaced header or the inflated monitor instead. Every object has room for it, so asking for an identity hash costs nothing in memory.
Records do it for you (mostly)
A record generates equals and hashCode from all its components through an invokedynamic bootstrap (java.lang.runtime.ObjectMethods). Reference components are compared with Objects.equals, primitives with the wrapper's compare semantics. Records are final and immutable at the field level, which removes most of the problems in this lesson.
The catch is array and mutable components. record Tags(String[] names) compares arrays by reference, so two Tags with the same strings are not equal. And record Cart(List<String> items) passed a mutable ArrayList can still have its hash change. Copy with List.copyOf in the compact constructor, as covered in Deep copy vs shallow copy.
The JPA entity trap
JPA entities are the hardest case, because the ID is usually generated on save. If equals and hashCode use id, a new Payment has id == null, goes into a HashSet with one hash, gets an ID after persist, and now has a different hash: the mutable-key bug again. If you use all fields, lazy associations get loaded (or throw LazyInitializationException) only to compute a hash.
The two patterns that work:
@Entity
public class Payment {
@Id @GeneratedValue private Long id;
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof Payment other)) return false; // instanceof, proxies are subclasses
return id != null && id.equals(other.getId()); // getter, proxy fields are empty
}
@Override
public int hashCode() {
return Payment.class.hashCode(); // constant: stable before and after persist
}
}
The constant hash looks wrong but is legal (equal objects have equal hashes) and stable across the entity's lifecycle. It degrades a HashSet of entities to a list, which is acceptable for the small collections entities usually live in. The better option, when you have one, is an immutable natural business key such as an ISBN or an external payment reference.
Lombok @EqualsAndHashCode: read before you annotate
@EqualsAndHashCode (and @Data, which includes it) generates both methods from every non-static, non-transient field. That's a problem in three places:
- On JPA entities, it pulls in lazy collections and the generated ID. With bidirectional relationships (
Orderhasitems, eachOrderItemhasorder), hashing recurses untilStackOverflowError. - It includes mutable fields, which brings back the lost-key bug.
callSuperdefaults tofalse, so parent fields are ignored. Lombok warns you when you extend a class without setting it.
If you use Lombok, opt in to specific fields: @EqualsAndHashCode(onlyExplicitlyIncluded = true) plus @EqualsAndHashCode.Include on the business key, and never put @Data on an entity.
06How to say it in the interview
When I ask this, I'm listening for three things: the one rule that links the two methods ("equal objects, equal hash codes"), the HashMap lookup order (bucket from the hash, then equals inside the bucket), and at least one real failure mode, ideally the mutable key. Senior candidates add instanceof vs getClass symmetry and the JPA ID problem without being prompted.
In the interview
"equals has to be reflexive, symmetric, transitive, consistent and false for null. hashCode has to be stable, and equal objects must return equal hash codes; collisions between unequal objects are allowed. The reason is HashMap: it spreads the hash, masks it to pick a bucket, then compares stored hash and equals inside that bucket. Override equals without hashCode and equal keys land in different buckets, so get returns null. I build both from the same immutable fields, use instanceof in a final class or a record, and I never mutate a key after inserting it, because the entry gets stranded in the old bucket. For JPA entities I use a business key, or an ID-based equals with a constant hashCode, and I avoid Lombok's default @EqualsAndHashCode there."
Here's how this might look if you sketch it on the whiteboard:
On the whiteboard
equals / hashCode
- equals: reflexive, symmetric, transitive, consistent,
x.equals(null) == false - a.equals(b) ⇒ a.hashCode() == b.hashCode() (not the reverse)
- HashMap:
h ^ (h >>> 16)→(n-1) & h→ hash, ==, equals in bucket - equals w/o hashCode → get() returns null
- mutate a key → stranded entry, leak
- final class + instanceof, or record · getClass breaks proxies
- identity hash: lazy, 31 bits in mark word, not an address
- JPA: business key or id + constant hashCode
Further reading (9)
- Object.equals and Object.hashCode (Java SE 21 API)
- HashMap (Java SE 21 API)
- Record.equals (Java SE 21 API)
- ObjectMethods bootstrap (Java SE 21 API)
- JEP 395: Records
- JEP 374: Deprecate and Disable Biased Locking
- Effective Java, 3rd ed., Items 10 and 11: equals and hashCode
- Lombok: @EqualsAndHashCode
- Baeldung: Java equals() and hashCode() Contracts