Mutable keys & bad hashCode in Java
Mutating a key after insertion loses the entry.
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 → nullYour 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 1null 1null 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 hashThe 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.
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
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.
record User(String email) {}
// final fields, generated
// equals and hashCodeCan't change, so its hash can't change. Records and Strings are ideal keys.
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
- Never mutate a key's equals/hashCode fields while it's in a map or set
- Override equals → you must override hashCode too
- A constant hashCode is legal but turns lookups into slow linear searches
- 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.
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());- one 1
- null 1
- null 0
- 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?
- Nothing — HashMap only calls equals()
- Two equal objects usually have different default hash codes, so get() searches the wrong bucket and returns null
- It doesn't compile
- 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'.