🔑 Section 10 · Question #8

If Two Objects Are Equal by equals(), What Can You Say About Their hashCode()

If two objects are equal according to equals() (o1.equals(o2) == true), their hash codes MUST BE STRICTLY IDENTICAL (o1.hashCode() == o2.hashCode()).


🟢 Junior Level

Short Interview Answer (30 Seconds)

If two objects are equal according to equals() (o1.equals(o2) == true), their hash codes MUST BE STRICTLY IDENTICAL (o1.hashCode() == o2.hashCode()).

This is a mandatory invariant of the Java Language Specification (java.lang.Object). It is dictated by the fundamental architecture of hash tables, where lookups in HashMap and HashSet operate as a two-phase pipeline:

  1. Phase 1 (Coarse & Fast): The hashCode() value determines the target bucket index (table[index]).
  2. Phase 2 (Exact): Inside the targeted bucket, equals() evaluates candidate nodes to isolate the matching key.

If equal objects produce different hash codes, HashMap maps them to different bucket indices. Looking up an existing key via map.get() will inspect the wrong bucket and return null, while a HashSet will silently store duplicate items.


Comparison Table: Logical Implications and Invariants

Logical Assertion Truth Value Underlying Reason Architectural Consequence
$A.\text{equals}(B) \implies \text{hash}(A) == \text{hash}(B)$ ✅ TRUE (Mandatory) Direct Java Language Specification requirement Guarantees lookups land in the same bucket
$\text{hash}(A) == \text{hash}(B) \implies A.\text{equals}(B)$ ❌ FALSE (Not Guaranteed) Hash Collision (distinct objects sharing a hash) Resolved via collision chains or trees
$A.\text{equals}(B) == \text{false} \implies \text{hash}(A) \ne \text{hash}(B)$ ❌ FALSE Distinct objects can produce identical hashes Standard collision
$\text{hash}(A) \ne \text{hash}(B) \implies A.\text{equals}(B) == \text{false}$ ✅ TRUE (Contrapositive) Distinct hashes guarantee object inequality Single-cycle CPU short-circuit filter

Java 21 Demonstration

import java.util.HashMap;

public class EqualsHashCodeContractDemo {

    public record CorrectUser(int id, String name) {}

    public static void main(String[] args) {
        // Instantiate two logically identical objects residing at distinct heap memory addresses
        CorrectUser u1 = new CorrectUser(101, "Alice");
        CorrectUser u2 = new CorrectUser(101, "Alice");

        // 1. Evaluate equality via equals()
        System.out.println("u1.equals(u2): " + u1.equals(u2)); // true

        // 2. Their hash codes MUST be identical:
        System.out.println("Hash codes match: " + (u1.hashCode() == u2.hashCode())); // true

        // 3. Flawless retrieval in HashMap:
        HashMap<CorrectUser, String> cache = new HashMap<>();
        cache.put(u1, "VIP-Session");

        // Retrieve using the second instance u2 as key:
        String session = cache.get(u2);
        System.out.println("Retrieved session: " + session); // "VIP-Session" (Found!)
    }
}

🟡 Middle Level

Fatal Consequences of Violating the Invariant: $equals \implies hashCode$

If a developer overrides equals() but forgets to override hashCode(), instances inherit the default implementation from Object, which assigns a unique identityHashCode based on memory references.

System Breakdown:

  1. Silent Lookup Failures (map.get(key) == null):
    Map<MyKey, String> map = new HashMap<>();
    map.put(new MyKey(1), "Found");
    map.get(new MyKey(1)); // Returns null!
    

    The first key maps to bucket 3. The second key (though logically equal) computes a different hash and searches bucket 11, which is empty.

  2. Duplicate Elements in HashSet:
    HashSet guarantees element uniqueness by delegating directly to an internal HashMap. Because unequal hashes map to different buckets, both equal instances are inserted. set.size() grows unexpectedly, corrupting business logic.
  3. Memory Leaks on Removal:
    Calling map.remove(new MyKey(1)) searches the wrong bucket, returns null, and leaves the original entry permanently stranded in the heap.

Hashing Floating-Point Primitives: Double and Float

When manually implementing hashCode() for floating-point fields, casting double to int ((int) value) is a critical error: values 1.2 and 1.8 would both truncate to 1.

The JDK resolves this via IEEE 754 bitwise normalization:

// OpenJDK Double.hashCode() implementation:
public static int hashCode(double value) {
    long bits = doubleToLongBits(value);
    return (int)(bits ^ (bits >>> 32));
}

Double.doubleToLongBits() collapses all representations of NaN (Not-a-Number) into a single canonical bit pattern and separates +0.0 from -0.0, ensuring that Double.valueOf(Double.NaN).equals(...) aligns with hashCode().


🔴 Senior Level

The Heisenbug Effect in Highload Systems

The most dangerous aspect of violating the $equals \implies hashCode$ contract is that the bug often remains invisible in local unit tests and detonates only in production:

Suppose object A equals object B, but:
hash(A) = 17 (binary: 0001 0001)
hash(B) = 33 (binary: 0010 0001)
  1. In Local Unit Tests (Default Capacity 16):
    • Bucket index for A: 17 & 15 = 1
    • Bucket index for B: 33 & 15 = 1
    • Accidental Bucket Collision: Although the hash codes differed, the 4-bit mask 0000 1111 discarded the differing higher bits. Both keys landed in bucket 1 by pure coincidence, equals() found the node, and the test passed cleanly!
  2. In Production Under High Load:
    • As elements accumulate, HashMap resizes to capacity 32 (n - 1 = 31 = 0001 1111_b).
    • Bucket index for A: 17 & 31 = 17
    • Bucket index for B: 33 & 31 = 1
    • The keys split into different buckets. Production lookups suddenly return null, dropping customer transactions. These bugs are classified as Heisenbugs because their manifestation depends dynamically on runtime table capacity.

Contraposition Optimization: $\text{hash}_1 \ne \text{hash}_2 \implies o_1 \ne o_2$

In mathematical formal logic, the direct rule: \(A \implies B \quad (equals \implies hashCode)\) is logically equivalent to its contrapositive: \(\neg B \implies \neg A \quad (hash_A \ne hash_B \implies !equals)\)

This contrapositive principle is the bedrock of Java’s collection performance:

// HotSpot HashMap.getNode() node check:
if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k))))

Thanks to the contrapositive guarantee, the JVM first compares two 32-bit integer CPU registers (p.hash == hash). If they differ, the runtime mathematically guarantees that the objects cannot be equal, instantly bypassing the expensive virtual equals() method.


🎯 Interview Cheat Sheet

1. 30-Second Elevator Pitch

If two objects are equal by equals(), their hashCode() values MUST be strictly identical. The reverse is not true: equal hash codes indicate a collision. If equal objects return different hash codes, HashMap places them in different buckets: get() returns null, HashSet retains duplicates, and memory leaks occur. The contrapositive law ($hash_1 \ne hash_2 \implies o_1 \ne o_2$) allows HashMap to discard non-matching objects in a single CPU cycle before invoking equals().


2. 4 Tricky Questions with Deep Answers

Question 1: What happens in a HashSet if two objects evaluate to true via equals(), but return different hashCode() values?

Answer: HashSet is backed by an internal HashMap where items are stored as keys with a dummy PRESENT value. When adding the second object, HashSet computes its hash code. Because the hash differs from the first object, HashMap assigns it to a completely different bucket. Consequently, the set stores both logically identical objects, violating the set’s contract of element uniqueness, and set.size() returns 2 instead of 1.

Question 2: How can an accidental bucket index collision mask a broken hashCode() implementation during local unit testing?

Answer: In small tables ($N = 16$), the bitmask (16 - 1) = 15 evaluates only the lowest 4 bits. If two equal objects have different 32-bit hash codes that happen to share identical lowest 4 bits (e.g., 17 and 33), both objects map to bucket index 1. Within bucket 1, equals() evaluates to true, and unit tests pass. In production, as entries exceed the threshold, resize() expands capacity to 32 or 64. The new bitmask exposes higher-order bits, splitting the keys into different buckets and triggering silent lookup failures.

Question 3: Must two objects that have identical hashCode() values be equal according to equals()?

Answer: No. This is the definition of a hash collision. The range of hashCode() is restricted to 32-bit integers ($2^{32} \approx 4.3 \times 10^9$), whereas the universe of possible objects is infinite (Pigeonhole Principle). Identical hash codes merely require HashMap to place both entries in the same bucket; true equality is determined subsequently via equals().

Question 4: How should hashCode() be implemented for double and float fields?

Answer: Never cast directly to int (which discards fractional values) or use ==. For float, use Float.floatToIntBits(f). For double, use Double.doubleToLongBits(d) followed by folding the upper 32 bits into the lower 32 bits: (int)(bits ^ (bits >>> 32)). This normalizes NaN values and distinguishes +0.0 from -0.0, preserving consistency with Double.equals().


3. Common Pitfalls and Red Flags

❌ “If objects are equal by equals(), their hash codes SHOULD preferably be equal.”
Correction: They MUST be strictly equal. This is an absolute invariant, not an optional recommendation.

❌ “If two objects have equal hash codes, they must be equal by equals().”
Correction: False. This reverses cause and effect. Equal hash codes simply represent a collision.

❌ “Using heap memory addresses as the basis for a domain object’s hashCode().”
Correction: Domain hash codes must be derived strictly from the same fields evaluated in equals().