Can You Use a Mutable Object as a Key in HashMap
Technically, Java permits using any mutable object as a HashMap key because the put(K key, V value) method accepts any class extending java.lang.Object. However, in professional...
🟢 Junior Level
Short Interview Answer (30 seconds)
Technically, Java permits using any mutable object as a HashMap key because the put(K key, V value) method accepts any class extending java.lang.Object. However, in professional software engineering, this is universally recognized as a severe anti-pattern.
Keys in a HashMap must be strictly immutable. If the state of a key’s fields is modified after insertion into a HashMap:
- The return value of
key.hashCode()changes; - When calling
map.get(key), the map computes a new bucket index and searches in a different bucket; - The new bucket does not contain the entry —
map.get()returnsnull; - The original entry remains permanently trapped in its original bucket (“ghost entry”), causing a progressive memory leak.
Ideal HashMap keys are immutable types: String, Integer, UUID, LocalDate, or Java records (Java 16+).
Disaster Demonstration in Java 21
import java.util.HashMap;
import java.util.Map;
import java.util.Objects;
public class MutableKeyTrapDemo {
public static class MutableAccount {
private String accountNumber; // Mutable field!
public MutableAccount(String accountNumber) {
this.accountNumber = accountNumber;
}
public void setAccountNumber(String accountNumber) {
this.accountNumber = accountNumber;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof MutableAccount that)) return false;
return Objects.equals(accountNumber, that.accountNumber);
}
@Override
public int hashCode() {
return Objects.hash(accountNumber);
}
}
public static void main(String[] args) {
Map<MutableAccount, String> accounts = new HashMap<>();
MutableAccount acc = new MutableAccount("ACC-001");
accounts.put(acc, "VIP-Client"); // Stored in bucket derived from hash of "ACC-001"
// Verification: key lookup works before mutation
System.out.println("Before mutation: " + accounts.get(acc)); // VIP-Client
// ❌ KEY MUTATION:
acc.setAccountNumber("ACC-999"); // Hash code changes directly in the heap!
// ❌ LOOKUP FAILURE & MEMORY LEAK:
System.out.println("After mutation: " + accounts.get(acc)); // null!
System.out.println("Map size: " + accounts.size()); // 1 (Entry is trapped inside!)
}
}
🟡 Middle Level
Safe Mutation Pattern: Delete $\to$ Mutate $\to$ Insert
If business requirements necessitate modifying an object currently serving as a map key, the only correct approach is the Extract-Mutate-Insert cycle:
public class SafeKeyMutationService {
private final Map<MutableAccount, String> registry = new HashMap<>();
public void updateAccountNumber(MutableAccount account, String newNumber) {
// 1. Remove the key BEFORE mutating its state (locates node via old hash!)
String value = registry.remove(account);
if (value == null) {
throw new IllegalStateException("Account not found in registry");
}
// 2. Safely mutate the object outside the map
account.setAccountNumber(newNumber);
// 3. Re-insert the entry (re-hashes and assigns to the correct bucket)
registry.put(account, value);
}
}
What If the Mutated Field Is Excluded from equals and hashCode?
Technically, yes, this will work. If a mutable field is not included in the formulas for equals() and hashCode(), modifying that field does not alter the hash code or bucket index:
public class UserKey {
private final long userId; // Included in equals & hashCode (final!)
private String lastAccessedIp; // Excluded from equals & hashCode (mutable)
}
[!WARNING] Architectural Code Smell:
Retaining mutable fields inside key objects creates fragile code. During future refactorings, another developer or an automated Lombok/IDE generator might include all fields in@EqualsAndHashCode, silently introducing catastrophic runtime bugs into production. A key should ideally be a pure, self-contained Value Object.
🔴 Senior Level
Memory Leak Anatomy: Ghost Entries in the Heap
When a key mutates inside a HashMap, it generates an elusive memory leak:
Heap State:
table[3] ──► [ Node: key (original hash="ACC-001"), value="VIP" ] ◄── Trapped forever!
▲
│ acc.setAccountNumber("ACC-999")
│ Current hash(acc) now points to table[11]!
Production Symptoms:
map.get(key) == nullandmap.containsKey(key) == false;map.remove(key) == null— The item cannot be deleted via standard key lookup;map.entrySet()Full Scan Still Finds the Element! Iterating over the map viafor (var entry : map.entrySet())exposes the trapped entry because sequential iteration traverses alltable[]buckets directly, bypassing hash calculations;- Unbounded Old Generation Growth: The orphaned
Node<K,V>remains strongly reachable from the map’s internaltable[]array, preventing garbage collection until the entireHashMapis discarded.
Alternative: java.util.IdentityHashMap
If you must key entries by mutable objects (e.g., building object serialization graphs in Jackson/Kryo, tracking cyclical dependencies in Spring IoC containers, or deep object cloning), replace HashMap with IdentityHashMap:
Map<MutableAccount, String> identityMap = new IdentityHashMap<>();
MutableAccount acc = new MutableAccount("ACC-001");
identityMap.put(acc, "VIP");
acc.setAccountNumber("ACC-999"); // Mutate key fields!
// Lookup still succeeds! Returns "VIP":
System.out.println(identityMap.get(acc));
Why IdentityHashMap Works:
- Completely ignores overridden
equals()andhashCode()methods; - Indexes using native
System.identityHashCode(acc)(the identity hash cached in the object header’s Mark Word, which never changes upon field mutation); - Compares keys strictly by pointer identity:
k1 == k2.
Modern Java Standard: Records for Composite Keys
For composite keys in modern Java (Java 16+), the architectural standard is record:
// Immutable composite key:
public record OrderKey(long customerId, String regionCode) {
// Compact validation constructor:
public OrderKey {
Objects.requireNonNull(regionCode, "Region code must not be null");
}
}
All record components are automatically private final, no setters are generated, and compiler-synthesized equals() and hashCode() guarantee contract consistency at the bytecode level.
🎯 Interview Cheat Sheet
1. 30-Second Elevator Pitch
Technically, Java allows mutable objects as HashMap keys, but it is a critical anti-pattern. If a key’s fields mutate after insertion, its hashCode() changes, causing subsequent get() and remove() calls to search the wrong bucket and return null. The original node remains trapped inside the original bucket as a “ghost entry”, leaking heap memory. HashMap keys must be strictly immutable (String, Integer, UUID, Java records).
2. 4 Tricky Interview Questions with Deep Answers
Question 1: What happens if you mutate a field of a key object that is NOT included in hashCode() and equals()?
Answer:
From HashMap’s perspective, lookups will continue to succeed because bucket indexing and in-bucket node matching depend exclusively on the fields evaluated in hashCode() and equals(). However, this is an architectural code smell: it violates the Value Object paradigm, and subsequent automated refactoring (e.g., regenerating Lombok annotations or IDE templates) may include that field, immediately breaking production.
Question 2: How can you locate and remove an entry from a HashMap if its key was mutated after insertion?
Answer:
Standard map.remove(key) fails because it hashes to the new, wrong bucket. The only way to remove the corrupted node is via full table traversal using an explicit Iterator:
Iterator<Map.Entry<MutableKey, Value>> it = map.entrySet().iterator();
while (it.hasNext()) {
Map.Entry<MutableKey, Value> entry = it.next();
if (entry.getKey() == brokenKey) { // Reference identity check!
it.remove(); // Safely removes the entry from its current bucket
}
}
Question 3: In what scenarios is IdentityHashMap preferred for mutable keys, and how does its semantics differ from HashMap?
Answer:
IdentityHashMap is designed for infrastructure workflows such as object graph traversal, serialization frameworks (Jackson, Kryo), and container lifecycle management (Spring BeanPostProcessors). In IdentityHashMap, keys are matched strictly using pointer equality (k1 == k2) and hashed using System.identityHashCode(). Because heap memory addresses/identity hashes in the Mark Word do not change when field values mutate, mutating a key’s state does not impair lookup.
Question 4: Why are StringBuilder and ArrayList unacceptable as HashMap keys?
Answer:
StringBuilder does not override equals() or hashCode() at all, falling back to Object reference identity. ArrayList overrides both methods based on its contained elements; as soon as an element is added or removed (list.add("item")), the list’s hashCode() changes dynamically, instantly breaking any HashMap or HashSet in which the list was used as a key.
3. Common Pitfalls & Red Flags
-
❌ “Mutable keys are fine as long as you promise not to call their setters.”
Correction: Immutability must be structurally enforced viafinalfields and omitting setters; relying on developer discipline inevitably leads to concurrency bugs and accidental mutations. -
❌ “When a key mutates,
map.size()decreases because the key is lost.”
Correction:sizedoes not change. The entry remains stored in its original bucket as an un-removable ghost entry. -
❌ “Using a JPA Entity with an auto-generated ID as a HashMap key.”
Correction: Before persisting,id == null; afterpersist(), the database assigns an ID. This mutates the entity’s hash code and corrupts any Set or Map containing it prior to persistence. -
❌ “IdentityHashMap is a drop-in replacement for HashMap when using mutable keys.”
Correction: It breaks logical equality semantics; two distinct instances with identical field values are treated as distinct keys.
4. Related Topics
- What Happens If You Modify a Key After Adding It to HashMap — Step-by-step failure trace
- What Are the Requirements for a HashMap Key — Comprehensive key requirements
- Why String Is Often Used as a Key in HashMap — Immutability and hash caching
- What is the equals() and hashCode() Contract — Core invariants
- What Happens If You Override equals() But Not hashCode() — Contract asymmetry