🗃️ Collections Framework · Intermediate

Mutable keys & bad hashCode in Java

Mutating a key after insertion loses the entry.

🧩 The mysteryThe entry is still inside the map. size() says so. Yet get() swears it isn't there. Welcome to the lost-key mystery.

The hash is stamped at check-in

When you put a key, HashMap files the entry in the bucket chosen by the key's hash code at that moment. Later lookups recompute the hash. If you change a field that hashCode depends on, the lookup goes to a different bucket and misses.

put(key)  → hash 111 → bucket 3
key.setName("new")
get(key)  → hash 942 → bucket 9 → null
🔮 Predict it

Your turn

A List's hash code depends on its contents. What does this print?

var key = new ArrayList<>(List.of("a"));
Map<List<String>, Integer> m = new HashMap<>();
m.put(key, 1);
key.add("b");
System.out.println(m.get(key) + " " + m.size());
  1. 1 1
  2. null 1
  3. null 0
Show the answer

After key.add("b") the hash changes, so get(key) searches the wrong bucket → null. But the entry never left: size is still 1. It's in there, just unreachable.

Lost, not gone

The same happens in a HashSet: contains(u) returns false for the very object you added. If you truly must change a key, remove it, change it, add it back. Better: make keys immutable so the problem can't happen.

users.remove(u);
u.setEmail("[email protected]");
users.add(u);   // filed under the new hash

The equals/hashCode pact

**Override equals → you must override hashCode too. The rule: equal objects must have equal hash codes. It only goes one way: unequal objects may share a hash, so a constant hashCode is legal**. It's just slow, since every key collides.

🤔 Think first

Half a pact

A class overrides equals() but not hashCode(). You put(new P(1), "x"), then call get(new P(1)). What do you get?

Think about it, then reveal the answer

Almost certainly **null**. The default hashCode is identity-based, so two equal-but-separate objects get different hashes and land in different buckets. equals() is never even called. The code compiles and doesn't throw: it just quietly fails.

Choosing a key class

✗ Mutable key
class User {
    String email;
    void setEmail(String e) { email = e; }
    // equals/hashCode use email
}

One setter call while it's in a map and the entry is lost.

✓ Immutable key
record User(String email) {}
// final fields, generated
// equals and hashCode

Can't change, so its hash can't change. Records and Strings are ideal keys.

💼 In the real world

Real-world damage

Lost-key bugs show up as "phantom duplicates": a cache keeps growing, or a Set holds the same user twice after a profile edit. They're hard to reproduce because nothing throws. Teams avoid them by using records, Strings, or IDs as keys. Note that TreeMap has the same problem if you mutate fields used by compareTo.

Key takeaways

  1. Never mutate a key's equals/hashCode fields while it's in a map or set
  2. Override equals → you must override hashCode too
  3. A constant hashCode is legal but turns lookups into slow linear searches
  4. Records and Strings make great keys: immutable with value-based equals/hashCode

💡 Changing a key after storing it is like repainting your car in a huge parking garage — you look for the red car, but it's now blue.

🤯 Did you know?

String caches its hash code in a private field after the first call to hashCode(). That trick is only safe because Strings are immutable: the cached value can never go stale.

Practice questions

What does this print?

var key = new ArrayList<>(List.of(1));
Map<List<Integer>, String> m = new HashMap<>();
m.put(key, "one");
key.add(2);
System.out.println(m.get(key));
System.out.println(m.size());
  1. one 1
  2. null 1
  3. null 0
  4. one 2
Check your answer

null 1. A List's hashCode depends on its contents. After key.add(2) the hash changes, so get(key) doesn't match the stored entry. The entry is still inside the map (size 1) — just unreachable.

A class overrides equals() but not hashCode(). What goes wrong when you use it as a HashMap key?

  1. Nothing — HashMap only calls equals()
  2. Two equal objects usually have different default hash codes, so get() searches the wrong bucket and returns null
  3. It doesn't compile
  4. HashMap throws IllegalStateException on put
Check your answer

Two equal objects usually have different default hash codes, so get() searches the wrong bucket and returns null. The default hashCode is identity-based. Equal-but-distinct objects almost always hash differently, breaking the rule 'equal objects must have equal hash codes'.

Next: stop writing get-check-put. One method call can count words, and another can group a whole list.