Create your own
Lesson illustration

Implementing equals and hashCode for Hash-Based Collections

Hello. Last lesson focused on selecting collections from their semantics: a Set expresses uniqueness, and a Map expresses lookup by key. This lesson examines the rule that makes those promises reliable for your own domain objects: Java must know when two distinct instances represent the same logical value.

By the end, you should be able to implement equals and hashCode together, explain why a HashMap uses both methods, and identify the common failure mode of mutable keys. These are frequent interview follow-ups because they reveal whether someone understands the mechanism beneath “average constant-time lookup.”


Equality is a domain decision

Start by separating reference identity from logical equality.

The == operator checks whether two references point to the same object. By default, Object.equals() does the same. Therefore, two separately created objects are not equal by default, even when their fields contain the same values.

ProductCode first = new ProductCode("SKU-42");
ProductCode second = new ProductCode("SKU-42");

System.out.println(first == second);       // false
System.out.println(first.equals(second));  // should be true

For a ProductCode, value-based equality makes sense: two independently constructed codes with the same normalized code identify the same product code. This is a value object.

Not every domain class should use equality based on all fields. An object representing a customer, order, or database-backed entity often has an identity of its own. Two customer objects with equal names and email addresses are not necessarily the same customer. Before writing code, be able to state the rule in one sentence:

Two ProductCode instances are equal when their value fields are equal.

That sentence determines both equals and hashCode. If you cannot state it clearly, generating methods from an IDE merely automates an unresolved design decision.


The two contracts

The Java Object API defines an equality relation, not just an arbitrary comparison method. Read the primary-source definitions before seeing how hash-based collections apply them.

Object (Java SE 11 & JDK 11 )

Read the Java SE Object documentation. This is the authoritative source for the guarantees an implementation must provide; focus on the direction of the rules, especially the fact that hash-code equality is required only for objects that are equal.

Under the equals method documentation, read the full five-item contract beginning with the equivalence relation, including the bullets on reflexivity, symmetry, transitivity, consistency, and comparison with null. Then, under hashCode, read all three bullets beginning with the consistency rule. Notice that the API explicitly permits unequal objects to share a hash code.

A correct equals method obeys five practical rules:

RuleMeaning
Reflexivex.equals(x) is true.
SymmetricIf x.equals(y) is true, then y.equals(x) is also true.
TransitiveIf x equals y, and y equals z, then x equals z.
ConsistentRepeated comparisons produce the same result unless equality-relevant state changes.
Non-nullx.equals(null) is always false.

The matching hash-code contract is shorter:

  1. If state used by equality does not change, repeated calls to hashCode() return the same integer during that execution.
  2. If a.equals(b) is true, then a.hashCode() == b.hashCode() must be true.
  3. If a.equals(b) is false, their hash codes may still be the same. That situation is a collision.

The one-way nature of the rule is crucial:

The reverse is not guaranteed. A hash code is a quick routing value, not proof of equality.


Why HashMap and HashSet need both methods

A HashMap cannot call equals against every existing key on every lookup; that would make ordinary lookup linear in the number of entries. Instead, it uses two stages:

  1. It obtains the key’s hash code and uses it to select a likely internal bucket.
  2. Within that bucket, it compares candidate keys with equals to find the logically matching key.

HashSet uses the same core idea, internally backed by a hash-based map-like structure. It uses the element as the thing whose equality determines whether a duplicate exists.

A conceptual hash-table layout: keys with the same hash route to one bucket, then equality distinguishes keys within that bucket. Modern `HashMap` implementations can use structures more sophisticated than a simple linked chain for heavily collided buckets, but the two-stage principle remains the same.

Suppose keyStored is inserted into a map, and later you call get(keyLookup). If the two keys are logically equal but return different hash codes, Java looks in the wrong bucket. It may never get to the equals comparison with the stored key.

That explains the familiar rule:

Override equals and hashCode together, using precisely the same equality-relevant state.

Watch this concise map demonstration to reinforce the failure mode and the correction.

The equals hashCode Contract - Java Programming

In “The equals hashCode Contract - Java Programming,” Will Tollefson demonstrates the observable consequence of overriding equals without a compatible hashCode: logically duplicate map keys are stored separately. The example is useful because it connects the contract directly to HashMap behavior.

Watch the map example. Focus on why the map initially has multiple entries for values that equals regards as equal, and why calculating the hash code solely from the equality field restores the expected single entry.

A collision is different from a broken contract. Consider a class whose hashCode() always returns 1. It is legal if equals is correct, because equal objects still receive the same hash code. But every object lands in one bucket, so lookups increasingly require many equals comparisons and performance degrades toward linear search. Good hash functions distribute unequal objects across buckets; they do not need to make every hash code unique.


Implementing a correct immutable value object

Here is a complete ProductCode implementation. It is final, validated at construction, and immutable—properties that keep equality simple and stable.

import java.util.Objects;

public final class ProductCode {
    private final String value;

    public ProductCode(String value) {
        if (value == null || value.isBlank()) {
            throw new IllegalArgumentException("Product code must not be blank");
        }
        this.value = value.trim().toUpperCase();
    }

    public String value() {
        return value;
    }

    @Override
    public boolean equals(Object other) {
        if (this == other) {
            return true;
        }

        if (!(other instanceof ProductCode that)) {
            return false;
        }

        return value.equals(that.value);
    }

    @Override
    public int hashCode() {
        return value.hashCode();
    }
}

Read it as an implementation of the design sentence:

  • this == other handles the fast self-comparison case and ensures reflexivity.
  • instanceof ProductCode that rejects null and unrelated types safely. It also binds that after the type check.
  • value.equals(that.value) defines logical equality.
  • value.hashCode() uses exactly the same state: value.

Since this is a final class, no subclass can add new state and complicate equality. If your codebase targets Java 11 rather than a newer Java version, use an explicit cast after the instanceof test:

if (!(other instanceof ProductCode)) {
    return false;
}

ProductCode that = (ProductCode) other;
return value.equals(that.value);

For a class with several fields, Objects.equals and Objects.hash are clear standard-library helpers:

@Override
public boolean equals(Object other) {
    if (this == other) {
        return true;
    }

    if (!(other instanceof Money that)) {
        return false;
    }

    return amount == that.amount
            && Objects.equals(currency, that.currency);
}

@Override
public int hashCode() {
    return Objects.hash(amount, currency);
}

The important point is not the helper method. It is that amount and currency occur in both methods because they jointly define equality.

Also note the exact signature:

@Override
public boolean equals(Object other)

This is not an override:

public boolean equals(ProductCode other)

It is an overload: a different method with a narrower parameter type. HashMap and HashSet invoke equals(Object), so the overloaded version would not define collection equality. The @Override annotation is a valuable guardrail because the compiler flags this mistake.

A modern concise alternative: records

When the class is purely a value carrier, a Java record generates value-based equals and hashCode from all record components:

public record ProductCode(String value) {
    public ProductCode {
        if (value == null || value.isBlank()) {
            throw new IllegalArgumentException("Product code must not be blank");
        }
        value = value.trim().toUpperCase();
    }
}

This record has the same intended equality rule as the explicit class: two ProductCode values are equal when their value components are equal. Records work well for small immutable value objects, DTO-like types, and domain concepts whose entire state participates in equality.


Verify the behavior in hash-based collections

The contract should produce predictable collection behavior:

Set<ProductCode> requested = new HashSet<>();

requested.add(new ProductCode("sku-42"));
requested.add(new ProductCode("SKU-42"));

System.out.println(requested.size()); // 1

The constructor normalizes both inputs to SKU-42; equals says the two objects are equal; hashCode sends them to the same logical search area; the set retains one logical value.

Likewise, a map recognizes the second key as the same key:

Map<ProductCode, String> descriptions = new HashMap<>();

descriptions.put(new ProductCode("SKU-42"), "Wireless mouse");
descriptions.put(new ProductCode("sku-42"), "Ergonomic wireless mouse");

System.out.println(descriptions.size()); // 1

String description = descriptions.get(new ProductCode("SKU-42"));
// "Ergonomic wireless mouse"

The second put replaces the value associated with the existing logical key rather than creating a second entry. This behavior is exactly what a caller expects from “lookup by product code.”

A focused test need not care about exact numeric hash values. It should test the contract and the behavior that depends on it:

@Test
void equalProductCodesHaveEqualHashCodes() {
    ProductCode first = new ProductCode("sku-42");
    ProductCode second = new ProductCode("SKU-42");

    assertEquals(first, second);
    assertEquals(first.hashCode(), second.hashCode());
}

@Test
void hashSetDeduplicatesEqualProductCodes() {
    Set<ProductCode> codes = new HashSet<>();
    codes.add(new ProductCode("SKU-42"));
    codes.add(new ProductCode("sku-42"));

    assertEquals(1, codes.size());
}

The mutable-key trap

The most damaging bug is not usually a missing method. It is using mutable state in equality for an object that has already become a hash-map key or hash-set element.

AccountKey key = new AccountKey("tenant-a", "external-17");
Map<AccountKey, String> accounts = new HashMap<>();

accounts.put(key, "account-9001");

key.setExternalId("external-18"); // Dangerous if this affects equals/hashCode.

String account = accounts.get(key); // May be null

At insertion, the map selected a bucket using the hash derived from "external-17". After mutation, lookup computes a potentially different hash using "external-18" and searches a different bucket. The entry may physically remain in the map yet be effectively unreachable through normal lookup or removal.

The practical rule is:

A key must not change in any field used by equals or hashCode while it is stored in a hash-based collection.

Immutability is the strongest protection. This is why identifiers and value objects used as keys are commonly immutable. Mutating a map value is generally fine; mutating the hash-relevant state of a key is the problem.


Inheritance: do not casually extend a value-equality class

Inheritance can make symmetry fail. Imagine a Money class that considers amount and currency, then a Voucher extends Money that also considers a store.

If Money.equals(voucher) considers only amount and currency, it may return true. If Voucher.equals(money) requires a store as well, it returns false. That violates symmetry.

There is no single magic comparison style that makes rich inheritance hierarchies and value equality effortless. The safer defaults are:

  • Make small value types final, or use records.
  • Prefer composition when a subtype adds equality-relevant state.
  • If a class is intentionally extensible, decide and document its equality model carefully rather than relying on IDE generation alone.

For a Spring interview, it is sufficient to identify this as a design concern, not merely a syntax concern: equality has to remain an equivalence relation across all participating runtime types.


An interview-quality explanation

For the prompt, “Why must equals and hashCode be overridden together?”, use claim, mechanism, consequence, and example:

equals defines logical equality, while hashCode lets hash-based collections narrow the search to a bucket before they compare candidates with equals. The contract is that equal objects must return the same hash code. If I override only equals, two logically equal keys can be routed to different buckets, so a HashMap may store what should be one key as separate entries or fail to retrieve a value using an equivalent new key. For an immutable ProductCode, I would make equality depend on its normalized code and derive the hash code from that same code. I would also ensure the code cannot change after insertion into a HashMap or HashSet.*

A strong follow-up if asked about collisions:

Unequal objects are allowed to have the same hash code. The collection resolves that collision by calling equals within the bucket. A constant hash code can still be correct, but it distributes poorly and damages lookup performance.


Key takeaways

  • == checks object identity; equals can define logical equality.
  • Define equality from the domain meaning of the type before implementing methods.
  • equals must be reflexive, symmetric, transitive, consistent, and false for null.
  • Equal objects must have equal hash codes; unequal objects may collide.
  • HashMap and HashSet use hash codes to locate candidates and equals to distinguish them.
  • Override equals(Object) and hashCode() together, using the same equality-relevant fields.
  • Immutable value objects are particularly safe keys and set elements.
  • Do not mutate equality-relevant state while an object is stored in a hash-based collection.
  • Keep inheritance out of value equality unless its semantics are deliberately designed and tested.

Next, the course moves to generics: using type parameters to make small Java APIs express valid operations at compile time rather than relying on casts and runtime failures.

Can't find a good explanation? Sign up and we'll make it for you

Sign up