1. The one idea to hold onto
Forget the formal wording for a moment and hold this picture instead.
Think of an object as a person's full name, like "Srinivas Reddy". Think of
hashCode() as that person's initials, "SR": a cheap little summary you can
compute from the full name in a second. And think of equals() as the slow,
careful check: do the two full names actually match, letter for letter?
With that picture, the two clauses of the contract stop being rules and become
common sense.
Clause one: equal objects must have equal hash codes. If two records are the
same person, their initials must come out the same. "Srinivas Reddy" gives you
"SR" every single time. It would be absurd for the identical name to produce
"SR" on one form and "TK" on another. So when the full thing is equal, any
summary derived from it is forced to be equal too. That is just what "summary of
the same thing" means.
Clause two: equal hash codes do NOT force the objects to be equal. Flip it
around. If I only tell you someone's initials are "SR", can you tell me who they
are? No. "SR" could be Srinivas Reddy, or Sara Roy, or Sam Rivera. Same
initials, completely different people. Matching initials is a hint that two
records might be the same, never a proof. You still have to read the full names.
Two different things sharing a summary has a name in the hashing world, a
collision, and it is completely normal and expected.
Here is a real collision from Java you can verify yourself. The two strings
"Aa" and "BB" are obviously different words, yet they produce the exact same
hashCode, the number 2112. It falls out of Java's string formula (each
character times 31, summed):
"Aa".hashCode(); // 65*31 + 97 = 2112
"BB".hashCode(); // 66*31 + 66 = 2112
Two genuinely different objects, one identical hash. That is clause two in the
flesh.
The one-way street. Initials are a summary, and a summary throws away almost
all the information in the name. Once you have thrown information away, different
things will occasionally collapse to the same summary. There simply are not
enough initials to go around. But the act of making the summary is fixed and
mechanical: the same name always yields the same initials, and two equal names
always yield equal initials. So the arrow only runs one direction. Equal name
guarantees equal initials. Equal initials guarantees nothing. Lossy, but
consistent, and that combination is the entire asymmetry.
The consistency clause. Your initials today are the same as your initials
tomorrow, unless you actually change your name. In code: as long as nothing
about the object changes, calling hashCode() again gives the same answer. It
does not wander.
If you want the single sentence that carries all of it: hashCode is a cheap
summary used to narrow down, and equals is the careful check used to confirm,
and the contract simply guarantees those two never contradict each other.
2. Why the contract exists: the two-step lookup
The reason clause one is non-negotiable becomes obvious once you see how a
HashMap actually finds things. It is secretly an array of slots called
buckets, and a lookup does two moves in sequence, never a full scan.
The first move: it calls hashCode() on your key and uses that number to pick
exactly one bucket. This is the shortlist step, how the map jumps straight to
the handful of entries that could possibly match instead of checking a million.
The second move: inside that one bucket, it calls equals() on each candidate
to find the exact match. This is the confirmation step.
So the roles are: hash narrows, equals confirms. Hash gets you to the right
shelf; equals reads the spine to find the exact book.
Now the asymmetry makes mechanical sense. Two different objects landing in the
same bucket is fine, because equals will sort them out once they are there.
That is a collision, and collisions are expected. But two equal objects landing
in different buckets is a catastrophe, because the confirmation step only ever
runs inside a single bucket. If your key is on the wrong shelf, equals never
even gets a chance to run. The librarian searching shelf seven will never find
the book sitting on shelf twelve, no matter how identical the two copies are,
because she never walks to shelf twelve.
That single sentence is the whole contract. Everything in the failure gallery
below is just that idea showing up in different disguises.
3. The contract, stated precisely
Now that you have the picture, the formal version reads as a summary rather than
a wall.
For hashCode:
- If
a.equals(b)is true, thena.hashCode() == b.hashCode()must be true. - If
a.hashCode() == b.hashCode(), thena.equals(b)is NOT required to be true (collisions are legal). - Called repeatedly on the same object with unchanged state,
hashCode()must return the same value.
For equals, the method must be:
-
Reflexive:
a.equals(a)is always true. -
Symmetric: if
a.equals(b)thenb.equals(a). -
Transitive: if
a.equals(b)andb.equals(c)thena.equals(c). - Consistent: repeated calls give the same result while nothing changes.
-
Null-safe:
a.equals(null)is always false and never throws.
The examples below are, in effect, a tour of what breaks when you violate one of
these.
4. The failure gallery
Each example is a complete, runnable snippet with the surprising output written
as a comment, followed by the reason it happens.
4.1 Override equals, forget hashCode (the classic)
This is the most common bug, and it is insidious because the code looks
perfectly reasonable.
class User {
final String email;
User(String email) { this.email = email; }
@Override
public boolean equals(Object o) {
return o instanceof User u && u.email.equals(this.email);
}
// hashCode() NOT overridden
}
Map<User, String> map = new HashMap<>();
map.put(new User("a@b.com"), "Srinivas");
String name = map.get(new User("a@b.com")); // returns null
When you put, the map calls hashCode() on your first User. You did not
override it, so it uses the one inherited from Object, which is based on the
object's identity (essentially its memory address, unique to that specific
object). Say that gives bucket 2, so the entry is filed there.
When you get with a brand new User that has the same email, the map calls
hashCode() on this different object. Different object, different identity hash,
say bucket 5. The map walks to bucket 5, finds it empty, and returns null. Your
carefully written equals is never called, because the search never reached
bucket 2 where the entry actually lives.
The entry is in the map. You just cannot get it out.
The same flaw wearing a different symptom in a HashSet:
Set<User> seen = new HashSet<>();
seen.add(new User("a@b.com"));
seen.add(new User("a@b.com")); // should be a duplicate
System.out.println(seen.size()); // prints 2
The set looked in the second object's bucket, found nothing, concluded it was
new, and stored it. You now have two equal elements in a Set. The set has
quietly stopped being a set.
4.2 The mutable-key leak
Here hashCode and equals are both correct, but the fields they depend on can
change after insertion.
class Point {
int x, y;
Point(int x, int y) { this.x = x; this.y = y; }
@Override public boolean equals(Object o) {
return o instanceof Point p && p.x == x && p.y == y;
}
@Override public int hashCode() {
return Objects.hash(x, y);
}
}
Set<Point> set = new HashSet<>();
Point p = new Point(1, 1);
set.add(p); // hash computed from (1,1), filed in that bucket
p.x = 99; // mutate the key AFTER inserting it
set.contains(p); // false, even though p is physically in the set
set.remove(p); // also fails to remove it
When you added p, its hash was computed from (1, 1) and it was filed in the
matching bucket. Then you changed x to 99, so the hash is now computed from
(99, 1), which points at a different bucket. Every future lookup computes the
new hash and searches the wrong bucket. The object is stranded in its old
bucket, unreachable and unremovable.
That is not a metaphor. It is a genuine memory leak: the entry occupies space,
cannot be found, and cannot be deleted. This is the strongest argument for
making keys and set elements immutable.
4.3 A legal but terrible hashCode
@Override public int hashCode() {
return 42; // constant
}
Here is the subtle part: this does NOT violate the contract. Equal objects do
return equal hashes, because every object returns 42. So lookups are correct.
They are just slow. Every entry now hashes to the same bucket, so instead of
jumping to a shelf with one or two items, the map dumps everything into one
giant bucket and walks the whole thing calling equals. Your O(1) map has
degraded toward O(n).
Modern Java softens this. Since Java 8, when a single bucket gets too crowded
(past a threshold, once the table is large enough), the HashMap converts that
bucket's internal linked list into a balanced red-black tree, making lookups
within it O(log n) instead of O(n). Better, but still a serious regression from
O(1), and it only helps if the keys implement Comparable so the tree has
something to sort by. The lesson: a correct hashCode is not enough, it also
needs to spread values out. A good hashCode distributes; a bad one clumps.
4.4 The symmetry trap
This one catches even experienced developers, and it is a favorite interview
question. It happens when you extend a value class and add a field to compare.
class Point {
final int x, y;
Point(int x, int y) { this.x = x; this.y = y; }
@Override public boolean equals(Object o) {
return o instanceof Point p && p.x == x && p.y == y;
}
@Override public int hashCode() { return Objects.hash(x, y); }
}
class ColorPoint extends Point {
final String color;
ColorPoint(int x, int y, String color) { super(x, y); this.color = color; }
@Override public boolean equals(Object o) {
return o instanceof ColorPoint c
&& super.equals(c) && c.color.equals(this.color);
}
}
Point p = new Point(1, 2);
ColorPoint cp = new ColorPoint(1, 2, "red");
p.equals(cp); // true (Point.equals only checks x and y)
cp.equals(p); // false (ColorPoint.equals also demands a matching color)
equals is now asymmetric: p.equals(cp) and cp.equals(p) disagree. That
breaks the symmetry rule from Section 3, and it produces bugs that depend on
argument order, which are miserable to track down. Worse, a common "fix" (make
ColorPoint ignore color when compared against a plain Point) trades the
symmetry break for a transitivity break, so you cannot patch your way out.
The real resolution is a design rule: there is no way to extend an instantiable
value class with a new value component and preserve the equals contract. Two
clean options exist. Use exact-class matching instead of instanceof, so a
Point and a ColorPoint are simply never equal:
@Override public boolean equals(Object o) {
if (o == null || getClass() != o.getClass()) return false;
Point p = (Point) o;
return p.x == x && p.y == y;
}
Or, better for most cases, favor composition over inheritance: give ColorPoint
a Point field and a method to expose it, rather than extending Point.
4.5 The TreeSet twist: compareTo must agree with equals
Everything so far has been about hash-based collections. Tree-based collections
play by a completely different rulebook, and knowing that difference is what
separates someone who memorized the contract from someone who understands it.
TreeMap and TreeSet do not call hashCode() at all. Not once. They are not
built on buckets. They keep elements in sorted order using either compareTo()
(if the type implements Comparable) or a Comparator you supply. So a
catastrophically broken hashCode, even return 42, causes a TreeSet zero
problems.
But a different contract governs them: compareTo should be consistent with
equals, meaning a.compareTo(b) == 0 in exactly the same situations where
a.equals(b) is true. Break that alignment and the tree quietly redefines
equality on your behalf, because to a tree, two elements are "the same" precisely
when compareTo returns zero, no matter what equals says.
The canonical demonstration is BigDecimal, because it is real, it is in the
standard library, and it disagrees with itself on purpose:
BigDecimal a = new BigDecimal("1.0");
BigDecimal b = new BigDecimal("1.00");
a.equals(b); // false equals also compares scale (1 vs 2 decimals)
a.compareTo(b); // 0 compareTo compares numeric value only
Set<BigDecimal> hash = new HashSet<>(List.of(a, b));
Set<BigDecimal> tree = new TreeSet<>(List.of(a, b));
hash.size(); // 2 HashSet uses equals, which says they differ
tree.size(); // 1 TreeSet uses compareTo, which says they are identical
Identical input, two Set implementations, two different answers. If code ever
fills one kind of set from a list of BigDecimal and later switches to the
other, the element count silently changes and nothing throws.
The same trap with your own class, to show it is not special to BigDecimal:
class Person implements Comparable<Person> {
String name; int id; int age;
// equals and hashCode built from name + id (correct and consistent)
@Override public int compareTo(Person other) {
return Integer.compare(this.age, other.age); // BUG: ordering ignores identity
}
}
TreeSet<Person> set = new TreeSet<>();
set.add(new Person("Asha", 1, 30));
set.add(new Person("Ravi", 2, 30)); // different person, same age
System.out.println(set.size()); // 1 Ravi rejected as a "duplicate"
Two clearly different people who happen to share an age. The TreeSet compares
them with compareTo, gets zero, decides they are the same element, and refuses
to add the second. No error, no warning, just a missing record.
5. How to write them correctly
Now that you have seen every way it breaks, the rules for doing it right are just
the failures turned around.
Override hashCode and equals together, from the same fields. If equals
compares email and name, then hashCode must be derived from email and name, so
two objects equal in those fields are guaranteed to hash the same. The modern
helpers make this a two-liner:
class User {
final String email;
final String name;
@Override public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof User u)) return false;
return Objects.equals(email, u.email) && Objects.equals(name, u.name);
}
@Override public int hashCode() {
return Objects.hash(email, name); // same fields as equals
}
}
Objects.equals handles nulls on both sides, and the instanceof check makes
equals(null) return false for free, since null instanceof User is false.
Prefer immutable fields for anything used in hashCode, so you never hit the
stranded-key leak from Section 4.2.
If your type is Comparable, keep compareTo consistent with equals, so
you never hit the BigDecimal or Person surprises from Section 4.5.
Do not add a value component by extending an instantiable class, per the
symmetry trap in Section 4.4. Use composition, or exact-class equality.
Easiest correct path: use a record. A Java record generates a correct
equals, hashCode, and toString from its components automatically, and they
are always consistent with each other. For a plain immutable value holder, this
removes the whole class of bugs above:
record User(String email, String name) { }
// equals and hashCode are generated, correct, and mutually consistent
If you cannot use a record, let your IDE generate both methods together, or use
a library like Lombok's @EqualsAndHashCode. The danger is almost always
hand-writing one method and forgetting or mismatching the other.
6. Quick reference
A lookup table for after you have read the reasoning above. The value of each row
is the reasoning in the linked section, not the one-liner.
| Symptom | Cause | Fix |
|---|---|---|
map.get(equalKey) returns null (4.1) |
equals overridden, hashCode not |
Override both from the same fields |
HashSet stores equal duplicates (4.1) |
same as above | Override both from the same fields |
| Entry cannot be found or removed after a change (4.2) | Key mutated after insertion | Make keys immutable |
| Correct but slow lookups (4.3) |
hashCode returns few distinct values |
Distribute the hash across fields |
| Order-dependent equality bugs (4.4) |
equals is asymmetric via inheritance |
Composition, or getClass() equality |
HashSet and TreeSet disagree on size (4.5) |
compareTo inconsistent with equals
|
Make compareTo == 0 match equals == true
|
TreeSet silently drops distinct elements (4.5) |
compareTo returns 0 for unequal objects |
Same as above |
Interview-ready one-liners, each of which the sections above let you defend:
- A
HashMapfinds an entry in two steps:hashCodeselects the bucket, thenequalsconfirms inside it. Equal objects must hash equally, or the lookup searches the wrong bucket andequalsnever runs. - A bad
hashCodethat breaks the contract makes lookups wrong; a badhashCodethat merely collides a lot makes them slow. Only the first is a correctness bug. - Tree collections never touch
hashCode; they order bycompareTo, and there the rule is thatcompareTomust agree withequals.BigDecimalbreaks that on purpose, so"1.0"and"1.00"give aHashSetof size two but aTreeSetof size one. - The cleanest way to get all of this right is a record, which generates
consistent
equalsandhashCodefor you.
Top comments (0)