🔑 Section 10 · Question #10

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):

  1. Data Loss in HashMap: Calling map.get(new Key(id)) returns null, even though an entry with that exact logical key was already inserted.
  2. Duplicates in HashSet: Calling set.add(duplicate) successfully adds a duplicate element, violating the fundamental set uniqueness invariant.
  3. 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:

  1. When calling map.put(u1, "Data"), the JVM requests u1.hashCode(). Suppose the JVM returns 1001. The map calculates the bucket index for default capacity 16: (16 - 1) & 1001 = 9. The node is placed in table[9].
  2. Later, a caller attempts to retrieve the data using an equivalent key instance: map.get(new DefectiveUser(42L)).
  3. The newly instantiated object u2 resides at a different heap location, and Object.hashCode() generates a completely different identity hash code, such as 2044.
  4. The map computes the bucket index: (16 - 1) & 2044 = 12.
  5. The map navigates to bucket table[12]. Bucket 12 is either empty or contains unrelated keys.
  6. The equals() method is never even invoked because the map is searching in the wrong bucket! The call immediately returns null.
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:

  1. Spring Cache (@Cacheable): If a caching method parameter DTO overrides equals() without hashCode(), 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.
  2. 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 throwing NonUniqueObjectException.

🔴 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:

  1. 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.
  2. SpotBugs: Detectors HE_EQUALS_USE_HASHCODE and HE_EQUALS_NO_HASHCODE flag unpaired implementations.
  3. 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
    
  4. Lombok Best Practice: When using @EqualsAndHashCode, explicitly specify @EqualsAndHashCode(onlyExplicitlyIncluded = true) and annotate immutable business keys with @EqualsAndHashCode.Include to 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: HashMap never 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: @Data applies unconfigured @EqualsAndHashCode, which includes mutable fields and breaks hash invariants when entities are mutated.