RizTech Academy logo
RizTech Academy
CollectionsLesson 5 of 730 min

equals and hashCode: the contract you must not break

Every lesson in this module has quietly depended on two methods you have not written. contains, indexOf, remove(Object), Set uniqueness, and every Map key go through equals and hashCode.

For String, Integer and records they are already correct. For a class you write yourself, they are not — and the failure is silent. A set that holds two identical customers does not throw anything.

Three classes, three outcomes

All three represent the same subscriber. Watch what a HashSet does with two identical instances of each.

No equals at all

class PlainSubscriber {
    final String name;
    final String pincode;
    // constructor only
}

Set<PlainSubscriber> plain = new HashSet<>();
plain.add(new PlainSubscriber("Priya", "411207"));
plain.add(new PlainSubscriber("Priya", "411207"));
System.out.println(plain.size());
2

Two entries for one person. Object.equals compares references, so two separate objects are never equal — the same fact behind == on strings, arriving for the fifth time.

equals but no hashCode

class HalfDone {
    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof HalfDone other)) return false;
        return name.equals(other.name) && pincode.equals(other.pincode);
    }
    // no hashCode
}
equals says: true
set size: 2
contains a fresh equal object: false

This is the worst outcome of the three, because the class now looks correct. equals returns true. contains returns false. The set holds two elements that are equal to each other.

Here is why. A HashSet finds the bucket with hashCode(), and only then compares with equals inside that bucket. Two equal objects with different hash codes land in different buckets, so equals is never called and the set never notices.

The inherited Object.hashCode is derived from the object's identity, so two equal objects get different hashes almost every time.

Both, correctly

class Proper {
    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof Proper other)) return false;
        return name.equals(other.name) && pincode.equals(other.pincode);
    }

    @Override
    public int hashCode() {
        return Objects.hash(name, pincode);
    }
}
set size: 1

Or: a record

record Rec(String name, String pincode) { }
set size: 1

One line, generated correctly, guaranteed consistent. This is the strongest practical argument for records, and it is why the records lesson said the generated pair was the real prize.

The contract

equals must be:

Rule Meaning
Reflexive x.equals(x) is true
Symmetric x.equals(y) and y.equals(x) agree
Transitive If x=y and y=z then x=z
Consistent Repeated calls give the same answer while nothing changes
Null-safe x.equals(null) is false, never an exception

And the one that ties them together:

If two objects are equal, their hash codes must be equal.

The converse is not required — two unequal objects may share a hash code, and that is a collision, which is normal and handled.

Breaking either direction has consequences:

Broken Symptom
equals without hashCode Duplicates in sets; map lookups fail
hashCode without equals Same — equals still compares references
hashCode uses fields equals does not Equal objects, different buckets, same failure
equals uses fields hashCode does not Usually works, but collisions worsen
Either depends on mutable state Entries strand when the object changes

Writing them

The shape to memorise:

@Override
public boolean equals(Object o) {
    if (this == o) return true;
    if (!(o instanceof Proper other)) return false;
    return Objects.equals(name, other.name)
        && Objects.equals(pincode, other.pincode);
}

@Override
public int hashCode() {
    return Objects.hash(name, pincode);
}

Four details, each deliberate.

this == o first is a cheap shortcut for the common self-comparison.

instanceof with a pattern handles null for free — null instanceof anything is false — and gives you the cast. The pattern-matching lesson's syntax paying off in the place you will use it most.

Objects.equals(a, b) is null-safe on both sides. Use it unless the fields are guaranteed non-null.

Objects.hash(...) takes the same fields, in the same order, as equals. Keeping those two field lists identical is the whole discipline. When you add a field to the class, both methods change or neither should.

One performance note: Objects.hash allocates an array for its varargs. In a hot path — a key hashed millions of times — write it out:

public int hashCode() {
    return 31 * name.hashCode() + pincode.hashCode();
}

Do that when you have measured, not before. 31 is conventional because it is an odd prime and the JVM optimises 31 * x into a shift and a subtraction.

instanceof or getClass()

The other common shape uses getClass() != o.getClass(). The difference shows up with subclasses.

  • instanceof lets a subclass be equal to its parent, which can break symmetry — parent.equals(child) may be true while child.equals(parent) is false.
  • getClass() makes a subclass never equal to its parent, which is strictly correct and sometimes surprising.

In practice: make the class final, or use a record, and the question disappears. That is what the library does — String is final, records are final. A class designed for value equality and also designed for inheritance is a problem with no clean answer, so avoid creating one.

The mutation trap, again

Set<Mutable> mutables = new HashSet<>();
Mutable m = new Mutable("Priya", "411207");
mutables.add(m);

System.out.println(mutables.contains(m));
m.setPincode("411014");
System.out.println(mutables.contains(m));
System.out.println(mutables);
found before: true
found after : false
[Priya/411014]

The set contains the object. Printing it shows the object. contains says no — about the very object you are holding.

This is the map-key trap from two lessons ago, and the reason is identical: the bucket was chosen at insertion time from the old hash.

Anything used in a Set or as a Map key must not change while it is in there. The clean rule is to make such classes immutable. A record with immutable components gives you that by construction.

What to do in practice

  1. Use a record when the class is data. Correct by construction.
  2. If you must write a class, let the IDE generate both methods, then check the field lists match — and regenerate both whenever you add a field.
  3. Make it final unless you have a reason not to.
  4. Never include mutable state in either method.
  5. Never include a derived value that can drift, like a cached total.

And the thing not to do: do not write equals without hashCode. If you only need one, you need both.

Check your work

What does a HashSet do with two equal objects whose class has no equals? Holds both. Object.equals compares references.

Why is equals without hashCode worse than neither? The class looks correct — equals returns true — but the set still holds duplicates and contains returns false, because lookup finds the bucket by hash first and never reaches equals.

State the contract in one sentence. Equal objects must have equal hash codes.

Is the converse required? No. Unequal objects may share a hash code; that is a collision and is handled normally.

Why instanceof with a pattern rather than a null check and a cast? It handles null for free and gives you the typed variable in one step.

Why must the field lists in equals and hashCode match? Otherwise two objects equals calls equal can land in different buckets, which fails in exactly the same way as having no hashCode at all.

What happens when you mutate an object already in a HashSet? It becomes unreachable — contains returns false even for that same object — while still appearing in iteration and counting towards size.

What is the simplest way to get all of this right? Use a record.

Practice 2, the three sizes. No equals: size 2. equals without hashCode: size 2, and contains on a fresh equal object returns false even though equals between them returns true. Both methods present: size 1. A record: size 1.

Practice 4, the deliberate mismatch. Including pincode in equals but not in hashCode usually still works, because equal objects then share a hash — it just produces more collisions. Doing it the other way round — hashCode using a field equals ignores — breaks immediately: two objects that are equals get different hashes and both end up in the set. The asymmetry is worth feeling rather than memorising.

Practice

  1. Write the three classes. No equals; equals only; both. Put two identical instances of each into a HashSet and print the sizes.

  2. Find the nastiest case. For the equals-only class, print one.equals(two) and set.contains(new HalfDone("Priya", "411207")) in the same run. Sit with the fact that one says true and the other says false.

  3. Replace it all with a record. Confirm the set size is 1, then print hashCode() for two equal records and confirm they match.

  4. Break the contract deliberately, both ways. First include a field in equals that hashCode ignores. Then include one in hashCode that equals ignores. Test both in a HashSet and explain why only one of them fails.

  5. Strand an object. Add a mutable object to a HashSet, change a field used by hashCode, then call contains with that same object. Print the set and its size alongside.

  6. Harder — a deduplicating loader. Read 10,000 delivery rows, some duplicated, into a Set of a record keyed on date, customer and area. Report how many duplicates were dropped using the return value of add. Then change the record to a mutable class with hand-written equals and hashCode, introduce a field-list mismatch, and watch the duplicate count go wrong without anything throwing.

Next: sorting, where Comparator decides the order and a subtraction quietly gets it wrong.

Stuck on this lesson?

Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.

About the internship