🔑 Section 10 · Question #11

What Happens If You Override hashCode() But Not equals()

If you override hashCode() but omit overriding equals():


🟢 Junior Level

Short Interview Answer (30 seconds)

If you override hashCode() but omit overriding equals():

  1. Logically equivalent objects will generate the identical hash code and land in the exact same bucket of a HashMap or HashSet.
  2. However, to distinguish between keys sharing a bucket, HashMap invokes key.equals(k). Because equals() was not overridden, the class inherits the default implementation from java.lang.Object, which performs strict reference identity comparison (this == obj).
  3. When querying with a newly instantiated object having identical fields (map.get(new Key("A"))), heap references differ (0x100 != 0x200), equals() returns false, and HashMap returns null, treating the key as missing. In HashSet, duplicates will continuously accumulate within that single bucket.

Comparison of the Two Symmetrical Contract Violations

Scenario Bucket Index Resolution (get) In-Bucket Collision Check Result of map.get(newKey) HashSet Behavior
Override equals(), omit hashCode() ❌ Wrong Bucket (objects scatter across different bins) Never reached (equals() is not called) null Duplicates scatter across multiple buckets
Override hashCode(), omit equals() ✅ Correct Bucket (objects land in the exact same bin) ❌ Fails (reference equality this == obj) null Duplicates accumulate inside the same bucket

Disaster Demonstration in Java 21

import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;

public class BrokenEqualsDemo {

    public static class SkuCode {
        private final String code;
        public SkuCode(String code) { this.code = code; }

        @Override
        public int hashCode() {
            return code != null ? code.hashCode() : 0; // ✅ hashCode is overridden!
        }
        // ❌ equals() is NOT overridden! Inherits Object.equals (this == obj)
    }

    public static void main(String[] args) {
        SkuCode sku1 = new SkuCode("ITEM-777");
        SkuCode sku2 = new SkuCode("ITEM-777");

        // 1. Hash codes match, target bucket is identical:
        System.out.println("Hash codes match? " + (sku1.hashCode() == sku2.hashCode())); // true
        System.out.println("Equal by equals? " + sku1.equals(sku2)); // false! (distinct heap references)

        // 2. HashMap breakdown:
        Map<SkuCode, Integer> prices = new HashMap<>();
        prices.put(sku1, 1500);

        // Lookup with newly instantiated sku2 returns null!
        System.out.println("Price via sku2: " + prices.get(sku2)); // null!

        // BUT lookup using the exact same heap reference sku1 succeeds:
        System.out.println("Price via sku1: " + prices.get(sku1)); // 1500

        // 3. HashSet breakdown:
        Set<SkuCode> uniqueSkus = new HashSet<>();
        uniqueSkus.add(sku1);
        uniqueSkus.add(sku2);
        System.out.println("HashSet size: " + uniqueSkus.size()); // 2! (Duplicate accepted into same bucket)
    }
}

🟡 Middle Level

Step-by-Step HotSpot HashMap.getNode() Trace

Why map.get(sku2) returns null despite a perfect hash match:

// OpenJDK HashMap.java bucket search logic:
if (first.hash == hash && 
   ((k = first.key) == key || (key != null && key.equals(k))))
    return first;
  1. first.hash == hash: The bucket index is calculated (e.g., bucket 5). Slot table[5] contains the node with sku1. Their hash codes match $\implies$ true.
  2. (k = first.key) == key: Fast-path reference comparison. The pointer to the stored key (sku1 at heap address 0x100) is compared with the query pointer (sku2 at heap address 0x200). Result $\implies$ false.
  3. key.equals(k): The map invokes sku2.equals(sku1). Because equals() is not overridden, it executes java.lang.Object.equals():
    public boolean equals(Object obj) {
        return (this == obj); // Strict pointer equality again!
    }
    

    Result $\implies$ false.

  4. The map iterates through the rest of the bucket chain (if any). Every node fails reference comparison, and HashMap returns null.

🔴 Senior Level

Production Catastrophe: Event Deduplication in Kafka / Messaging Queues

The most destructive consequence in distributed microservices is using a class with omitted equals() in an idempotent message deduplicator:

@Component
public class PaymentDeduplicationFilter {
    // Processed transaction set protecting against double-charges:
    private final Set<PaymentKey> processedKeys = ConcurrentHashMap.newKeySet();

    public boolean isDuplicate(PaymentKey key) {
        // Every incoming HTTP request or Kafka record deserializes into a NEW heap instance!
        // key.equals() performs reference comparison (this == obj)!
        return !processedKeys.add(key); // 💥 ALWAYS returns false!
    }
}

Business & Operational Impact:

  1. Double Charges & Financial Loss: Duplicate checking fails entirely. Because each deserialized PaymentKey is a new object in the heap, Set.add() treats every duplicate as unique, allowing duplicate financial operations through.
  2. Single-Bucket Memory Leak: Every duplicate payload shares the identical hashCode(), routing every single record into the exact same bucket.
  3. Degradacy to Red-Black Tree (TreeNode): When the bucket exceeds 8 entries, it treeifies. Because PaymentKey does not implement Comparable, HotSpot falls back to tieBreakOrder() using System.identityHashCode(). Under high throughput, this induces severe CPU thrashing, destroys L1/L2/L3 cache locality, and eventually crashes the JVM with OutOfMemoryError: Java heap space.

Architectural Contract Testing via EqualsVerifier

In enterprise codebases, contract correctness is strictly enforced using EqualsVerifier in unit tests:

import nl.jqno.equalsverifier.EqualsVerifier;
import org.junit.jupiter.api.Test;

public class ContractVerificationTest {
    @Test
    void verifySkuCodeContract() {
        // Automatically tests: 5 equals axioms, hashCode consistency,
        // null safety, reflexivity, symmetry, transitivity, and inheritance:
        EqualsVerifier.simple().forClass(SkuCode.class).verify();
        // Fails with clear diagnostic message:
        // "Subclass does not override equals, but does override hashCode"
    }
}

🎯 Interview Cheat Sheet

1. 30-Second Elevator Pitch

If hashCode() is overridden but equals() is omitted, objects route to the correct HashMap bucket, but in-bucket matching fails because inherited Object.equals() uses reference identity (==). Lookups using new instances return null, and HashSet accumulates duplicates inside the exact same bucket. In production, this causes deduplication filters (e.g. Kafka idempotent consumers) to fail, allowing duplicate payments while overcrowding a single bucket into a Red-Black tree that causes severe CPU spikes and memory exhaustion.


2. 4 Tricky Interview Questions with Deep Answers

Question 1: What is the fundamental difference in HashSet behavior between omitting equals() versus omitting hashCode()?

Answer:

  • When omitting hashCode(): Duplicates receive different identity hash codes and scatter across different buckets throughout the table.
  • When omitting equals(): Duplicates generate identical hash codes and cluster inside the exact same bucket. The collision chain in that single bucket grows unboundedly, treeifying into a TreeNode and causing substantial CPU overhead during rebalancing and tieBreakOrder() comparisons.

Question 2: Will map.get(key) work if you pass the exact same object reference that was used during map.put(key, value)?

Answer: Yes, it will work. In OpenJDK’s HashMap.getNode(), the lookup condition begins with (k = first.key) == key. If the identical heap reference is provided, reference equality evaluates to true, and HashMap immediately returns the entry without ever invoking equals(). The bug only manifests when querying with a different instance with identical field values (new Key(...)), which is what happens in virtually all real-world applications upon request deserialization.

Question 3: Why does message deduplication in Kafka/JMS fail when using HashSet<EventDto> if EventDto only overrides hashCode()?

Answer: Serialization frameworks (Jackson, Gson, Protobuf) construct a new Java heap instance for each arriving message. When set.add(event) executes, it checks equality against existing elements. Because equals() is not overridden, it defaults to this == obj. Two distinct heap instances are never reference-equal, so set.add() always returns true. The deduplicator erroneously treats every repeated message as brand new.

Question 4: How does Java 14+ record eliminate both of these contract mistakes entirely?

Answer: The Java compiler automatically synthesizes canonical bytecode for both equals() and hashCode() based on all component fields in the record header. A developer cannot accidentally override one and forget the other. Furthermore, record components are intrinsically final, ensuring that keys remain immutable and their hash codes stay constant throughout collection lifecycles.


3. Common Pitfalls & Red Flags

  • ❌ “If hashCode() is overridden, HashMap can always find the object.”
    Correction: The hash code only finds the bucket. Finding the exact entry within the bucket requires equals().

  • ❌ “Omitting equals() is less severe than omitting hashCode().”
    Correction: It is equally destructive functionally (returns null on new instances) and architecturally worse for performance because it concentrates all duplicate entries into a single overloaded bucket.

  • ❌ “HashMap uses reflection or field-by-field comparison if equals is not overridden.”
    Correction: HashMap strictly calls key.equals(k). It never uses reflection.

  • ❌ “If map.get(key) works in a unit test, equals() must be working.”
    Correction: If the unit test reuses the same local variable reference key for both put and get, (k == key) evaluates to true and masks the missing equals() implementation.