skip to content
everything.oop.objectMethods

equals, hashCode, toString

the contracts every object signs, and what breaks when you don't keep them.

chapter
chapter 03.09
reading time
6 min read

every class inherits equals, hashCode and toString from Object, and all three default to identity: equal only to itself, hashed by identity, printed as ClassName@hex. override equals to mean "same value", and you must override hashCode too: equal objects need equal hash codes, or every hash-based collection quietly breaks.

the picture

you know how two new Point(1, 2) aren't equals until you say so? Object can't know what "the same" means for your class, so it plays it safe: same object or nothing.

think of a library. equals is "is this the same book?" (same title and author, not the same physical copy). hashCode is the shelf number: the catalogue says Moby-Dick lives on shelf 12, so that's the only shelf anyone checks. if two copies of the same book get different shelf numbers, the librarian looks on the wrong shelf and swears you don't have it.

the equals contract

overriding equals is signing a contract with every collection in the JDK. five promises:

impl =
// CIString: a case-insensitive string
public boolean equals(Object o) {
    if (o instanceof CIString c)
        return s.equalsIgnoreCase(c.s);
    if (o instanceof String str)       // one-way
        return s.equalsIgnoreCase(str);
    return false;
}
✓
reflexivex.equals(x)
cis.equals(cis) → true
✗
symmetricx.equals(y) ⇔ y.equals(x)
cis.equals("polish") → true "polish".equals(cis) → false
✓
transitivex=y and y=z ⇒ x=z
fine among CIStrings themselves
✓
consistentsame answer every time, if nothing changed
only reads a final field
✓
non-nullx.equals(null) is false
cis.equals(null) → false

String has no idea CIString exists, so the friendship is one-way. the fallout: a List holding cis says list.contains("polish") is false, because List asks "polish".equals(cis).

pick an implementation. ✓ means the promise holds, ✗ shows the exact calls that break it (all run on jdk 25). the two ColorPoints are the classic: you can't add a value field in a subclass and keep the contract.

the good version, written out:

Point.java
final class Point {
    private 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    // false for null and other types
            && x == p.x && y == p.y;   // the fields that define "same value"
    }
 
    @Override
    public int hashCode() {
        return Objects.hash(x, y);     // the same fields, nothing else
    }
 
    @Override
    public String toString() {
        return "Point(" + x + ", " + y + ")";
    }
}

or let the compiler do it. record Point(int x, int y) {} generates exactly this equals, a matching hashCode and a readable toString. see records.

hashCode: the half people forget

a HashSet is a HashMap underneath: an array of buckets. to find an object it computes hashCode(), mixes the bits, and checks one bucket. only inside that bucket does it call equals. so if two equal objects produce different hash codes, they land in different buckets, and equals never even gets asked.

hashCode =
size() = 0

empty set. add a Point(1, 2), then ask if it contains a Point(1, 2).

add a Point(1, 2), then ask contains(new Point(1, 2)). with the inherited hashCode, each new object gets its own identity hash (real values from one jdk 25 run), lands somewhere random, and the answer is false. switch to Objects.hash(x, y): every equal Point goes to bucket 2, and the answer is true.
predict the output
java
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;
    }
    // no hashCode
}
 
Set<Point> set = new HashSet<>();
set.add(new Point(1, 2));
IO.println(set.contains(new Point(1, 2)));
set.add(new Point(1, 2));
IO.println(set.size());
Main.java
import java.util.*;
 
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;
    }
    // no hashCode: inherited from Object
}
 
public class Main {
    public static void main(String[] args) {
        Set<Point> set = new HashSet<>();
        set.add(new Point(1, 2));
        System.out.println(set.contains(new Point(1, 2)));
        set.add(new Point(1, 2));
        System.out.println(set.size());
    }
}
output
false
2

the rules for hashCode, all following from "the set looks in one bucket":

  • equal objects must have equal hash codes. (the one you can't break.)
  • unequal objects may share a hash code. that's a collision, it's legal, it's just slower.
  • it must stay the same while the object is in a collection, so build it from the same fields equals uses, ideally final ones.

toString: what Object@hex means

Object.toString() is getClass().getName() + "@" + Integer.toHexString(hashCode()). that hex is just the hash code. so if you override hashCode but not toString, the hex changes to your hash:

Ts.java
import java.util.*;
 
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);   // 31 * (31 + 1) + 2 = 994 = 0x3e2
    }
}
 
public class Ts {
    public static void main(String[] args) {
        Point p = new Point(1, 2);
        System.out.println(p);
        System.out.println(Integer.toHexString(p.hashCode()));
        System.out.println(new HashSet<>(List.of(p)).contains(new Point(1, 2)));
    }
}
output, from jdk 25
Point@3e2
3e2
true

it is not a memory address. it's a hash, and with the default hashCode it's a random-ish number the JVM generates once and stores in the object's header (that's what the mark word's hash bits are for, see classes and objects). override toString on anything you'll ever log: it costs nothing and saves hours.

one level deeper:compareTo should agree with equals too+

sorted collections don't use equals at all, they use compareTo (or a Comparator). if the two disagree, a HashSet and a TreeSet disagree about the same elements. the JDK's own example is BigDecimal: 1.0 and 1.00 aren't equals (different scale), but compareTo says they're the same number.

Bd.java
import java.math.BigDecimal;
import java.util.*;
 
public class Bd {
    public static void main(String[] args) {
        var a = new BigDecimal("1.0");
        var b = new BigDecimal("1.00");
        System.out.println(a.equals(b) + " " + a.compareTo(b));
        System.out.println(new HashSet<>(List.of(a, b)).size() + " " + new TreeSet<>(List.of(a, b)).size());
    }
}
output
false 0
2 1

two elements in the hash set, one in the tree set. legal, documented, and surprising. more in sorting and comparators.

keylines
  • the defaults are identity: equals is ==, hashCode is an identity hash, toString is Class@hex.
  • equals has five promises: reflexive, symmetric, transitive, consistent, false for null.
  • override equals ⇒ override hashCode, using the same fields.
  • a hash collection checks one bucket. wrong hash = invisible object.
  • records write all three for you. use them for value types.