Hash-based collections in Java—such as HashMap and HashSet—are designed for near-instantaneous O(1) access time. Under the hood, these collections utilize an object's hashCode() value to determine the specific memory bucket index where the object should be stored. However, this design carries a hidden, highly dangerous trap: if an object is added to a set or used as a map key, and you subsequently mutate one of its fields that participates in the hashCode() calculation, the object's hash code will change. Because the collection does not dynamically re-hash elements when their properties change, the mutated object remains physically stuck in its original bucket. When you later attempt to retrieve, contain-check, or remove the object, Java calculates its new hash code, looks in the wrong bucket, finds nothing, and fails silently. In this guide, we will trace a code scenario to see how key mutation traps elements in memory.

Real-World Analogy: The School Sorter Mailbox

To visualize this error, imagine a school mailroom containing a wall of labeled sorting boxes:

  • The Initial Sorting: You receive a letter for a student. You calculate that the letter belongs in Mailbox 2 based on the envelope's details, and you place it in Slot 2.
  • The Secret Mutation: While no one is looking, a student erases the label details on the envelope and writes new info that corresponds to Mailbox 1 (mutating the comparison fields).
  • The Retrieval Failure: Later, you want to retrieve the letter. You read the modified envelope details, conclude it belongs in Mailbox 1, and search inside Slot 1. Because Slot 1 is completely empty, you conclude the letter does not exist.
The letter remains trapped in Slot 2 indefinitely, invisible to the sorting system because its address no longer matches its location.

1. The Mutability Code Scenario

Here is a mutable class definition where the hash code calculation depends directly on a mutable field:

import java.util.HashSet;
import java.util.Set;
 
class KeyMaster {
    public int i;
 
    public KeyMaster(int i) {
        this.i = i;
    }
 
    public boolean equals(Object o) {
        return i == ((KeyMaster) o).i;
    }
 
    public int hashCode() {
        return i; // Hash code depends directly on mutable field 'i'
    }
}

2. Tracing the Execution

Now, let's trace the execution steps when we insert objects, mutate a field, and attempt to clean them up:

public static void main(String[] args) {
    Set<KeyMaster> set = new HashSet<>();
    KeyMaster k1 = new KeyMaster(1);
    KeyMaster k2 = new KeyMaster(2);
 
    set.add(k1);
    set.add(k1); // Duplicate addition is ignored
    set.add(k2);
    set.add(k2); // Duplicate addition is ignored
 
    System.out.print(set.size() + ":"); // PRINTS: 2:
    
    // TRAP: Mutate k2's comparison field!
    k2.i = 1; 
    
    System.out.print(set.size() + ":"); // PRINTS: 2: (Set size is unchanged)
    
    set.remove(k1);
    System.out.print(set.size() + ":"); // PRINTS: 1: (k1 is successfully removed)
    
    set.remove(k2);
    System.out.print(set.size());       // PRINTS: 1 (k2 CANNOT be found or removed!)
}

Under the Hood: Why the Trap Fails

Let's trace the exact bucket state transitions to see why set.remove(k2) fails:

  1. At Addition: When k2 is added, i = 2. The HashSet calculates hashCode() = 2 and places k2 in bucket 2.
  2. At Mutation: When you change k2.i = 1, the object remains in bucket 2, but its runtime hashCode() updates to 1.
  3. At Removal: When you call set.remove(k2), the set evaluates the object's current hash code (1) and searches bucket 1. Since bucket 1 is empty (or holds other entries), the set concludes k2 does not exist. The reference remains trapped in bucket 2, causing a silent memory leak.

Conclusion & Design Best Practices

To prevent this collection trap, follow these core rules:

  • Prioritize Immutability: Always design map keys and set elements using immutable fields (such as final properties) so their hash codes cannot change after construction.
  • The Remove-Modify-Add Pattern: If you absolutely must modify an element's fields, first remove the object from the collection, perform the modification, and then re-add it to ensure it is placed in the correct bucket.