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:
- Reflexive:
x.equals(x)is true. - Symmetric:
x.equals(y)is true exactly wheny.equals(x)is true. - Transitive: if
x.equals(y)andy.equals(z), thenx.equals(z). - Consistent: repeated calls return the same answer as long as the fields used in the comparison do not change.
- Null-safe:
x.equals(null)is false and never throws. - 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 thereThe fixes are all about not letting this happen:
- Make keys immutable:
finalfields, 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: aPointequal to aColorPointin one direction but not in the other. Prefer final classes or composition. - JPA entities. Using a generated database id in
hashCodechanges 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.equalsor 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;HashMapnever calls it. Use@Overrideand theObjectparameter so the compiler catches this. - Using a floating-point field with
==. Compare withDouble.compare; records andObjects.equalson wrapper types already behave that way. - Putting a field in
hashCodethat is not inequals. 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
toStringor 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.
compareToconsistency documented where a type is alsoComparable.- A test that puts the object into a
HashSetand 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.