The equals contract has five properties: reflexive, symmetric, transitive, consistent, and non-null. Any class that overrides equals must honor all five; violating any one breaks hash collections, lists, and the JDK's own utilities in subtle ways.
The five properties
- Reflexive —
x.equals(x)istruefor every non-nullx. - Symmetric —
x.equals(y) == y.equals(x)for every non-nullx, y. - Transitive — if
x.equals(y) && y.equals(z)thenx.equals(z). - Consistent — repeated calls return the same result as long as the compared state doesn't change.
- Non-null —
x.equals(null)returnsfalse, never throwsNullPointerException.
The canonical hand-written implementation
public final class Money {
private final long cents;
private final String currency;
public Money(long cents, String currency) {
this.cents = cents;
this.currency = Objects.requireNonNull(currency);
}
@Override
public boolean equals(Object o) {
if (this == o) return true; // reflexive fast-path
if (!(o instanceof Money other)) return false; // type + null check
return cents == other.cents
&& currency.equals(other.currency); // field-by-field
}
@Override
public int hashCode() { return Objects.hash(cents, currency); }
}
The instanceof pattern (Java 16+) handles null for free: null instanceof Money is always false.
Why each property matters in practice
| Property | What breaks when violated |
|---|---|
| Reflexive | List.contains(x) may return false even when x is in the list. |
| Symmetric | set.contains(a) and set.contains(b) give different answers depending on insertion order. |
| Transitive | A HashSet can hold three pairwise-equal objects, all "duplicates" of each other. |
| Consistent | Using System.currentTimeMillis() or a mutable field makes keys "vanish" from HashMap. |
| Non-null | NPE inside Collection.remove(null) because the JDK routinely calls equals(null). |
A subtle violation: comparing a BigDecimal
BigDecimal a = new BigDecimal("1.0");
BigDecimal b = new BigDecimal("1.00");
a.equals(b); // false — different scale
a.compareTo(b) == 0; // true — same numeric value
BigDecimal.equals checks scale and value, so equal-numerically values are not equals-equal. This is famously inconsistent with compareTo and breaks TreeSet<BigDecimal> (which uses compareTo) vs HashSet<BigDecimal> (which uses equals).