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.
instanceoflets a subclass be equal to its parent, which can break symmetry —parent.equals(child)may betruewhilechild.equals(parent)isfalse.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
- Use a record when the class is data. Correct by construction.
- 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.
- Make it
finalunless you have a reason not to. - Never include mutable state in either method.
- 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
-
Write the three classes. No
equals;equalsonly; both. Put two identical instances of each into aHashSetand print the sizes. -
Find the nastiest case. For the
equals-only class, printone.equals(two)andset.contains(new HalfDone("Priya", "411207"))in the same run. Sit with the fact that one saystrueand the other saysfalse. -
Replace it all with a record. Confirm the set size is 1, then print
hashCode()for two equal records and confirm they match. -
Break the contract deliberately, both ways. First include a field in
equalsthathashCodeignores. Then include one inhashCodethatequalsignores. Test both in aHashSetand explain why only one of them fails. -
Strand an object. Add a mutable object to a
HashSet, change a field used byhashCode, then callcontainswith that same object. Print the set and its size alongside. -
Harder — a deduplicating loader. Read 10,000 delivery rows, some duplicated, into a
Setof a record keyed on date, customer and area. Report how many duplicates were dropped using the return value ofadd. Then change the record to a mutable class with hand-writtenequalsandhashCode, 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