Lesson 5 of 71
Object Contracts: equals, hashCode, and Comparison
Every Java class extends Object and inherits a small set of methods. Hash-based collections, sorting, and logging all depend on how a class implements three of them: equals, hashCode, and toString. After this lesson a reader can write these methods correctly, define a sort order, and explain the bugs that appear when the contracts are broken. It builds on Classes, Interfaces, and Inheritance.
| Topic | What it decides |
|---|---|
equals |
When two objects count as the same value |
hashCode |
Which bucket a key goes to in HashMap and HashSet |
toString |
The text shown in logs and string concatenation |
Comparable |
The natural order of a class |
Comparator |
Any other order, built from small parts |
Objects |
Null-safe helpers for the methods above |
clone |
A copy mechanism with known problems |
Example 1: the methods of Object
Object declares equals, hashCode, toString, getClass, clone, finalize, and the monitor methods wait, notify, and notifyAll. Without overrides, equals compares references (this == other), so two objects with the same fields are not equal.
public class ObjectDefaults {
static class Point {
final int x, y;
Point(int x, int y) { this.x = x; this.y = y; }
}
public static void main(String[] args) {
Point a = new Point(1, 2);
Point b = new Point(1, 2);
System.out.println("a == b: " + (a == b));
System.out.println("a.equals(b): " + a.equals(b));
System.out.println("a.equals(a): " + a.equals(a));
System.out.println("class: " + a.getClass().getSimpleName());
System.out.println("default toString starts with class name: "
+ a.toString().startsWith("ObjectDefaults$Point@"));
}
}a == b: false
a.equals(b): false
a.equals(a): true
class: Point
default toString starts with class name: trueThe default toString is the class name, @, and the hash code in hexadecimal. The hash code is not stable between runs, so the example prints a check instead of the text. The monitor methods are covered in Threads, synchronized, wait and notify, and finalize in Immutability, Defensive Copying, and Object Lifecycle.
Example 2: a correct equals and hashCode
The equals contract from the Object documentation has five rules for non-null references:
| Rule | Meaning |
|---|---|
| Reflexive | x.equals(x) is true |
| Symmetric | x.equals(y) equals y.equals(x) |
| Transitive | if x.equals(y) and y.equals(z), then x.equals(z) |
| Consistent | repeated calls give the same result while the fields used do not change |
| Null | x.equals(null) is false |
The hashCode contract adds one rule: equal objects must have equal hash codes. Unequal objects may share a hash code (a collision), which costs speed but not correctness.
import java.util.HashSet;
import java.util.Objects;
public class MoneyEquals {
static final class Money {
private final String currency;
private final long cents;
Money(String currency, long cents) {
this.currency = currency;
this.cents = cents;
}
@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);
}
@Override
public int hashCode() {
return Objects.hash(currency, cents);
}
}
public static void main(String[] args) {
Money a = new Money("USD", 500);
Money b = new Money("USD", 500);
System.out.println("equals: " + a.equals(b));
System.out.println("same hash: " + (a.hashCode() == b.hashCode()));
System.out.println("equals(null): " + a.equals(null));
HashSet<Money> set = new HashSet<>();
set.add(a);
set.add(b);
System.out.println("set size: " + set.size());
System.out.println("contains new USD 500: " + set.contains(new Money("USD", 500)));
}
}equals: true
same hash: true
equals(null): false
set size: 1
contains new USD 500: trueo instanceof Money other is a type pattern: it tests the type and binds a variable in one step, and it is false for null, which satisfies the null rule. Pattern Matching covers it in full. Objects.hash combines field hash codes as 31 * result + h over its arguments. It allocates an array for the varargs call, so hot code often writes 31 * currency.hashCode() + Long.hashCode(cents) by hand.
Example 3: breaking hashCode breaks HashMap
HashMap first uses hashCode to choose a bucket, and only then calls equals on keys in that bucket. A class that overrides equals but not hashCode gives equal keys different hash codes, so the map looks in the wrong bucket.
import java.util.HashMap;
public class BrokenHash {
static final class Key {
final String id;
Key(String id) { this.id = id; }
@Override
public boolean equals(Object o) {
return o instanceof Key k && id.equals(k.id);
}
// hashCode not overridden: identity hash code from Object
}
public static void main(String[] args) {
HashMap<Key, Integer> map = new HashMap<>();
map.put(new Key("A"), 1);
map.put(new Key("A"), 2);
System.out.println("equal keys: " + new Key("A").equals(new Key("A")));
System.out.println("size: " + map.size());
System.out.println("get: " + map.get(new Key("A")));
}
}equal keys: true
size: 2
get: nullThe two equal keys are stored as two entries, and a third equal key finds neither. The internals of buckets and hashing are in HashMap Internals.
Example 4: getClass versus instanceof, and mutable keys
equals can test the type with instanceof or with getClass() != o.getClass(). With instanceof, a subclass that adds a field and overrides equals breaks symmetry: the parent accepts the child, but the child rejects the parent. With getClass, a subclass instance never equals a parent instance, which keeps the contract but surprises code that treats subclasses as the parent type. Declaring the class final removes the choice.
The second part shows a mutable key: a key whose fields used by hashCode change after it is put in a map. The entry stays in the bucket chosen for the old hash, so lookups fail.
import java.util.HashSet;
import java.util.Objects;
public class EqualsPitfalls {
static class Point {
final int x, y;
Point(int x, int y) { this.x = x; this.y = y; }
@Override public boolean equals(Object o) {
return o instanceof Point p && x == p.x && y == p.y;
}
@Override public int hashCode() { return Objects.hash(x, y); }
}
static class ColorPoint extends Point {
final String color;
ColorPoint(int x, int y, String color) { super(x, y); this.color = color; }
@Override public boolean equals(Object o) {
return o instanceof ColorPoint cp && super.equals(cp) && color.equals(cp.color);
}
}
static class MutableKey {
int value;
MutableKey(int value) { this.value = value; }
@Override public boolean equals(Object o) {
return o instanceof MutableKey k && value == k.value;
}
@Override public int hashCode() { return Integer.hashCode(value); }
}
public static void main(String[] args) {
Point p = new Point(1, 2);
ColorPoint cp = new ColorPoint(1, 2, "red");
System.out.println("p.equals(cp): " + p.equals(cp));
System.out.println("cp.equals(p): " + cp.equals(p));
HashSet<MutableKey> set = new HashSet<>();
MutableKey key = new MutableKey(1);
set.add(key);
key.value = 2;
System.out.println("contains same object: " + set.contains(key));
System.out.println("size: " + set.size());
}
}p.equals(cp): true
cp.equals(p): false
contains same object: false
size: 1The set still holds the object, but contains looks in the bucket for hash 2 and finds nothing. Keys of hash-based collections are immutable for this reason; String, Integer, and records are common choices.
Example 5: toString and the Objects helpers
toString is called by string concatenation and by println. A useful toString shows the fields that identify the object and leaves out secrets. The java.util.Objects class has null-safe helpers for these methods.
import java.util.Objects;
public class ObjectsHelpers {
static final class User {
private final String name;
private final String email;
private final String password;
User(String name, String email, String password) {
this.name = Objects.requireNonNull(name, "name");
this.email = email;
this.password = password;
}
@Override public String toString() {
return "User[name=" + name + ", email=" + Objects.toString(email, "none") + "]";
}
}
public static void main(String[] args) {
System.out.println(new User("ana", null, "secret"));
System.out.println("equals(null, null): " + Objects.equals(null, null));
System.out.println("equals(\"a\", null): " + Objects.equals("a", null));
System.out.println("hashCode(null): " + Objects.hashCode(null));
System.out.println("hash(): " + Objects.hash());
System.out.println("hash(\"a\"): " + Objects.hash("a") + " vs \"a\".hashCode(): " + "a".hashCode());
System.out.println("requireNonNullElse: " + Objects.requireNonNullElse(null, "default"));
try {
new User(null, "x@y", "p");
} catch (NullPointerException e) {
System.out.println("NPE message: " + e.getMessage());
}
}
}User[name=ana, email=none]
equals(null, null): true
equals("a", null): false
hashCode(null): 0
hash(): 1
hash("a"): 128 vs "a".hashCode(): 97
requireNonNullElse: default
NPE message: nameObjects.hash("a") is 31 + "a".hashCode() because the combination starts from 1, so it differs from the single field's hash code. requireNonNull fails at construction time, close to the bug, instead of later when the field is used. The try and catch blocks catch the thrown exception; Exceptions teaches them.
Example 6: Comparable and consistency with equals
A class implements Comparable<T> to define its natural order: compareTo returns a negative number, zero, or a positive number. The order is consistent with equals when compareTo returns zero exactly when equals returns true. BigDecimal is the standard example of an inconsistent order: 2.0 and 2.00 compare as equal but are not equals, so a HashSet and a TreeSet disagree on the size.
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.TreeSet;
public class NaturalOrder {
static final class Version implements Comparable<Version> {
final int major, minor;
Version(int major, int minor) { this.major = major; this.minor = minor; }
@Override public int compareTo(Version o) {
int c = Integer.compare(major, o.major);
return c != 0 ? c : Integer.compare(minor, o.minor);
}
@Override public String toString() { return major + "." + minor; }
}
public static void main(String[] args) {
List<Version> versions = new ArrayList<>(List.of(
new Version(2, 0), new Version(1, 10), new Version(1, 2)));
Collections.sort(versions);
System.out.println(versions);
BigDecimal a = new BigDecimal("2.0"), b = new BigDecimal("2.00");
System.out.println("compareTo: " + a.compareTo(b) + ", equals: " + a.equals(b));
HashSet<BigDecimal> hash = new HashSet<>(List.of(a, b));
TreeSet<BigDecimal> tree = new TreeSet<>(List.of(a, b));
System.out.println("HashSet size: " + hash.size() + ", TreeSet size: " + tree.size());
System.out.println("overflow-prone subtraction: " + (Integer.MIN_VALUE - 1 > 0));
}
}[1.2, 1.10, 2.0]
compareTo: 0, equals: false
HashSet size: 2, TreeSet size: 1
overflow-prone subtraction: trueInteger.compare is used instead of a - b, because subtraction overflows for values far apart: Integer.MIN_VALUE - 1 wraps to a large positive number, which gives the wrong sign. Sorted sets and maps use compareTo instead of equals, which is why TreeSet keeps one of the two decimals. Sorted collections are covered in Sorted Maps, Sets, and Priority Queues.
Example 7: Comparator combinators
A Comparator<T> defines an order outside the class. The static and default methods on Comparator build one from parts. The examples pass method references such as Employee::dept, which mean "call dept() on the argument"; Lambdas and Functional Interfaces teaches them.
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Comparator;
import java.util.List;
public class Combinators {
static final class Employee {
private final String name;
private final String dept;
private final int salary;
Employee(String name, String dept, int salary) {
this.name = name; this.dept = dept; this.salary = salary;
}
String name() { return name; }
String dept() { return dept; }
int salary() { return salary; }
@Override public String toString() { return name; }
}
public static void main(String[] args) {
List<Employee> staff = new ArrayList<>(List.of(
new Employee("Bo", "ops", 90),
new Employee("Al", "dev", 120),
new Employee("Cy", "dev", 100),
new Employee("Di", null, 80)));
Comparator<Employee> byDeptThenSalaryDesc = Comparator
.comparing(Employee::dept, Comparator.nullsFirst(Comparator.naturalOrder()))
.thenComparing(Comparator.comparingInt(Employee::salary).reversed());
staff.sort(byDeptThenSalaryDesc);
System.out.println("dept, salary desc: " + staff);
staff.sort(Comparator.comparing(Employee::name).reversed());
System.out.println("name reversed: " + staff);
String[] words = {"pear", "Fig", "apple"};
Arrays.sort(words, String.CASE_INSENSITIVE_ORDER);
System.out.println("case-insensitive: " + Arrays.toString(words));
}
}dept, salary desc: [Di, Al, Cy, Bo]
name reversed: [Di, Cy, Bo, Al]
case-insensitive: [apple, Fig, pear]comparing(keyExtractor, keyComparator) orders by a key with its own comparator; here nullsFirst places the employee with no department first instead of throwing NullPointerException. comparingInt avoids boxing the int key. reversed() reverses the whole comparator it is called on, so it is applied to the salary comparator alone, not to the chain.
Example 8: clone versus copy constructors
Object.clone makes a field-by-field (shallow) copy, but only when the class implements the marker interface Cloneable; otherwise it throws CloneNotSupportedException. It is protected, returns Object, bypasses constructors, and copies references to mutable fields, so the copy shares them. A copy constructor (a constructor that takes an instance of the same class) avoids all of these problems.
import java.util.ArrayList;
import java.util.List;
public class CloneVsCopy {
static class Basket implements Cloneable {
List<String> items = new ArrayList<>();
Basket() {}
Basket(Basket other) { // copy constructor
this.items = new ArrayList<>(other.items);
}
@Override public Basket clone() {
try {
return (Basket) super.clone(); // shallow: shares items
} catch (CloneNotSupportedException e) {
throw new AssertionError(e);
}
}
}
public static void main(String[] args) {
Basket original = new Basket();
original.items.add("apple");
Basket cloned = original.clone();
Basket copied = new Basket(original);
original.items.add("pear");
System.out.println("cloned shares list: " + (cloned.items == original.items));
System.out.println("cloned sees pear: " + cloned.items);
System.out.println("copied is independent: " + copied.items);
int[] array = {1, 2, 3};
int[] arrayCopy = array.clone();
arrayCopy[0] = 99;
System.out.println("array clone is fine for primitives: " + array[0]);
}
}cloned shares list: true
cloned sees pear: [apple, pear]
copied is independent: [apple]
array clone is fine for primitives: 1The override uses a covariant return type (Basket instead of Object) to spare callers a cast. Arrays are the one place where clone is common: array.clone() is public and correct for arrays of primitives or immutable elements.
Interview points
equalsmust be reflexive, symmetric, transitive, consistent, and false fornull.hashCodemust be equal for equal objects.- Overriding
equalswithouthashCodemakesHashMapstore equal keys twice and miss lookups, because the bucket is chosen by hash first. instanceofinequalsallows subclasses but breaks symmetry if a subclass adds state;getClasskeeps symmetry but rejects subclasses. Afinalclass avoids the issue.- Mutating a field used by
hashCodeafter insertion loses the entry for lookups. compareTois consistent with equals when it returns 0 exactly for equal objects;BigDecimalis not, soTreeSetandHashSetgive different sizes.- Compare with
Integer.compare, never subtraction, to avoid overflow. Comparator.comparing(...).thenComparing(...).reversed()reverses the whole chain; putreversed()on the part to reverse.cloneis shallow, bypasses constructors, and needsCloneable; copy constructors or static copy factories are the usual replacement. Arrays are the exception.
Summary
Object.equalscompares references by default;toStringprints class name and hex hash code.Objects.hash(a, b)equals31 * (31 * 1 + hash(a)) + hash(b)and allocates a varargs array.HashMapuseshashCodeto pick a bucket andequalsinside it.- Sorted collections use
compareToor aComparator, notequals. nullsFirstandnullsLastwrap a comparator to handle null keys.Objects.requireNonNull,Objects.equals,Objects.hashCode, andObjects.toStringare null-safe helpers.- Copy constructors make deep or shallow copies explicitly;
clonemakes shallow ones implicitly.
