The problem nobody notices until it's too late
You deploy a service that stores user sessions in a HashMap keyed by User objects. Everything works fine in testing. Three weeks later, in production, users can't find their sessions after the cache is reloaded. The Map contains your objects, but lookups fail silently. Welcome to the equals and hashCode: the contract — a step-by-step guide problem. Breaking this contract doesn't throw exceptions; it breaks your code in ways that take weeks to track down.
The rule — Equal objects must have equal hash codes
Java's contract is simple but strict: if two objects are equal according to equals(), they must return the same hash code. The reverse is not required—two unequal objects may share a hash code (this is called a hash collision, and it's fine).
This contract is fundamental to how HashMaps, HashSets, and other hash-based collections work. When you call map.put(key, value), the Map:
- Computes the hash code of the key
- Uses that hash to locate a bucket in the internal array
- Within that bucket, calls
equals()to find or store the entry
When you later call map.get(key) with an equivalent key object, the same process happens in reverse. If the hash codes don't match, the lookup never even checks equals()—it looks in the wrong bucket and returns null.
Let's see this in code:
class User {
private long id;
private String email;
public User(long id, String email) {
this.id = id;
this.email = email;
}
@Override
public boolean equals(Object obj) {
if (!(obj instanceof User)) return false;
User other = (User) obj;
return this.id == other.id && Objects.equals(this.email, other.email);
}
// ⚠️ BROKEN: hashCode does not use the same fields as equals()
@Override
public int hashCode() {
return 42; // Oops
}
}
// What happens:
Map<User, String> sessions = new HashMap<>();
User alice = new User(1, "alice@example.com");
sessions.put(alice, "session-xyz");
User aliceAgain = new User(1, "alice@example.com");
System.out.println(sessions.get(aliceAgain)); // null ❌
System.out.println(alice.equals(aliceAgain)); // true
aliceAgain is equal to alice, but the Map returns null. Why? Because alice hashes to bucket N (always 42 mod bucket count), but when we look up aliceAgain with hash code 42, we might land in a different bucket if the Map was resized or if the hash table layout changed. More commonly, the lookup finds the right bucket but never compares equals() because some implementations short-circuit bucket searches.
Use the same fields — Both methods must use exactly the same fields
The most common violation of the contract comes from using different fields in equals() and hashCode(). This is where bugs hide.
Consider a user account system:
class UserAccount {
private long id;
private String email;
private String displayName;
public UserAccount(long id, String email, String displayName) {
this.id = id;
this.email = email;
this.displayName = displayName;
}
@Override
public boolean equals(Object obj) {
if (!(obj instanceof UserAccount)) return false;
UserAccount other = (UserAccount) obj;
// Equals checks id and email
return this.id == other.id && Objects.equals(this.email, other.email);
}
@Override
public int hashCode() {
// ⚠️ BROKEN: hashCode uses displayName but equals does not
return Objects.hash(id, displayName);
}
}
// The problem:
Set<UserAccount> accounts = new HashSet<>();
UserAccount user1 = new UserAccount(1, "alice@example.com", "Alice");
UserAccount user2 = new UserAccount(1, "alice@example.com", "Alice Smith");
accounts.add(user1);
accounts.add(user2);
System.out.println(accounts.size()); // 2 ❌ Should be 1
System.out.println(user1.equals(user2)); // true
user1 and user2 are equal (same id and email), but they have different display names, so they hash differently. The HashSet treats them as different objects and allows both. Later, if you iterate the set or search it, you'll find duplicates that equals() says are the same. This violates the contract and breaks the Set invariant.
The rule is simple: every field you check in equals() must also be used in hashCode().
Here's the fix:
@Override
public int hashCode() {
// ✅ CORRECT: uses the same fields as equals()
return Objects.hash(id, email);
}
Mutable keys are a trap — Change a field used in hashCode after inserting and the entry becomes unreachable
The contract assumes that objects don't change. Once you insert a key into a HashMap or HashSet, never modify the fields used in hashCode(). If you do, the entry becomes a ghost—it's in the collection but unreachable.
Here's why: the Map stored your object in a bucket based on the original hash code. If you then mutate a field and the hash code changes, the bucket index changes, but the Map doesn't know to rehash and move the entry. Future lookups compute a new hash code and look in the wrong bucket.
A real-world scenario:
class Order {
private String orderId;
private String status;
public Order(String orderId, String status) {
this.orderId = orderId;
this.status = status;
}
public void setStatus(String status) {
this.status = status;
}
@Override
public boolean equals(Object obj) {
if (!(obj instanceof Order)) return false;
Order other = (Order) obj;
return Objects.equals(this.orderId, other.orderId);
}
@Override
public int hashCode() {
return Objects.hash(orderId);
}
}
// The trap:
Set<Order> activeOrders = new HashSet<>();
Order order = new Order("ORD-001", "pending");
activeOrders.add(order);
// Later, update the order status
order.setStatus("shipped");
// The object is still in the set, but...
Order lookup = new Order("ORD-001", "shipped");
System.out.println(activeOrders.contains(lookup)); // true ✅ Works by luck
System.out.println(activeOrders.remove(order)); // true ✅ Works because we have the reference
// But if we lose the reference and try to reconstruct:
Order orderCopy = new Order("ORD-001", "pending");
System.out.println(activeOrders.contains(orderCopy)); // true (because orderId is the same)
System.out.println(activeOrders.remove(orderCopy)); // true (worked, but only because orderId didn't change)
In this example, we got lucky because orderId (used in hashCode) didn't change. But if your hash code depended on status, you'd have a real problem. The safest rule: use only immutable fields in hashCode() and equals().
Let the language help — Records generate correct implementations
Writing correct equals() and hashCode() by hand is tedious and error-prone. Java provides three ways to avoid mistakes:
Records (Java 16+) — Automatic, bulletproof
The best solution. Records automatically generate equals() and hashCode() based on all fields, and they're immutable by default:
record User(long id, String email) {}
// Generated by the compiler:
// ✅ equals() compares id and email
// ✅ hashCode() uses id and email
// ✅ Both use the exact same fields
// ✅ Fields are final by default (immutability)
Map<User, String> sessions = new HashMap<>();
sessions.put(new User(1, "alice@example.com"), "session-xyz");
System.out.println(sessions.get(new User(1, "alice@example.com"))); // session-xyz ✅
Objects.hash() and Objects.equals() — For classes
If you're using regular classes and can't switch to records, use utility methods:
class User {
private final long id;
private final String email;
public User(long id, String email) {
this.id = id;
this.email = email;
}
@Override
public boolean equals(Object obj) {
if (!(obj instanceof User)) return false;
User other = (User) obj;
return this.id == other.id && Objects.equals(this.email, other.email);
}
@Override
public int hashCode() {
return Objects.hash(id, email);
}
}
Objects.hash() handles null values gracefully and combines hash codes correctly. It's much safer than writing your own hash calculation.
IDE generation — Convenience tool
Most IDEs (IntelliJ, Eclipse) can generate equals() and hashCode() with a single command. Select your class, invoke "Generate" or "Source" menu, and let the IDE build both methods. Always verify that the generated code includes the right fields.
Common mistakes
- Forgetting a field in hashCode(): If you use a field in
equals()but forget it inhashCode(), equal objects will have different hashes and HashMap lookups will fail. - Using mutable fields: A field like a List or an object's internal timestamp should never be part of
hashCode(). Once mutated, the hash code becomes stale and the entry is lost. - Including computed fields: Don't hash fields that are derived from other fields. Hash only the source fields. For example, if you have a
firstNameandlastName, don't hash a computedfullNamefield—hash the components. - Assuming equals() implies hashCode(): You can override one without the other, but the contract breaks. Always override both or neither (unless you're using a tool like Lombok or records).
- Using inconsistent hash algorithms across deployments: If you serialize a HashMap and deserialize it in another JVM, the hash code might differ if you used a randomized seed (like String.hashCode() in newer Java versions). Avoid relying on consistent hash codes across processes.
Key takeaways
- The contract is non-negotiable: equal objects must have equal hash codes. Violating it causes silent failures in HashMaps and HashSets.
- Use exactly the same fields in
equals()andhashCode(), or lookups will fail. - Never mutate fields that are part of
hashCode()after inserting the object into a hash-based collection. - Use records (Java 16+) to get correct implementations automatically. For regular classes, use
Objects.hash()or IDE generation. - Always include immutable, non-derived fields. Exclude computed fields and any field that might change.
- When in doubt, let your IDE or the compiler generate the code. Manual implementations are a common source of bugs.






Comments
Loading comments…