The problem: an entry that is in the map but cannot be found

A team keeps a Map<CustomerKey, Customer> as an in-memory lookup. The key class has two fields, a country code and a tax number, and someone generated equals and hashCode from both. Everything works until a service method corrects a typo in the tax number of a key that is already stored. After that, map.get(key) returns null, map.containsKey(key) returns false, yet iterating over the map still shows the entry. The data is there, but nobody can reach it.

This is the classic symptom of breaking the equals/hashCode contract in spirit, even when the methods look correct. To avoid it you need to know what the contract says and how hash-based collections use it.

The contract

The Javadoc of Object defines the rules. In plain words:

  1. Reflexive: x.equals(x) is true.
  2. Symmetric: x.equals(y) is true exactly when y.equals(x) is true.
  3. Transitive: if x.equals(y) and y.equals(z), then x.equals(z).
  4. Consistent: repeated calls return the same answer as long as the fields used in the comparison do not change.
  5. Null-safe: x.equals(null) is false and never throws.
  6. hashCode agreement: if two objects are equal, they must return the same hashCode(). The reverse is not required; two different objects may share a hash code, it only costs performance.

Rule 6 is the one that matters for collections. If you override equals and forget hashCode, Object.hashCode() still returns an identity-based value, so two equal objects usually land in different buckets.

How HashMap uses the two methods

HashMap first calls hashCode() on the key to pick a bucket, then walks the entries in that bucket and calls equals to find the right one. Both steps must succeed. A lookup with an equal key but a different hash code goes to the wrong bucket and finds nothing, even though equals would have said yes.

final class Money {    private final long cents;    private final String currency;    Money(long cents, String currency) {        this.cents = cents;        this.currency = currency;    }    @Override public boolean equals(Object o) {        if (this == o) return true;        if (!(o instanceof Money other)) return false;        return cents == other.cents && currency.equals(other.currency);    }    // Forgetting this method leaves identity-based hashing in place.    @Override public int hashCode() {        return Objects.hash(cents, currency);    }}

With both methods in place, new Money(500, "EUR") finds the value stored under another Money(500, "EUR"). Remove hashCode and the same lookup fails most of the time, and the failure is not even deterministic across runs.

The mutable key problem

Back to the opening story. The hash code was computed when the key was inserted, and the map placed the entry in the bucket for that hash. When a field used in hashCode changes afterwards, the key now hashes to a different bucket. The entry still sits in the old one. A lookup with the modified key checks the new bucket, finds nothing and gives up. Iteration walks all buckets, which is why the entry still shows up there.

var key = new MutableKey("AZ", "1234567890");var map = new HashMap<MutableKey, String>();map.put(key, "Alice");key.setTaxNumber("1234567899");   // hashCode changes heremap.get(key);              // null: wrong bucketmap.containsKey(key);      // falsemap.size();                // 1: the entry is still there

The fixes are all about not letting this happen:

  • Make keys immutable: final fields, no setters.
  • If a key must change, remove it from the map first, change it and insert it again.
  • Prefer keys that are naturally stable, such as an identifier, a value object or a string, over entities that evolve.

The same warning applies to HashSet elements, and to elements you place in a TreeMap or TreeSet when the fields used for ordering change.

Records: the contract for free

Since Java 16, a record generates equals, hashCode and toString from all its components. For value objects that is almost always what you want, and it removes a whole class of mistakes.

record CustomerKey(String country, String taxNumber) {    CustomerKey {        Objects.requireNonNull(country);        Objects.requireNonNull(taxNumber);    }}

Records are shallowly immutable: the fields cannot be reassigned, but a component such as a List can still be mutated unless you copy it defensively with List.copyOf in the compact constructor. A record containing a mutable list is a mutable key again.

Consistency with compareTo

Sorted collections such as TreeMap and TreeSet use compareTo (or a Comparator) instead of equals to decide whether two keys are the same. The Comparable documentation recommends that compareTo be consistent with equals, meaning a.compareTo(b) == 0 exactly when a.equals(b). The classic counterexample is BigDecimal: new BigDecimal("2.0") and new BigDecimal("2.00") are not equal according to equals because the scale differs, but compareTo returns 0. A HashSet therefore holds both, while a TreeSet holds only one.

If your type defines an ordering that ignores some fields, document it, because mixing HashSet and TreeSet on the same type can then give different answers.

What IDE generation gets right, and what it does not

IDE generators and Lombok-style tools produce correct equals and hashCode for plain value classes: they use Objects.equals for nullable fields, compare primitives directly and combine all selected fields into the hash. Their weak points are the decisions you have to make:

  • Which fields? Include all fields that define identity, and the same set in both methods.
  • Inheritance. instanceof-based equality across a class hierarchy can break symmetry: a Point equal to a ColorPoint in one direction but not in the other. Prefer final classes or composition.
  • JPA entities. Using a generated database id in hashCode changes the hash after the first save, which is the mutable key problem again. Entities are better served by a business key or a stable constant approach, chosen deliberately.
  • Arrays and collections inside the class. Use Arrays.equals or compare lists by content rather than by reference.

Common mistakes

  • Overriding equals(Foo other) with a typed parameter. That is an overload, not an override; HashMap never calls it. Use @Override and the Object parameter so the compiler catches this.
  • Using a floating-point field with ==. Compare with Double.compare; records and Objects.equals on wrapper types already behave that way.
  • Putting a field in hashCode that is not in equals. Equal objects may then produce different hashes.
  • Returning a constant from hashCode. It is legal and correct but turns buckets into long chains, so lookups degrade to a linear or tree-based search.
  • Relying on toString or the default hash for identity in logs and maps.

Trade-offs

Including every field makes equality precise but makes the hash code more expensive and more fragile when fields change. Including fewer fields (for example only a business id) is cheaper and more stable but is a design statement: two objects with the same id are the same thing, whatever their other fields say. Make that choice consciously and write it down in the class documentation.

Checklist

  • Both methods overridden together, with @Override.
  • The same fields used in both.
  • Keys of hash-based collections are immutable.
  • Records for plain value objects.
  • compareTo consistency documented where a type is also Comparable.
  • A test that puts the object into a HashSet and finds it again.

Where to go next

Continue with choosing the right collection to see how HashMap, TreeMap and LinkedHashMap differ in practice, or read about stream pitfalls such as toMap collisions that depend on equality. To see how this fits the bigger picture, look at the junior Java developer roadmap.