Definition
- When a mutable key is mutated after
put(), itshashCode()changes - The entry remains in the bucket computed from the original hash – it does not move
get()with the mutated key computes a new hash, goes to a different bucket, and returns null- The entry exists in the old bucket (visible during iteration, counted in
size()), but cannot be found byget(),containsKey(), orremove() - This is a memory leak: unreachable entries occupy memory and are never garbage collected
- Rule: always use immutable objects as HashMap keys
You file a document in drawer number 7, computed from the account number on the document. Then you change the account number on the document. When you search for it using the new account number, you look in drawer 12 – not found. The document is still in drawer 7, but nobody can find it because the label no longer matches. The filing cabinet has a phantom entry that wastes space forever.
Understanding the Concepts
What happens when you mutate a key after insertion?
The problem unfolds in four steps:
- You insert the key. HashMap computes
hash(key)and stores the entry in the corresponding bucket. - You mutate the key. The key's
hashCode()now returns a different value. - You call
get(key). HashMap computes the new hash, goes to a different bucket, and does not find the entry. - The entry is stuck in the old bucket, unreachable by any key-based operation.
package com.bank.demo;
import java.util.*;
public class MutableKeyDemo {
static class Account {
String id;
Account(String id) { this.id = id; }
@Override public int hashCode() { return id.hashCode(); }
@Override public boolean equals(Object o) {
return o instanceof Account a && a.id.equals(this.id);
}
@Override public String toString() { return "Account(" + id + ")"; }
}
public static void main(String[] args) {
Map<Account, String> map = new HashMap<>();
Account key = new Account("SAV2001");
// Step 1: insert with hash of "SAV2001"
map.put(key, "Rajesh");
System.out.println("Before mutation: " + map.get(key)); // Rajesh
// Step 2: MUTATE the key
key.id = "CUR3001";
// Step 3: get() computes hash of "CUR3001" → wrong bucket
System.out.println("After mutation: " + map.get(key)); // null!
// Try the original value – also fails
System.out.println("Original lookup: " + map.get(new Account("SAV2001"))); // null!
// Why? The entry in the old bucket has key.id = "CUR3001" now.
// equals("SAV2001") fails because the stored key's id is "CUR3001".
// Try the new value
System.out.println("New value lookup: " + map.get(new Account("CUR3001"))); // null!
// Why? The new hash points to a different bucket where no entry exists.
// The entry is STUCK
System.out.println("Size: " + map.size()); // 1 – entry exists!
System.out.println("Contains key: " + map.containsKey(key)); // false!
// Only visible during iteration
for (var entry : map.entrySet()) {
System.out.println("Iteration found: " + entry.getKey() + " → " + entry.getValue());
}
}
}
Before mutation: Rajesh After mutation: null Original lookup: null New value lookup: null Size: 1 Contains key: false Iteration found: Account(CUR3001) → Rajesh
Why does looking up by the original value also fail?
The entry is in the bucket computed from hash("SAV2001"). When you look up new Account("SAV2001"), HashMap goes to the correct bucket. But the stored key's id has been changed to "CUR3001". So equals("SAV2001") checks against "CUR3001" and returns false. The entry is right there in the bucket, but equals() cannot match it.
This creates a truly unreachable entry: no key value can find it.
What types are safe as HashMap keys?
| Type | Safe? | Why |
|---|---|---|
| String | Yes | Immutable – hashCode never changes |
| Integer, Long, Double | Yes | Immutable wrapper types |
| enum | Yes | Enum constants are immutable |
| Java record | Yes* | If all components are immutable |
| Custom immutable class | Yes | All fields final, no setters |
| Mutable class | No | hashCode can change after insertion |
| arrays | No | hashCode is based on identity, equals compares identity |
// Safe keys:
Map<String, String> map1 = new HashMap<>(); // String is immutable
Map<Integer, String> map2 = new HashMap<>(); // Integer is immutable
Map<DayOfWeek, String> map3 = new EnumMap<>(DayOfWeek.class); // enum is immutable
// Safe: records with immutable components
record AccountId(String branch, String number) {} // immutable
Map<AccountId, String> map4 = new HashMap<>();
// UNSAFE: mutable class as key
class MutableAccount { String id; /* no final, has setter */ }
How do you make a custom class safe as a HashMap key?
package com.bank.model;
import java.util.Objects;
public final class AccountKey {
private final String branchCode;
private final String accountNumber;
public AccountKey(String branchCode, String accountNumber) {
this.branchCode = Objects.requireNonNull(branchCode);
this.accountNumber = Objects.requireNonNull(accountNumber);
}
@Override
public int hashCode() {
return Objects.hash(branchCode, accountNumber);
}
@Override
public boolean equals(Object o) {
return o instanceof AccountKey ak
&& ak.branchCode.equals(this.branchCode)
&& ak.accountNumber.equals(this.accountNumber);
}
@Override
public String toString() {
return branchCode + "-" + accountNumber;
}
// No setters! Fields are final!
public String branchCode() { return branchCode; }
public String accountNumber() { return accountNumber; }
}
Or use a record (Java 16+), which is immutable by default:
record AccountKey(String branchCode, String accountNumber) {}
// hashCode, equals, and toString are auto-generated
// Fields are final – cannot be mutated
What are common mistakes?
Mistake: Using a mutable collection as a key
// WRONG: List is mutable – hashCode changes if elements change
Map<List<String>, String> map = new HashMap<>();
List<String> key = new ArrayList<>(List.of("SAV", "2001"));
map.put(key, "Rajesh");
key.add("EXTRA"); // hashCode changes!
System.out.println(map.get(key)); // null! Entry lost.
// CORRECT: use an immutable list or convert to String
Map<List<String>, String> map2 = new HashMap<>();
map2.put(List.of("SAV", "2001"), "Rajesh"); // List.of is immutable
What should a senior engineer know?
- String caching. String caches its hashCode after the first call. This makes String extremely fast as a HashMap key – hashCode is computed once and reused.
- Identity vs equality for keys. If you do not override hashCode/equals, HashMap uses Object.hashCode (identity-based). Two different Account objects with the same data are treated as different keys. Always override both methods.
- The hashCode/equals contract. If a.equals(b) is true, then a.hashCode() == b.hashCode() must be true. The reverse is not required: equal hash codes do not imply equals (collisions are allowed).
- TreeMap alternative. If your keys implement Comparable, TreeMap does not use hashCode at all. It uses compareTo for ordering and equality. Mutable keys in TreeMap cause the same lost-entry problem if compareTo results change.
What are common interview follow-up questions?
- "What happens if you mutate a HashMap key?" – Entry becomes unreachable; get returns null.
- "Can you use an array as a HashMap key?" – Technically yes, but it uses identity hashCode/equals, so two arrays with the same content are different keys.
- "What is the hashCode/equals contract?" – Equal objects must have equal hash codes.
- "Why is String the most common HashMap key?" – Immutable, good hash distribution, cached hashCode.
Visual Overview
Mutable key problem
Before mutation:
key.id = "SAV2001"
hash("SAV2001") → bucket 7
table[7] → [key=SAV2001, value=Rajesh]
get(key) → hash("SAV2001") → bucket 7 → found! → "Rajesh"
After mutation: key.id = "CUR3001"
hash("CUR3001") → bucket 12
get(key) → hash("CUR3001") → bucket 12 → EMPTY → null!
table[7] still has [key=CUR3001, value=Rajesh]
But bucket 7 can't be reached with hash("CUR3001")
And equals("SAV2001") fails because key.id is now "CUR3001"
→ Entry is PERMANENTLY LOST (memory leak)
Key Takeaway
If you use a mutable object as a HashMap key and mutate it after insertion, the entry becomes permanently unreachable. The entry stays in the bucket computed from the original hash, but get() computes the new hash and looks in the wrong bucket. Even looking up the original value fails because equals() is checked against the mutated key.
The entry exists – it is counted in size() and visible during iteration – but cannot be found, updated, or removed by any key-based operation. This is a memory leak in disguise.
In the National Bank of India system, Rajesh always uses String account IDs as HashMap keys, never mutable Account objects. Strings are immutable – their hashCode never changes, and they cache the hash value for fast repeated lookups. For composite keys, he uses Java records: record AccountKey(String branch, String number) {} – immutable by default with auto-generated hashCode and equals.
Remember one sentence: HashMap keys must be immutable because mutating a key after insertion makes the entry permanently unreachable.
---