skip to content

Why are immutable objects ideal as keys in a HashMap or HashSet, and what breaks if you use a mutable key?

level: middleimportance: must knowfreq 71%

answer

  1. Bucket index comes from hashCode() at put AND at get
  2. Mutate a hash-relevant field -> wrong bucket -> entry unretrievable
  3. Entry still iterable but get/contains/remove miss
  4. Immutable keys: String, Integer, UUID, LocalDate, enum
  5. Fix: immutable snapshot, or remove-mutate-reinsert

basics

~20 s

A HashMap finds entries using the key's hashCode. If a key never changes, its hashCode stays the same, so the entry is always findable. If you mutate a key after putting it in, its hashCode can change and the map looks in the wrong bucket - the entry is effectively lost.

solid answer

~50 s

HashMap and HashSet place each entry in a bucket derived from the key's hashCode(), and find it later by recomputing that hash and then using equals() within the bucket. This only works if a key's hashCode() and the fields equals() uses stay constant for as long as it's in the collection. An immutable key guarantees that, so lookups are always correct. If you use a mutable key and change a field that participates in hashCode/equals after inserting, the key now hashes to a different bucket than where it lives. containsKey/get compute the new hash, look in the new bucket, and miss - the entry becomes unretrievable even though it's still in the map (you can still see it by iterating). The map's internal invariant is broken. That's why immutable types like String, Integer, and LocalDate are the standard map keys, and why a mutable key must never have its hash-relevant state changed while it's in a hash collection.

go deeper

for a junior

Knows that immutable objects make good map keys and that mutating a key can 'lose' it, even if hazy on the bucket mechanics.

for a middle

Explains the put/get hash-recompute mechanism, shows the wrong-bucket failure, and notes the entry is still iterable but unretrievable by lookup.

for a senior

Ties it to the precise hashCode consistency clause, prescribes safe patterns (immutable snapshot, remove-mutate-reinsert, IdentityHashMap), and connects to the equals/hashCode contract.

for a principal

Generalizes to invariant stability of keys across any keyed structure (TreeMap ordering, distributed caches), and treats 'keys must be immutable or stable' as an API design rule it enforces in reviews.

## How a hash-based collection finds things A `HashMap` is an array of **buckets**. To store key `k`, it computes `k.hashCode()`, spreads the bits, and reduces that to a bucket index. To look up `k` later (`get`, `containsKey`, `remove`), it does the **same** computation to pick a bucket, then walks that bucket comparing candidates with `k.equals(candidate)`. So retrieval depends on two things being **stable while the key is in the map**: 1. the key's `hashCode()` value, and 2. the result of `equals()` on the fields it compares. ## Why immutability makes a perfect key An immutable object's fields never change, so `hashCode()` and `equals()` are **constant for the object's lifetime**. The bucket you computed at insert time is the same bucket you'll compute at lookup time. Lookups therefore always land in the right place. The JDK's canonical keys - `String`, `Integer`, `Long`, `UUID`, `LocalDate`, enums - are all immutable for exactly this reason. ## What breaks with a mutable key Suppose `Point` is mutable with `x`, `y` used in `hashCode`/`equals`: ``` Point p = new Point(1, 2); map.put(p, "a"); // stored in bucket for hash(1,2) p.setX(99); // hashCode now reflects (99,2) map.get(p); // computes hash(99,2) -> different bucket -> MISS (null) ``` The entry still physically exists (iterating the map's entrySet finds it), but `get`/`containsKey`/`remove` all look in the wrong bucket and fail. You now have a key you **cannot retrieve or delete by lookup** - a silent, hard-to-debug leak/corruption. The collection's contract ('if you put it, you can get it') is violated, and it's entirely your fault for mutating a live key. ## The contract, stated precisely The `equals`/`hashCode` contract says equal objects must have equal hash codes, and `hashCode` must be **consistent** - return the same value on repeated calls *provided no information used in equals is modified*. A mutable key that changes such a field after insertion breaks the 'no information modified' precondition **relative to the collection's expectations**, which is what corrupts lookup. ## Safe options if you must key on mutable data - **Use an immutable snapshot as the key** (copy the relevant fields into an immutable value or a record) so later mutation of the original doesn't affect the stored key. - **Don't mutate the key while it's in the map**: remove it, mutate, re-insert. - **Use `IdentityHashMap`** if identity (not value equality) is the intended key semantics - it keys on `==` and the default identity hash, which doesn't depend on mutable fields. ## Tie to the broader topic This is one of the headline **benefits** of immutability: stable hash/equality makes immutable objects trouble-free keys and set members. The corresponding **trade-off** elsewhere (allocation per change) doesn't apply here because keys are typically created once and never modified - so for keys, immutability is almost pure upside.

  • Can you still get the entry out after mutating its key?
    Not via get/containsKey/remove - they compute the new hash and miss the old bucket. You can still reach it by iterating entrySet()/keySet(), and you can fix the map by removing the entry during iteration and re-inserting it, but lookup-by-key is broken until then.
  • How would you safely use data that changes over time as a key?
    Key on an immutable snapshot: copy the identifying fields into an immutable value object or record and use that as the key, leaving the mutable object out of the map. Then later mutation of the original can't disturb the stored key's hash.

saying these in an interview costs you the question

  • Saying a mutated key is 'removed' from the map - it's still there, just unreachable by lookup
  • Believing equals() alone matters - hashCode picks the bucket first
  • Thinking the map auto-rehashes when a key changes (it has no way to know)
  • Using a mutable object as a key and mutating it 'just a little'

context