What Happens If You Override equals() But Not hashCode()
If a class overrides equals() but leaves hashCode() unoverridden, it inherits the default implementation from java.lang.Object. Standard Object.hashCode() returns an Identity Ha...
🟢 Junior Level
Short Interview Answer (30 seconds)
If a class overrides equals() but leaves hashCode() unoverridden, it inherits the default implementation from java.lang.Object. Standard Object.hashCode() returns an Identity Hash Code (a pseudorandom integer generated by the JVM and stored in the object’s memory header).
As a result, two logically equivalent objects will have completely different hash codes. This catastrophically breaks all hash-based collections in Java (HashMap, HashSet, ConcurrentHashMap, LinkedHashMap):
- Data Loss in
HashMap: Callingmap.get(new Key(id))returnsnull, even though an entry with that exact logical key was already inserted. - Duplicates in
HashSet: Callingset.add(duplicate)successfully adds a duplicate element, violating the fundamental set uniqueness invariant. - Memory Leaks (“Ghost Entries”): Calling
map.remove(key)fails to locate the entry because it inspects the wrong bucket, leaving orphaned entries in memory indefinitely.
Disaster Demonstration in Java 21
import java.util.HashSet;
import java.util.HashMap;
import java.util.Set;
import java.util.Map;
public class BrokenContractDemo {
// Broken contract: equals is overridden, hashCode is NOT!
public static class DefectiveUser {
private final long id;
public DefectiveUser(long id) { this.id = id; }
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof DefectiveUser that)) return false;
return this.id == that.id;
}
// hashCode() is omitted! Falls back to Object.hashCode()
}
public static void main(String[] args) {
DefectiveUser u1 = new DefectiveUser(42L);
DefectiveUser u2 = new DefectiveUser(42L);
System.out.println("Logically equal? " + u1.equals(u2)); // true
// 1. HashSet breakdown:
Set<DefectiveUser> set = new HashSet<>();
set.add(u1);
set.add(u2);
// The set now holds TWO logically identical users! Uniqueness invariant shattered:
System.out.println("HashSet size: " + set.size()); // 2! (Expected: 1)
// 2. HashMap breakdown:
Map<DefectiveUser, String> cache = new HashMap<>();
cache.put(u1, "AdminPermissions");
// Attempting to retrieve data using an equivalent key instance:
System.out.println("Value for u2: " + cache.get(u2)); // null! (Data is "lost")
}
}
🟡 Middle Level
Why equals() Is Never Even Called: Lookup Mechanics in HashMap
To understand the mechanics of this failure, examine the core lookup loop inside HashMap.getNode():
// OpenJDK HashMap.getNode() logic:
int hash = hash(key); // Calls key.hashCode()
Node<K,V> first = tab[(n - 1) & hash]; // Computes target bucket index
if (first != null) {
if (first.hash == hash && ((k = first.key) == key || (key != null && key.equals(k))))
return first;
// ... traverse collision chain (linked list or red-black tree)
}
return null;
Step-by-Step Failure Scenario:
- When calling
map.put(u1, "Data"), the JVM requestsu1.hashCode(). Suppose the JVM returns1001. The map calculates the bucket index for default capacity 16:(16 - 1) & 1001 = 9. The node is placed intable[9]. - Later, a caller attempts to retrieve the data using an equivalent key instance:
map.get(new DefectiveUser(42L)). - The newly instantiated object
u2resides at a different heap location, andObject.hashCode()generates a completely different identity hash code, such as2044. - The map computes the bucket index:
(16 - 1) & 2044 = 12. - The map navigates to bucket
table[12]. Bucket 12 is either empty or contains unrelated keys. - The
equals()method is never even invoked because the map is searching in the wrong bucket! The call immediately returnsnull.
| Step | Operation | Key Instance | Hash Code | Target Bucket | equals() Invoked? |
Result |
|---|---|---|---|---|---|---|
| 1 | map.put(u1, "Data") |
u1 (id=42) |
1001 |
table[9] |
N/A | Stored in bucket 9 |
| 2 | map.get(u2) |
u2 (id=42) |
2044 |
table[12] |
Never | null (Cache Miss) |
| 3 | map.remove(u2) |
u2 (id=42) |
2044 |
table[12] |
Never | null (Ghost Entry Leak) |
Impact on Enterprise Frameworks
In enterprise applications, this subtle bug causes critical production incidents:
- Spring Cache (
@Cacheable): If a caching method parameter DTO overridesequals()withouthashCode(), each incoming HTTP request generates a new DTO instance with a distinct identity hash code. This causes a 100% Cache Miss rate. The cache rapidly bloats with millions of duplicate entries while the backend database is overwhelmed by redundant queries. - JPA / Hibernate L1 Cache (Persistence Context): Hibernate tracks managed entities in an internal session map. A broken
hashCode()can lead to duplicate entity representations within the same persistence context, corrupting change tracking and throwingNonUniqueObjectException.
🔴 Senior Level
How HotSpot C++ Generates Object.hashCode()
When hashCode() is not overridden, the JVM executes the native intrinsic java.lang.Object.hashCode(), which routes to get_next_hash in HotSpot’s C++ runtime (synchronizer.cpp):
JVM Flag: -XX:hashCode=N (Default: 5)
Algorithm 5: Marsaglia's xor-shift PRNG on thread-local state.
- The default hash code is not a raw memory address. Because modern garbage collectors (ZGC, G1, Shenandoah, Parallel) continuously relocate objects during heap compaction, using a raw address would cause the hash code to mutate across GC pauses.
- It is a 31-bit pseudorandom number generated on first access and lazily written once into the object header’s Mark Word.
- Two successive allocations of
new User(1L)are guaranteed to receive different pseudorandom numbers in their Mark Words.
Ghost Entries Memory Leak Pattern
When remove() cannot locate the target bucket, entries cannot be evicted, causing an insidious memory leak into the Old Generation:
public class LeakyCacheService {
// Long-lived cache held by a Singleton service:
private final Map<DefectiveKey, SessionData> activeSessions = new ConcurrentHashMap<>();
public void login(long userId, SessionData data) {
activeSessions.put(new DefectiveKey(userId), data);
}
public void logout(long userId) {
// ❌ SILENT MEMORY LEAK: remove() calculates a different bucket index!
// The original entry remains referenced in table[] forever!
activeSessions.remove(new DefectiveKey(userId));
}
}
Under thousands of login/logout cycles per day, the map continuously accumulates uncollectible Node<K,V> instances until the JVM crashes with java.lang.OutOfMemoryError: Java heap space.
Automated Prevention in CI/CD Quality Gates
Modern engineering standards enforce automated detection of broken contracts:
- SonarQube / SonarLint: Rule
java:S1206(“equals(Object)” and “hashCode()” should be overridden in pairs) is flagged as a Major/Critical Bug and blocks pull request merges. - SpotBugs: Detectors
HE_EQUALS_USE_HASHCODEandHE_EQUALS_NO_HASHCODEflag unpaired implementations. - Java 14+ Records: For DTOs and cache keys, preferred practice is using
record, where the compiler automatically generates canonical implementations for both methods based on all component fields:public record UserKey(long id) {} // Canonical equals/hashCode guaranteed by compiler - Lombok Best Practice: When using
@EqualsAndHashCode, explicitly specify@EqualsAndHashCode(onlyExplicitlyIncluded = true)and annotate immutable business keys with@EqualsAndHashCode.Includeto prevent mutable or transient fields from entering the hash calculation.
🎯 Interview Cheat Sheet
1. 30-Second Elevator Pitch
Overriding equals() without overriding hashCode() breaks the fundamental Java contract. Equal objects inherit distinct identity hash codes from Object.hashCode() (generated via HotSpot’s Marsaglia xor-shift PRNG). Consequently, HashMap.get() hashes to the wrong bucket and returns null without even calling equals(). HashSet permits duplicate elements, breaking set uniqueness. Furthermore, remove() fails silently, causing progressive Old Generation memory leaks (“ghost entries”).
2. 4 Tricky Interview Questions with Deep Answers
Question 1: Why does HashSet.contains() return false for an element that was definitely added earlier, if only equals() was overridden?
Answer:
HashSet is internally backed by a HashMap. When calling contains(new Key(1)), the set invokes key.hashCode() to determine the bucket index. Because hashCode() was not overridden, the new key instance receives its own distinct identity hash code. The set navigates to a different bucket index. That bucket is either empty or contains unrelated objects, so contains() immediately returns false without ever evaluating equals().
Question 2: How exactly does a memory leak (“ghost entries”) develop in long-lived caches due to an omitted hashCode()?
Answer:
When an item is evicted via cache.remove(new Key(id)), the map computes the hash of the transient lookup key. Because the contract is broken, this hash directs the map to a different bucket than the one containing the stored entry. The remove() method finds nothing and returns null. The original Node<K,V> and all referenced payload objects remain strongly reachable through the map’s internal table[] array, shielding them from garbage collection and progressively exhausting the heap.
Question 3: Can a program function correctly if only equals() is overridden, and in what single scenario?
Answer:
Yes, but strictly only if instances of that class are never stored in or queried against hash-based collections (HashMap, HashSet, ConcurrentHashMap, LinkedHashMap). For example, if instances are exclusively stored in ArrayList or LinkedList and queried via list.contains() or list.indexOf() (which rely solely on sequential equals() comparisons). However, this is an anti-pattern and a brittle architectural liability, as another developer could later place the class into a hash collection.
Question 4: What does Lombok’s @EqualsAndHashCode do, and what is the danger of applying it blindly?
Answer:
@EqualsAndHashCode generates both methods using all non-static fields by default, ensuring contract symmetry. The danger is that it includes every field, including mutable collections, lazy JPA relationship proxies, and transient cache fields. In JPA entities, this causes LazyInitializationException, cyclic StackOverflowError, or mutating hash codes when child collections are modified. The safe approach is @EqualsAndHashCode(onlyExplicitlyIncluded = true) targeting strictly immutable business identifiers.
3. Common Pitfalls & Red Flags
-
❌ “If you add two equal objects with missing hashCode to a HashSet, the second replaces the first.”
Correction: With different hash codes, they land in different buckets; both stay in the set, producing duplicate entries. -
❌ “HashMap always falls back to calling equals() across the entire table if the hash doesn’t match.”
Correction:HashMapnever performs a full table scan; it strictly checks the computed bucket. If the bucket is wrong,equals()is never called. -
❌ “Overriding only equals() is safe if objects are only used in a List.”
Correction: While technically functional in a List, it violates class contract invariants and creates latent bugs the moment a caller places the object in a cache or Set. -
❌ “Lombok’s @Data is completely safe for domain entities.”
Correction:@Dataapplies unconfigured@EqualsAndHashCode, which includes mutable fields and breaks hash invariants when entities are mutated.
4. Related Topics
- What is the equals() and hashCode() Contract — Fundamental invariants
- If Two Objects Are Equal by equals(), What Can You Say About Their hashCode() — Invariant guarantees
- If Two Objects Have the Same hashCode(), Are They Necessarily Equal by equals() — Collision mechanics
- What Happens If You Override hashCode() But Not equals() — The inverse failure mode
- Can You Use a Mutable Object as a Key in HashMap — Key mutability hazards