Why this matters
- This is the single most consequential contract in the Java standard library:
HashMap,HashSet,Collectors.groupingByand distinct-value logic all depend on it. - The failure is silent. Nothing throws; an object simply vanishes from a set it is demonstrably inside.
- Almost every interview that touches collections works its way to this question.
Bucket index comes from the key's hash, not its insertion order — which is why HashMap iteration has no predictable sequence.
What Object gives every class
Every class inherits eleven methods from Object. Five matter day to day.
equals(Object)— reference identity by default, which is rarely the comparison you want.hashCode()— anintderived from the object's identity by default.toString()— class name plus a hexadecimal identity hash by default, which is useless in a log.getClass()— the run-time class, andfinalso it cannot lie.clone()— a shallow field copy,protected, and covered in the next article.
The remaining ones — wait, notify, notifyAll, finalize — belong to concurrency and the
deprecated finalisation mechanism, both covered later.
The default equals is identity
class Point {
final int x, y;
Point(int x, int y) { this.x = x; this.y = y; }
}
Point a = new Point(1, 2);
Point b = new Point(1, 2);
System.out.println(a.equals(b)); // false — two distinct objects
System.out.println(a.equals(a)); // true
Object.equals is literally this == other. For a value type like Point, where two instances with
the same coordinates should be interchangeable, that is wrong and must be overridden.
The contract
equals must satisfy five properties. They sound academic until one is broken.
- Reflexive —
x.equals(x)is true. - Symmetric — if
x.equals(y)theny.equals(x). Broken most often by comparing across types. - Transitive — if
x.equals(y)andy.equals(z)thenx.equals(z). - Consistent — repeated calls give the same answer while neither object changes.
- Null-safe —
x.equals(null)is false, never an exception.
And the rule that links the two methods:
What breaks when you override only one
class Product {
final String sku;
Product(String sku) { this.sku = sku; }
@Override
public boolean equals(Object other) {
return other instanceof Product p && sku.equals(p.sku);
}
// hashCode deliberately not overridden
}
Set<Product> catalogue = new HashSet<>();
catalogue.add(new Product("A-100"));
System.out.println(catalogue.contains(new Product("A-100"))); // false
System.out.println(catalogue.size()); // 1
Both objects are equals, but they inherited identity hash codes, so they land in different buckets.
contains searches one bucket, does not find the object there, and returns false. The object is in
the set and simultaneously not findable.
The reverse mistake — overriding hashCode only — sends both to the same bucket, where equals
then reports them as different. Also broken, in a different place.
Writing them correctly
public final class Employee {
private final String employeeId; // the identity
private final String name; // not part of identity
private final String department;
public Employee(String employeeId, String name, String department) {
this.employeeId = Objects.requireNonNull(employeeId);
this.name = name;
this.department = department;
}
@Override
public boolean equals(Object other) {
if (this == other) return true; // cheap fast path
if (!(other instanceof Employee that)) return false; // handles null too
return employeeId.equals(that.employeeId);
}
@Override
public int hashCode() {
return Objects.hash(employeeId); // same field as equals
}
@Override
public String toString() {
return "Employee[id=%s, name=%s, department=%s]".formatted(employeeId, name, department);
}
}
The rules this follows
- Identical fields in both methods. If
equalsuses onlyemployeeId, so musthashCode. Any mismatch breaks the contract. instanceofhandles null for free, becausenull instanceof Anythingis false. No separate null check is needed.- Pattern-matching
instanceoftests and binds in one step, replacing a separate cast. - Start with
this == other. It is nearly free and often true. - Always add
toString(). The default output tells you nothing in a log or a debugger.
instanceof or getClass
| Aspect | instanceof | getClass() comparison |
|---|---|---|
| Subclass equals parent | Possible — may break symmetry | Never |
| Symmetry | At risk if a subclass adds fields | Guaranteed |
| Liskov substitution | Preserved | A subclass is never equal to its parent |
| Works with proxies and ORM entities | Yes | No — Hibernate proxies fail |
| Recommended with | A final class, or value semantics | An open hierarchy needing strict symmetry |
Subclass equals parent
instanceofPossible — may break symmetrygetClass() comparisonNeverSymmetry
instanceofAt risk if a subclass adds fieldsgetClass() comparisonGuaranteedLiskov substitution
instanceofPreservedgetClass() comparisonA subclass is never equal to its parentWorks with proxies and ORM entities
instanceofYesgetClass() comparisonNo — Hibernate proxies failRecommended with
instanceofA final class, or value semanticsgetClass() comparisonAn open hierarchy needing strict symmetry
Making the class final removes the dilemma entirely, which is the usual advice for value types.
Mutable keys break maps
This is the practical consequence people meet in real code.
class MutableKey {
String value;
MutableKey(String value) { this.value = value; }
@Override public boolean equals(Object o) {
return o instanceof MutableKey k && Objects.equals(value, k.value);
}
@Override public int hashCode() { return Objects.hashCode(value); }
}
MutableKey key = new MutableKey("original");
Map<MutableKey, String> map = new HashMap<>();
map.put(key, "stored");
key.value = "changed"; // the hash code just changed
System.out.println(map.get(key)); // null — wrong bucket now
System.out.println(map.containsKey(key)); // false
System.out.println(map.size()); // 1 — still in there somewhere
The entry sits in the bucket chosen by the old hash code, and every lookup now computes the new one. The value is unreachable through the map and will stay in memory until the map is discarded — a genuine leak as well as a logic bug.
What a good hash code looks like
// Preferred: clear, handles nulls, boxes a varargs array
@Override public int hashCode() {
return Objects.hash(employeeId, department);
}
// Manual, avoids the varargs allocation in a very hot path
@Override public int hashCode() {
int result = employeeId.hashCode();
result = 31 * result + Objects.hashCode(department);
return result;
}
The multiplier 31 is conventional because it is an odd prime and 31 * x compiles to a shift and a
subtraction. The important property is that different field orderings produce different results, so
("a", "b") and ("b", "a") do not collide.
Objects.hash allocates a varargs array on every call. That is irrelevant almost everywhere, and
worth avoiding only in a measured hot loop.
Common misreadings
- "Unequal hash codes mean unequal objects." That direction is true and is exactly what makes the collection fast. The invalid direction is assuming equal hash codes mean equal objects.
- "
hashCodereturns a memory address." It returns anintderived from identity by default, and whatever you compute once you override it. - "Records still need these methods." Records generate both from their components, along with
toString. That is a large part of their appeal. - "
Objects.hashis slow." It allocates an array; in normal code this is unmeasurable. - "
equalsshould accept the specific type."equals(Employee)is an overload, not an override. Collections callequals(Object)and will ignore it entirely.
Quick recall
Everything you need if you only revisit this box.
hashCodepicks the bucket;equalsconfirms the match. Override both or neither.- Equal objects must have equal hash codes. Equal hash codes do not imply equality — that is a collision.
- Override only
equalsand aHashSetwill contain an object it cannot find. - Use the same fields in both methods, start with
this == other, and letinstanceofhandle null. instanceofpreserves substitutability;getClass()guarantees symmetry. Making the classfinalremoves the choice.- Never use a mutable object as a map key. Changing it strands the entry in the wrong bucket.
Objects.hash(...)is the clear default; always add a realtoString().
Test yourself
Answer these before moving on — recall is what makes it stick.