Skip to content
ExponaBLOG
JAVA, SPRING FRAMEWORK & SPRING BOOT · DAY 011 / 100

equals and hashCode: the contract — a step-by-step guide

Break it and HashMaps silently lose your objects.

Expona Admin9 min read

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:

  1. Computes the hash code of the key
  2. Uses that hash to locate a bucket in the internal array
  3. 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:

java
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:

java
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:

java
@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:

java
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:

java
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:

java
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 in hashCode(), 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 firstName and lastName, don't hash a computed fullName field—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() and hashCode(), 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.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles