🔑 Section 10 · Question #12

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:

  1. The return value of key.hashCode() changes;
  2. When calling map.get(key), the map computes a new bucket index and searches in a different bucket;
  3. The new bucket does not contain the entry — map.get() returns null;
  4. 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:

  1. map.get(key) == null and map.containsKey(key) == false;
  2. map.remove(key) == null — The item cannot be deleted via standard key lookup;
  3. map.entrySet() Full Scan Still Finds the Element! Iterating over the map via for (var entry : map.entrySet()) exposes the trapped entry because sequential iteration traverses all table[] buckets directly, bypassing hash calculations;
  4. Unbounded Old Generation Growth: The orphaned Node<K,V> remains strongly reachable from the map’s internal table[] array, preventing garbage collection until the entire HashMap is 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() and hashCode() 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 via final fields 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: size does 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; after persist(), 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.