A HashMap finds entries by hash code, so a key whose hash changes after insertion is lost without any error. Immutable keys make that bug impossible.
Week 1 at a glance
| Item | Plan |
|---|---|
| Track | Java, then Spring Boot |
| Level | S1 |
| Topic | equals, hashCode, immutability |
| What to learn | The equals/hashCode contract, mutable keys, defensive copies, final, records |
| Why it matters | Broken keys silently lose data in maps and caches |
| Practical task | Mutate a key inside a HashMap and watch the entry vanish; fix it with a record |
Learning objective. By the end you should be able to write a correct equals and hashCode, explain why a mutable key corrupts a HashMap, and make a class safe to use as a key by making it immutable.
Prerequisites: you can write a Java class, and you know what HashMap, HashSet and List do.
The problem
A service keeps an in-memory map of permissions, keyed by a UserKey object that holds a user id and a tenant. It works for weeks. Then someone adds a method that updates the tenant on the same UserKey object after it was inserted.
From that moment, the map misbehaves:
map.get(key)returnsnull, though the key was put in earlier.map.containsKey(key)returnsfalse.map.size()still counts the entry, and iterating the map still shows it.
No exception is thrown, and nothing is logged. The entry is still in memory but can never be reached by lookup. In a cache this means silent misses and rebuilt data, and in a map of permissions it can mean a user suddenly has none.
The cause sits in two small methods most Java developers write without thinking: equals and hashCode, combined with a key that is allowed to change.
The equals and hashCode contract
By default, Object.equals compares identity: two references are equal only if they point to the same object. Most value-like classes override it to compare content instead, and when they do, they must also override hashCode.
The contract:
| Rule | Meaning |
|---|---|
| Reflexive | x.equals(x) is true |
| Symmetric | x.equals(y) is true exactly when y.equals(x) is true |
| Transitive | If x equals y and y equals z, then x equals z |
| Consistent | Repeated calls give the same answer while nothing changes |
| Null-safe | x.equals(null) is false, never an exception |
| Hash agreement | If x.equals(y), then x.hashCode() == y.hashCode() |
The reverse is not required: two unequal objects may share a hash code. That is just a collision, and it only costs speed.
How HashMap uses them
put(key, value): computekey.hashCode(), and use it to pick a bucket.- Inside that bucket, compare existing keys with
equals. If one matches, replace its value; otherwise add a new entry. get(key): compute the hash again, go to the same bucket, and search it withequals.
The hash decides where to look. equals decides whether it is the same key. If the hash code of a key changes after insertion, get looks in the wrong bucket and never finds it.
The classic mistake: equals without hashCode
class Money {
final long amount;
final String currency;
Money(long amount, String currency) {
this.amount = amount;
this.currency = currency;
}
@Override
public boolean equals(Object o) {
return o instanceof Money m
&& amount == m.amount
&& currency.equals(m.currency);
}
// hashCode() not overridden
}
Set<Money> set = new HashSet<>();
set.add(new Money(10, "INR"));
set.contains(new Money(10, "INR")); // usually false
The two objects are equal, but the inherited hashCode is identity-based, so they almost always land in different buckets and equals is never even asked.
The correct version
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof Money m)) return false;
return amount == m.amount && currency.equals(m.currency);
}
@Override
public int hashCode() {
return Objects.hash(amount, currency);
}
Two habits keep this safe. Use exactly the same fields in both methods, and mark the class final (or think hard about subclasses), because instanceof plus inheritance makes symmetry easy to break.
Practical task: watch the entry vanish
Create VanishingKey.java and run it. The class has a correct equals and hashCode, but it is mutable.
import java.util.*;
public class VanishingKey {
static class UserKey {
private final String userId;
private String tenant; // mutable on purpose
UserKey(String userId, String tenant) {
this.userId = userId;
this.tenant = tenant;
}
void setTenant(String tenant) { this.tenant = tenant; }
@Override
public boolean equals(Object o) {
return o instanceof UserKey k
&& userId.equals(k.userId)
&& tenant.equals(k.tenant);
}
@Override
public int hashCode() { return Objects.hash(userId, tenant); }
@Override
public String toString() { return userId + "@" + tenant; }
}
public static void main(String[] args) {
Map<UserKey, String> permissions = new HashMap<>();
UserKey key = new UserKey("u1", "acme");
permissions.put(key, "ADMIN");
System.out.println(permissions.get(key)); // ADMIN
key.setTenant("globex"); // mutate the key
System.out.println(permissions.get(key)); // null
System.out.println(permissions.containsKey(key)); // false
System.out.println(permissions.size()); // 1
System.out.println(permissions); // {u1@globex=ADMIN}
permissions.put(key, "VIEWER");
System.out.println(permissions.size()); // 2: a duplicate key
key.setTenant("acme"); // mutate it back
System.out.println(permissions.get(key)); // ADMIN, found again
}
}
What to observe:
- After
setTenant,getandcontainsKeyfail, yetsize()andtoString()prove the entry is still there. - Putting the mutated key again creates a second entry for what is logically one key.
- Mutating the key back makes the original entry reachable again. This shows nothing was deleted: the map stored the old hash, and
getwas looking for the new one.
Why it happens. HashMap stores each entry's hash when it is inserted and never recomputes it. After the mutation, hashCode() returns a different number, so get searches a different bucket (and even in the same bucket it compares the stored hash first). The entry sits where the old hash put it, unreachable, and cannot even be removed.
Write down the answer to this before moving on: which single change to UserKey makes this whole class of bug impossible?
The fix: immutability
The answer is to stop the key from changing. An immutable object has the same hash for its whole life, so the bug cannot occur.
Step 1: replace the class with a record
record UserKey(String userId, String tenant) {
UserKey {
Objects.requireNonNull(userId, "userId");
Objects.requireNonNull(tenant, "tenant");
}
}
A record gives you, for free: private final fields, a constructor, accessors (userId(), tenant()), a content-based equals and hashCode over all components, and a toString. There is no setter to call.
Run the task again with this record. To "change the tenant" you must now create a different key:
UserKey acme = new UserKey("u1", "acme");
permissions.put(acme, "ADMIN");
UserKey globex = new UserKey(acme.userId(), "globex"); // a new, separate key
permissions.get(acme); // ADMIN, still reachable
permissions.get(globex); // null, a different key
The old entry can never be lost, because nobody can alter the object it is stored under.
Step 2: know what final does not do
final stops a reference from being reassigned. It does not stop the object it points to from changing.
class Route {
private final List<String> stops; // final, but the list is mutable
Route(List<String> stops) { this.stops = stops; }
List<String> stops() { return stops; }
}
List<String> list = new ArrayList<>(List.of("Pune", "Mumbai"));
Route r = new Route(list);
list.add("Goa"); // changes r from outside
r.stops().clear(); // changes r from outside
If Route is used as a map key, both lines above break it exactly as before.
Step 3: defensive copies
Copy mutable data on the way in and on the way out:
record Route(List<String> stops) {
Route {
stops = List.copyOf(stops); // immutable copy, also rejects nulls
}
}
List.copyOf takes an unmodifiable snapshot. The caller's original list can change freely and Route is unaffected, and the accessor now returns a list that cannot be modified. For a hand-written class, do the same in the constructor and return List.copyOf(stops) or an unmodifiable view from the getter.
Immutability checklist
- Class is
final(or a record). - All fields are
private final. - No setters, and no method that changes state.
- Mutable inputs are copied in the constructor.
- Mutable fields are never returned directly.
- Prefer immutable field types:
String,Instant,LocalDate,BigDecimal,List.of. AvoidDateand arrays inside keys.
Record caveats
- A record's
equalsandhashCodeuse all components. If equality should depend on only some fields, a record is the wrong tool. - A record is shallowly immutable: a mutable component (a plain
ArrayList) can still change, so add the compact-constructor copy shown above. - An array component compares by identity in
equals, so use aListinstead.
When NOT to make something immutable
Immutability is the right default for keys, value objects and anything shared across threads. Objects that genuinely model changing things, such as an Account with a balance, should stay mutable and protect their own rules (see the object-thinking chapter). The thing to avoid is using a changing object as a key: if the key is an Account, key it by its accountId, not by the object.
Where this bites in Spring Boot
You have not written a Spring application yet, but the same mechanics sit underneath it. Four places to recognize them later.
1. Cache keys. With @Cacheable, Spring builds the cache key from the method arguments, and the cache looks entries up with equals and hashCode.
@Cacheable("permissions")
public Permissions find(UserKey key) { ... }
If UserKey is mutable, or has no equals/hashCode, you get the silent misses from this lesson: the cache never hits, and the database is hit every time. A record makes it correct by construction.
2. Request and response DTOs. Use records for objects that only carry data across the web layer. They are immutable, have a content-based equals (handy in tests), and a readable toString.
record CreateUserRequest(String name, String email) {}
3. Configuration properties. @ConfigurationProperties classes can be records too, so configuration is immutable once bound.
4. JPA entities, the hard case. An entity's id is often null until the database assigns it on save. If hashCode uses the id, it changes at the moment of saving:
Set<Order> set = new HashSet<>();
Order order = new Order(); // id is null here
set.add(order);
orderRepository.save(order); // id assigned, hashCode changes
set.contains(order); // can now be false: the mutable-key bug again
There is no single answer, and this is one of the most debated corners of JPA, but the usual safe options are:
- Base
equalsandhashCodeon a natural business key that never changes (an email, an order number). - Or compare by id only when it is non-null, and return a constant
hashCodefor the class. - Avoid generating
equals/hashCodewith Lombok's@Dataon entities, because it includes every field, including lazy relationships.
The principle is always the lesson's: a hash must not change while the object sits in a hash-based collection.
Thread safety bonus. Spring beans are singletons handled by many request threads at once. Immutable objects shared between them need no locking, which is one more reason to prefer them.
Check yourself
Can you explain?
- Why must you override
hashCodewhenever you overrideequals? What goes wrong with aHashSetif you do not? - What are the two jobs in
HashMap.get, and which method does each? - A key's field changes after it is put in a map. Why does
getreturnnullwhilesize()still counts the entry? - A class has a
private final List<String> itemsfield. Is it immutable? Why or why not? - Which
equals/hashCodefields does a record use, and when is a record the wrong choice? - Why is an auto-generated database id a risky field to put in
hashCode?
Can you do?
- Run
VanishingKeyand reproduce all five observations. Then changeUserKeyto a record and show that each one disappears. - Write an immutable
DateRange(LocalDate from, LocalDate to)record that rejectsfromaftertoin its compact constructor, and use it as aHashMapkey. - Write a record
Basket(List<String> products), then prove with code that changing the original list afterwards does not change the record or itshashCode(). - Break it on purpose: write
equalswithouthashCodefor a class, put two equal instances in aHashSet, and print the set's size.
Completion criteria
You are done with this lesson when you can:
- Write a correct
equalsandhashCodepair without looking anything up. - Explain, using buckets, why a mutated key cannot be found.
- Make a class safe to use as a key, using a record or the immutability checklist.
- Say why
finalon a field does not make an object immutable. - Recognize the mutable-key risk in cache keys and JPA entities.
Recall
Revisit this lesson after 1 day, 3 days and 10 days. Without scrolling up, answer:
- What does the hash decide, and what does
equalsdecide? - What must never change while an object is in a hash-based collection?
- What are the three steps that make a class immutable?
This comes back later in the roadmap, whenever you use a cache, a Set of objects, or a JPA entity.
Notes & links
Saved automatically.