DEV Community

srinivas reddy gouru
srinivas reddy gouru

Posted on

The equals and hashCode contract in Java: Failure modes and the TreeSet twist

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
Enter fullscreen mode Exit fullscreen mode

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:

  1. If a.equals(b) is true, then a.hashCode() == b.hashCode() must be true.
  2. If a.hashCode() == b.hashCode(), then a.equals(b) is NOT required to be true (collisions are legal).
  3. 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) then b.equals(a).
  • Transitive: if a.equals(b) and b.equals(c) then a.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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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
    }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 HashMap finds an entry in two steps: hashCode selects the bucket, then equals confirms inside it. Equal objects must hash equally, or the lookup searches the wrong bucket and equals never runs.
  • A bad hashCode that breaks the contract makes lookups wrong; a bad hashCode that merely collides a lot makes them slow. Only the first is a correctness bug.
  • Tree collections never touch hashCode; they order by compareTo, and there the rule is that compareTo must agree with equals. BigDecimal breaks that on purpose, so "1.0" and "1.00" give a HashSet of size two but a TreeSet of size one.
  • The cleanest way to get all of this right is a record, which generates consistent equals and hashCode for you.

Top comments (0)