Java equals and hashCode Contract, Comparable vs Comparator
Why equal Java objects must have equal hash codes, how to override equals and hashCode correctly, why keys must be immutable, and Comparable vs Comparator.
- Course: Java study plan
- Module: Interview idioms
- Kind: Lesson
- Reading time: 14 min
- Runtime: OpenJDK 18
What is the equals and hashCode contract in Java?
The equals and hashCode contract in Java says that objects that are equal must return the same hash code, while unequal objects may share one. equals must be reflexive, symmetric, transitive, consistent and false for null. Override both together: HashMap and HashSet find the bucket by hashCode first and only then call equals, so breaking the contract makes lookups fail.
Lesson
Half of the collections framework — HashMap, HashSet, contains, remove(Object), indexOf, distinct() — depends on equals and hashCode agreeing; the other half — TreeMap, sort, PriorityQueue, max — depends on a consistent ordering. Get the contracts wrong and the symptoms are maddening: a key you just inserted "is not in the map", a set holds two equal objects, a sort throws IllegalArgumentException: Comparison method violates its general contract. Interviewers ask about these contracts because every Java developer has been bitten by them. This lesson states them exactly, shows the implementations that satisfy them, and lists the mistakes.
The equals contract
For non-null references: reflexive (a.equals(a)), symmetric (a.equals(b) == b.equals(a)), transitive, consistent (repeated calls agree while nothing changes), and a.equals(null) is false. The default in Object is identity (==). Override it when instances are values — two Point(1, 2)s are the same point — and leave it alone for entities whose identity is their existence (a connection, a thread, a session).
@Override public boolean equals(Object o) {
if (this == o) return true; // fast path
if (o == null || getClass() != o.getClass()) return false; // or `!(o instanceof Point p)` if subclasses may be equal
Point p = (Point) o;
return x == p.x && y == p.y; // compare every significant field; Objects.equals for references
}
getClass() != versus instanceof: getClass() makes a Point never equal a ColoredPoint, which keeps symmetry trivially; instanceof lets subclass instances be equal to base instances but makes symmetry your problem (ColoredPoint.equals(Point) must then ignore colour). Pick getClass() unless you have a reason. The parameter type must be Object — equals(Point p) is an overload that HashMap never calls; @Override catches this mistake.
The hashCode contract
Equal objects must have equal hash codes. Unequal objects may share one (a collision), but fewer collisions mean faster maps. hashCode must be consistent while the object is unchanged. Override it whenever you override equals — a HashMap finds the bucket by hashCode first and only then calls equals, so a class with equals but the default identity hashCode puts equal keys in different buckets and get fails.
@Override public int hashCode() { return Objects.hash(x, y); } // fine for most classes
@Override public int hashCode() { return 31 * Integer.hashCode(x) + Integer.hashCode(y); } // the hand-rolled form: no varargs array
Objects.hash boxes and allocates an array — negligible almost always, avoidable in a hot key class. For arrays use Arrays.hashCode/Arrays.equals (a plain array.hashCode() is identity). String, the boxed primitives, List, Set, Map, Optional, records and enums all implement both correctly; your own classes are the risk.
Records generate equals, hashCode and toString from the components — the fastest correct way to a value class, and the answer to "how would you make this a good map key" nine times out of ten.
Mutable keys: the silent disaster
Set<List<Integer>> seen = new HashSet<>();
List<Integer> key = new ArrayList<>(List.of(1, 2));
seen.add(key);
key.add(3);
seen.contains(key); // false — it hashes to a different bucket now
seen.contains(List.of(1, 2)); // also false — the stored key's contents changed
The set now contains an entry that no lookup can find and remove cannot remove: a leak and a bug. The rule: fields that participate in equals/hashCode must not change while the object is a key — best enforced by making the key class immutable (final fields, records).
Comparable: the natural order
record Version(int major, int minor) implements Comparable<Version> {
public int compareTo(Version o) {
int c = Integer.compare(major, o.major);
return c != 0 ? c : Integer.compare(minor, o.minor);
}
}
compareTo must be antisymmetric (sgn(a.compareTo(b)) == -sgn(b.compareTo(a))), transitive, and consistent; it should be consistent with equals (compareTo == 0 iff equals) — TreeSet and TreeMap use only compareTo, so two objects that compare equal but are not equals collapse into one entry. Use Integer.compare(a, b), never a - b: the subtraction overflows for values of opposite sign (Integer.MIN_VALUE - 1 is positive), which is exactly the "violates its general contract" exception when TimSort catches the inconsistency. Compare doubles with Double.compare (handles NaN and -0.0), strings with compareTo or String.CASE_INSENSITIVE_ORDER.
Comparator: every other order
Comparator<Employee> byDeptThenSalaryDescThenName =
Comparator.comparing(Employee::dept)
.thenComparing(Employee::salary, Comparator.reverseOrder())
.thenComparing(Employee::name);
list.sort(byDeptThenSalaryDescThenName);
Comparator.comparingInt(String::length); // primitive specialisation: no boxing
Comparator.nullsFirst(Comparator.naturalOrder()); // nulls are otherwise an NPE
Comparator.reverseOrder(); comparator.reversed();
A Comparator is the ordering for one use (this sort, this heap), Comparable is the type's one natural order. Sorting with Comparator.comparing chains beats hand-written compare methods on readability and is harder to get wrong. List.sort/Arrays.sort on objects are stable (TimSort): equal elements keep their input order, which is why "sort by score, ties by input order" needs no extra key.
Where each is used
Needs equals/hashCode | Needs an order |
|---|---|
HashMap, HashSet, LinkedHash*, ConcurrentHashMap | TreeMap, TreeSet, PriorityQueue |
List.contains, indexOf, remove(Object) | Collections.sort, List.sort, Arrays.sort(T[]) |
Stream.distinct, groupingBy keys, toSet | Stream.sorted, min, max, Collections.min/max |
Objects.equals, assertEquals in tests | Arrays.binarySearch (must be sorted with the same order) |
Common mistakes, in one place
- Overriding
equalswithouthashCode(or vice versa). equals(MyType other)— an overload, not an override; useObjectand@Override.a - bincompareTo;==onDouble/Integerfields insideequals.- Mutating a key after insertion.
compareToinconsistent withequals, then wondering why aTreeSetlost elements.- Comparing
doublefields with==(NaN != NaN;0.0 == -0.0); useDouble.compare. - Forgetting that
Arrays.equalsis needed for array fields andArrays.deepEqualsfor nested arrays.
Interview angle
- "What is the
equals/hashCodecontract?" Equal objects must have equal hash codes;equalsis reflexive, symmetric, transitive, consistent, null-false. - "What happens if you override only
equals?" Equal keys land in differentHashMapbuckets — lookups fail. - "How does
HashMap.getuse them?" Hash to a bucket, thenequalsalong the chain. - "
ComparableversusComparator?" The type's natural order versus an external, per-use order;TreeMapandPriorityQueueaccept either. - "Why not
return a - bin a comparator?" Overflow for values of opposite sign breaks antisymmetry;Integer.compare.
Key takeaways
- Override both or neither; parameter type
Object; compare all significant fields;Objects.hash/Arrays.hashCode. - Records give correct value semantics for free — reach for them as map keys.
- Keys must be immutable in their hashed fields.
compareTo:Integer.compare/Double.compare, transitive, consistent withequalsfor tree collections.Comparator.comparing(...).thenComparing(...)chains; object sorts are stable.
Common questions
What happens if you override equals but not hashCode?
Equal objects keep their default identity hash codes, so a HashMap or HashSet puts them in different buckets: get and contains fail for a key equal to one already stored, and a set can end up holding two equal objects.
What is the difference between Comparable and Comparator in Java?
Comparable is a type's single natural order, implemented as compareTo inside the class. A Comparator is an external order for one use, such as a sort or a PriorityQueue, built with Comparator.comparing(...).thenComparing(...). Sorts and tree collections accept either.
Why should compareTo not return a - b?
The subtraction overflows when the values have opposite signs — Integer.MIN_VALUE - 1 is positive — which breaks antisymmetry and can make a sort throw "Comparison method violates its general contract". Use Integer.compare(a, b), and Double.compare for doubles.
Why is a mutable object a bad HashMap key?
If a field used by hashCode changes after insertion, the entry sits in the wrong bucket: get and contains no longer find it and remove cannot remove it, so it is a bug and a leak. Make key classes immutable — records are the easiest way.
Should equals use getClass() or instanceof?
getClass() != o.getClass() means a subclass instance never equals a base instance, which keeps symmetry simple; instanceof allows cross-class equality but makes symmetry your job. Prefer getClass() unless you have a reason, and always take an Object parameter with @Override.
Exercises
Point as a key
Write a class Point with int x, y, a correct equals(Object) and a consistent hashCode (not a record — do it by hand). Read an integer n and n points, then m and m query points. Print distinct=<size of a HashSet of the points>, then for each query (x,y): present or (x,y): absent, then hashAgree=<whether two separately constructed equal points have equal hash codes>.
Example: 3 then 1 2, 1 2, 3 4, then 2 then 1 2, 2 1 →
distinct=2
(1,2): present
(2,1): absent
hashAgree=trueSort by many keys
Write record Employee(String dept, String name, int salary) implements Comparable<Employee> whose natural order is department ascending, then salary descending, then name ascending — using Integer.compare, never subtraction. Read an integer n and n employees dept name salary, sort them, and print <dept> <name> <salary> per line. Then print richest=<name of the highest salary; ties to the earliest in input> using Collections.max with Comparator.comparingInt, and byName=<names sorted alphabetically, comma-separated> using Comparator.comparing.
Example: 3 then eng ann 100, ops bob 90, eng cy 120 →
eng cy 120
eng ann 100
ops bob 90
richest=cy
byName=ann,bob,cyIn this module: Interview idioms
- The interview template — input, output, limits
- equals, hashCode and Comparable — the contracts that make collections work (this lesson)
- The pitfalls that fail interviews
- The collections idiom sheet
- Writing clean solutions under time pressure
- The Java theory drill — thirty questions, thirty answers
- Final checkpoint — Interview idioms
← The interview template — input, output, limits · The pitfalls that fail interviews →