Guide contents

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.

Java
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@"));
    }
}
Output
a == b: false
a.equals(b): false
a.equals(a): true
class: Point
default toString starts with class name: true

The 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.

Java
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)));
    }
}
Output
equals: true
same hash: true
equals(null): false
set size: 1
contains new USD 500: true

o 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.

Java
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")));
    }
}
Output
equal keys: true
size: 2
get: null

The 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.

Java
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());
    }
}
Output
p.equals(cp): true
cp.equals(p): false
contains same object: false
size: 1

The 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.

Java
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());
        }
    }
}
Output
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: name

Objects.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.

Java
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));
    }
}
Output
[1.2, 1.10, 2.0]
compareTo: 0, equals: false
HashSet size: 2, TreeSet size: 1
overflow-prone subtraction: true

Integer.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.

Java
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));
    }
}
Output
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.

Java
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]);
    }
}
Output
cloned shares list: true
cloned sees pear: [apple, pear]
copied is independent: [apple]
array clone is fine for primitives: 1

The 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

  • equals must be reflexive, symmetric, transitive, consistent, and false for null. hashCode must be equal for equal objects.
  • Overriding equals without hashCode makes HashMap store equal keys twice and miss lookups, because the bucket is chosen by hash first.
  • instanceof in equals allows subclasses but breaks symmetry if a subclass adds state; getClass keeps symmetry but rejects subclasses. A final class avoids the issue.
  • Mutating a field used by hashCode after insertion loses the entry for lookups.
  • compareTo is consistent with equals when it returns 0 exactly for equal objects; BigDecimal is not, so TreeSet and HashSet give different sizes.
  • Compare with Integer.compare, never subtraction, to avoid overflow.
  • Comparator.comparing(...).thenComparing(...).reversed() reverses the whole chain; put reversed() on the part to reverse.
  • clone is shallow, bypasses constructors, and needs Cloneable; copy constructors or static copy factories are the usual replacement. Arrays are the exception.

Summary

  • Object.equals compares references by default; toString prints class name and hex hash code.
  • Objects.hash(a, b) equals 31 * (31 * 1 + hash(a)) + hash(b) and allocates a varargs array.
  • HashMap uses hashCode to pick a bucket and equals inside it.
  • Sorted collections use compareTo or a Comparator, not equals.
  • nullsFirst and nullsLast wrap a comparator to handle null keys.
  • Objects.requireNonNull, Objects.equals, Objects.hashCode, and Objects.toString are null-safe helpers.
  • Copy constructors make deep or shallow copies explicitly; clone makes shallow ones implicitly.