Course contents

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.

FreeMust know for interviewsLesson15 min readCoreReviewed Oct 2026 · Java 21
On this page
  1. Picture it first: a library with numbered shelves
  2. The two contracts, written out
  3. How HashMap uses them under the hood
  4. Real world: the key that got lost
  5. Advanced: subclasses, hashing cost, identity hash, records, JPA and Lombok
  6. How to say it in the interview

🤝 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 Comparable and Comparator: 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 HashMap lookup: h ^ (h >>> 16), (n - 1) & hash, then stored hash, == and equals
  • Explain why overriding equals without hashCode, or mutating a key, makes entries disappear
  • Choose between instanceof and getClass() and explain the subclass symmetry problem
  • Know what Objects.hash costs, where the identity hash lives, and what records generate
  • Write safe equality for JPA entities and avoid Lombok's default @EqualsAndHashCode there

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.

Fig. 1

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

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:

  1. Called repeatedly on the same object, it returns the same int, as long as the fields used by equals don't change. It may differ between JVM runs.
  2. If x.equals(y), then x.hashCode() == y.hashCode(). This is the one that matters.
  3. 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.

Fig. 2

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

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:

  1. Hash and spread. HashMap.hash(key) computes h = key.hashCode() and returns h ^ (h >>> 16). The XOR folds the high 16 bits into the low ones, because only the low bits pick the bucket. A null key gets hash 0.
  2. Pick a bucket. The index is (n - 1) & hash, where n is the table length, always a power of two (16 by default). With 16 buckets, only the low 4 bits matter.
  3. Walk the bucket. For each node, compare the stored hash first (node.hash == hash), then identity (node.key == key), and only then key.equals(node.key). The stored-hash check skips most equals calls.
  4. 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.

Where candidates slip

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.

Fig. 3

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

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
In the real world

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.

Performance angle

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 (Order has items, each OrderItem has order), hashing recurses until StackOverflowError.
  • It includes mutable fields, which brings back the lost-key bug.
  • callSuper defaults to false, 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
Practise this topicThe Object contract: interview questions & scenarios
Further reading (9)