Equality, Identity & Hashing: ==, equals(), hashCode()
The Bug That Eats Your Data
Picture this: your checkout code stores a Customer in a HashMap. An order arrives for the same customer — same name, same email. You look up the record with map.get(customer) and get… null. The entry is right there (map.size() returns 1), but Java cannot find it. The map has silently swallowed your data.
This is not a HashMap bug. It is an equality bug, and it is one of the most common sources of "impossible" behavior in Java applications — contains() returning false for an element you just added, duplicate entries in a HashSet, cache lookups that always miss. This post explains the three moving parts behind it: reference identity (==), logical equality (equals()), and the hashCode() contract that ties them together.
Every Java interviewer has a variant of this question. More importantly, every production codebase has a variant of this bug.
Identity vs Equality: == Asks "Same Object?", equals() Asks "Same Value?"
On primitives, == compares values: 5 == 5 is true. On objects, == compares reference identity — it answers only one question: do these two references point to the same object in memory?
String s1 = new String("java");
String s2 = new String("java");
System.out.println(s1 == s2); // false — two distinct objects
System.out.println(s1.equals(s2)); // true — same characters
s1 and s2 are twins, not the same person. == distinguishes the person; equals() compares the content. The rule of thumb: for objects, == is almost never what you mean.
The Default: Object.equals() Is Just ==
If a class does not override equals(), it inherits this from Object:
public boolean equals(Object obj) {
return (this == obj);
}
The default is pure identity. That default is the correct choice when a class has no meaningful notion of "same value" — an entity whose identity is its database row, a thread pool, a service, a connection. Don't override equals() just because a linter nags you. Override it only when your class is a value carrier (a customer, a money amount, a date range) where two different instances with the same data should be treated as the same.
The equals Contract: Five Rules That Keep Collections Sane
When you do override equals(), you sign a contract. Every method in the JDK — List.contains(), HashSet, HashMap — assumes you honored it. Here is what each rule means in practice, with one concrete way it breaks when violated:
- Reflexive:
x.equals(x)must betrue. Breaks:list.contains(x)returnsfalsefor the exact object you put in the list. - Symmetric:
x.equals(y)must matchy.equals(x). Breaks: two collections give different answers depending on which side you call from —set1.contains(a)istruebutset2.contains(b)isfalsefor "equal" objects. - Transitive: if
x.equals(y)andy.equals(z), thenx.equals(z). Breaks: a subclass that adds a field and compares "up and down" lets aHashSetaccumulate objects that are all mutually "equal" yet all stored — the duplicate-removal you expected never happens. - Consistent: repeated calls on unchanged objects must give the same answer. Breaks: an
equals()that depends on the current time or a random token makes map lookups work once and fail on retry — flaky bugs that vanish when you debug them. - Null-safe:
x.equals(null)must returnfalse, never throw. Breaks: aNullPointerExceptioninsidemap.containsKey()orlist.remove()the moment a collection touches your object with a null comparison.
Notice the pattern: every violation turns into collection behavior that looks haunted. The contract is not academic decoration — it is what contains, remove, get, and deduplication are built on.
The hashCode Contract and the Binary Rule
hashCode() exists for one job: to tell hash-based collections which bucket to look in before they start calling equals(). Its contract is short:
- If
a.equals(b), thena.hashCode() == b.hashCode(). Equal objects must land in the same bucket. - If
!a.equals(b), their hash codes may still collide. Collisions are legal — they are resolved withequals()inside the bucket — they just cost performance.
This leads to the binary rule of the language: override both equals() and hashCode(), or neither. Override equals() alone and you create objects that are "equal" but live in different buckets — invisible to each other. The compiler will not warn you. Your tests may not catch it. Production will.
Demo: The Vanishing Map Entry
Here is the failure mode, proven with a runnable program. The Customer class overrides equals() but not hashCode():
import java.util.HashMap;
import java.util.Map;
import java.util.Objects;
final class Customer {
private final String name;
private final String email;
Customer(String name, String email) {
this.name = name;
this.email = email;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof Customer c)) return false;
return Objects.equals(name, c.name) && Objects.equals(email, c.email);
}
// BUG: hashCode() not overridden — still the identity-based default
}
public class EqualityDemo {
public static void main(String[] args) {
Map<Customer, String> loyaltyTier = new HashMap<>();
Customer enrolled = new Customer("Aarav Sharma", "aarav@example.com");
loyaltyTier.put(enrolled, "GOLD");
Customer checkout = new Customer("Aarav Sharma", "aarav@example.com");
System.out.println("equal? " + enrolled.equals(checkout));
System.out.println("map size: " + loyaltyTier.size());
System.out.println("lookup result: " + loyaltyTier.get(checkout));
}
}
Output:
equal? true
map size: 1
lookup result: null
Read that again: the two customers are equal, the map has one entry, and the lookup still returns null. HashMap.get() first computes the key's hash code to choose a bucket. checkout uses the default identity hash — a different number from enrolled's — so it searches the wrong bucket and never even calls equals().
The Correct Implementation (Modern Java)
The fix is one method — plus the habit of writing it this way every time. Pattern-matching instanceof (stable since Java 16) collapses the old three-line cast dance into a single condition:
final class Customer {
private final String name;
private final String email;
Customer(String name, String email) {
this.name = name;
this.email = email;
}
@Override
public boolean equals(Object o) {
if (this == o) return true; // fast path: identity
if (!(o instanceof Customer c)) return false; // null + wrong type in one check
return Objects.equals(name, c.name) // null-safe field comparison
&& Objects.equals(email, c.email);
}
@Override
public int hashCode() {
return Objects.hash(name, email); // same fields, same contract
}
}
Walk through the checklist: identity shortcut first (reflexive, and fast); pattern-matching instanceof returns false for null and wrong types (null-safe, symmetric); Objects.equals compares fields without NPE risk (consistent); Objects.hash is computed from the same fields equals() uses — that is what guarantees the hashCode contract. With this version, the demo program prints GOLD instead of null.
One decision this template hides: instanceof vs getClass(). For value classes, instanceof is the modern default — it keeps symmetry simple and works with subclasses. Some frameworks (notably JPA entities, far later in this track) prefer getClass() checks for proxy reasons. When in doubt for plain value objects: instanceof.
Immutable Keys: Why Mutating a Key Loses the Entry
The contract has a hidden partner: the fields that feed equals() and hashCode() must not change while the object is a key. HashMap computes the bucket once, at insertion. Mutate the key afterwards and its hash points somewhere new — the entry is orphaned, retrievable by nobody.
import java.util.HashMap;
import java.util.Map;
import java.util.Objects;
class CartItem {
private String sku; // mutable — dangerous as a map key
CartItem(String sku) { this.sku = sku; }
public void setSku(String sku) { this.sku = sku; }
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof CartItem c)) return false;
return Objects.equals(sku, c.sku);
}
@Override
public int hashCode() { return Objects.hash(sku); }
}
public class MutableKeyDemo {
public static void main(String[] args) {
Map<CartItem, Integer> stock = new HashMap<>();
CartItem item = new CartItem("SKU-42");
stock.put(item, 10);
item.setSku("SKU-99"); // mutation AFTER insertion
System.out.println("lookup with same object: " + stock.get(item));
System.out.println("map size: " + stock.size());
}
}
Output:
lookup with same object: null
map size: 1
The entry exists, the reference is identical (== would say true), and the lookup still fails: the hash computed at insertion time was for "SKU-42"; the lookup hashes the current value "SKU-99" and searches a different bucket. The decision rule: keys should be immutable. Make the fields final — or reach for records (post 16), which are immutable by design. Note the same logic applies to HashSet elements and anything stored in a sorted/hashed collection.
The Canonical Pitfalls: String and Integer
Two JDK classes turn up in every equality interview because they look simple and bite anyway.
String: never use ==
String a = "invoice";
String b = "invoice";
String c = new String("invoice");
System.out.println(a == b); // true — both refer to the pooled literal
System.out.println(a == c); // false — c is a separate object
System.out.println(a.equals(c)); // true — same characters
String literals are interned — the JVM keeps one shared copy of each literal, so a == b happens to work. That is an optimization, not a promise. Any string built at runtime (new String(...), user input, substring results, database reads) is a distinct object, and == fails silently. Always compare strings with equals().
Integer: the cache that lies to you
Integer x = 100, y = 100;
Integer m = 1000, n = 1000;
System.out.println(x == y); // true — cached instances
System.out.println(m == n); // false — separate objects
System.out.println(m.equals(n)); // true — same value
Autoboxed integers are cached for the small range the language specification guarantees (−128 to 127), so == works below that line and breaks above it — a boundary that shifts with JVM flags, making it the perfect Heisenbug. Always compare wrapper objects with equals() (or unbox them to primitives first). The same applies to Long, Short, Character, and Boolean.
The Decision Rules (Interview Cheat Sheet)
| Situation | Use | Why |
|---|---|---|
| Comparing two primitives | == | Value comparison is exactly what == does for primitives. |
| Checking "is this the exact same instance?" | == | Identity check — e.g. singleton, enum comparison. |
| Comparing two objects for value equality | equals() | Strings, numbers, dates, your value classes — always. |
| Null-safe comparison where either side may be null | Objects.equals(a, b) | Handles nulls on both sides, no NPE, reads cleanly. |
| Comparing arrays element-by-element | Arrays.equals(a, b) | Plain equals() on arrays is identity — a classic trap. |
Writing equals() | pattern instanceof + Objects.equals | Null-safe, symmetric, concise; the modern default since Java 16. |
Writing hashCode() | Objects.hash(...same fields...) | Same fields as equals() — that is the whole contract. |
Choosing a HashMap key | immutable objects only | Mutating a key after insertion orphans the entry. |
And the three lines worth memorizing: default equals() is identity; override equals() and hashCode() together or not at all; equal objects must hash equally.
Why this matters beyond interviews: the next post in the Core track that depends on this one is the deep dive into how HashMap really works (post 12) — buckets, collisions, and resizing will all read as "the hashCode contract in action." Records (post 16) generate correct equals()/hashCode() for you, which will feel like magic only because you now know what the magic does. And if you spot a contains() that lies or a cache that never hits in your own code, you now know exactly which contract to interrogate first.
Continue: Java Learning Roadmap 2026
Comments
Post a Comment