What is the equals and hashCode contract in Java, and what actually breaks when you break it?
The contract has two halves. If a.equals(b) is true, then a.hashCode() must equal b.hashCode(). And if the hash codes are equal, the objects are not necessarily equal — that is only a hint that they might be, and it is why hash-based collections still have to call equals.
The reason the first half exists is structural. A HashMap computes the hash to pick a bucket, then scans that bucket calling equals. If two equal objects had different hash codes, they would be in different buckets and the equals comparison would never happen — so a lookup with an equal-but-distinct key would fail. The map would silently lose entries.
The symptoms of breaking it are specific. A HashSet containing objects whose equals is overridden but whose hashCode is not will report a size larger than you expect, because two value-equal objects land in different buckets and both are kept. A HashMap keyed on such objects will treat a lookup with an equal key as a miss, so put followed by get returns null for a key you can see in the map.
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
public class Main {
public static void main(String[] args) {
// equals overridden, hashCode not: the set treats them as different.
Set<Broken> broken = new HashSet<>();
broken.add(new Broken("a"));
broken.add(new Broken("a"));
System.out.println("broken set size : " + broken.size());
// equals overridden, hashCode consistent: one entry.
Set<Fixed> fixed = new HashSet<>();
fixed.add(new Fixed("a"));
fixed.add(new Fixed("a"));
System.out.println("fixed set size : " + fixed.size());
Map<Fixed, String> map = new HashMap<>();
map.put(new Fixed("a"), "first");
map.put(new Fixed("a"), "second");
System.out.println("map size : " + map.size());
System.out.println("map value : " + map.get(new Fixed("a")));
}
}
class Broken {
private final String id;
Broken(String id) {
this.id = id;
}
@Override
public boolean equals(Object o) {
return o instanceof Broken && ((Broken) o).id.equals(id);
}
}
class Fixed {
private final String id;
Fixed(String id) {
this.id = id;
}
@Override
public boolean equals(Object o) {
return o instanceof Fixed && ((Fixed) o).id.equals(id);
}
@Override
public int hashCode() {
return id.hashCode();
}
}The output is the diagnosis. The broken set holds two entries because the two value-equal objects hashed differently and never met in the same bucket. The fixed set holds one. And the map keyed on the fixed type ends with a single entry whose value is the second put, because the two equal keys resolved to the same slot.
The other failure mode is instability. If the hash code depends on a mutable field and that field changes after the object is in the map, the entry becomes unreachable — it is still counted in the size, but no lookup will find it, and the entry can only be removed by iterating and removing rather than by key.
There is a symmetry requirement too, which is easy to miss: equals must be symmetric, reflexive and transitive. A well-known trap is an equals that is not symmetric between a class and its subclass, which breaks the contract in a way that produces collections whose behaviour depends on the order of the operations.