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.
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.
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:
- At Addition: When
k2is added,i = 2. The HashSet calculateshashCode() = 2and placesk2in bucket 2. - At Mutation: When you change
k2.i = 1, the object remains in bucket 2, but its runtimehashCode()updates to1. - 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 concludesk2does 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
finalproperties) 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.