PrepZone Logo
PrepZone

equals, hashCode and the Object Contract

The contract that makes HashMap work, and exactly what breaks when you implement only half of it.

Read these first

Why this matters

  • This is the single most consequential contract in the Java standard library: HashMap, HashSet, Collectors.groupingBy and distinct-value logic all depend on it.
  • The failure is silent. Nothing throws; an object simply vanishes from a set it is demonstrably inside.
  • Almost every interview that touches collections works its way to this question.
table — array of buckets
bucket 0empty
bucket 1"asha" → 30
bucket 5 — collision chain
"ravi" → 24next →
"neha" → 29next → null
bucket 9 — treeified
Red-black treeMore than 8 entries: lookup becomes O(log n)

Bucket index comes from the key's hash, not its insertion order — which is why HashMap iteration has no predictable sequence.

The hash picks a bucket. Keys that land in the same bucket form a short list, and once a bucket grows past eight entries it becomes a balanced tree so lookups stay fast.

What Object gives every class

Every class inherits eleven methods from Object. Five matter day to day.

  • equals(Object) — reference identity by default, which is rarely the comparison you want.
  • hashCode() — an int derived from the object's identity by default.
  • toString() — class name plus a hexadecimal identity hash by default, which is useless in a log.
  • getClass() — the run-time class, and final so it cannot lie.
  • clone() — a shallow field copy, protected, and covered in the next article.

The remaining ones — wait, notify, notifyAll, finalize — belong to concurrency and the deprecated finalisation mechanism, both covered later.

The default equals is identity

Java
class Point {
    final int x, y;
    Point(int x, int y) { this.x = x; this.y = y; }
}

Point a = new Point(1, 2);
Point b = new Point(1, 2);

System.out.println(a.equals(b));      // false — two distinct objects
System.out.println(a.equals(a));      // true

Object.equals is literally this == other. For a value type like Point, where two instances with the same coordinates should be interchangeable, that is wrong and must be overridden.

The contract

equals must satisfy five properties. They sound academic until one is broken.

  • Reflexive — x.equals(x) is true.
  • Symmetric — if x.equals(y) then y.equals(x). Broken most often by comparing across types.
  • Transitive — if x.equals(y) and y.equals(z) then x.equals(z).
  • Consistent — repeated calls give the same answer while neither object changes.
  • Null-safe — x.equals(null) is false, never an exception.

And the rule that links the two methods:

What breaks when you override only one

Java
class Product {
    final String sku;
    Product(String sku) { this.sku = sku; }

    @Override
    public boolean equals(Object other) {
        return other instanceof Product p && sku.equals(p.sku);
    }
    // hashCode deliberately not overridden
}
Java
Set<Product> catalogue = new HashSet<>();
catalogue.add(new Product("A-100"));

System.out.println(catalogue.contains(new Product("A-100")));   // false
System.out.println(catalogue.size());                            // 1

Both objects are equals, but they inherited identity hash codes, so they land in different buckets. contains searches one bucket, does not find the object there, and returns false. The object is in the set and simultaneously not findable.

The reverse mistake — overriding hashCode only — sends both to the same bucket, where equals then reports them as different. Also broken, in a different place.

Writing them correctly

Java
public final class Employee {
    private final String employeeId;      // the identity
    private final String name;            // not part of identity
    private final String department;

    public Employee(String employeeId, String name, String department) {
        this.employeeId = Objects.requireNonNull(employeeId);
        this.name = name;
        this.department = department;
    }

    @Override
    public boolean equals(Object other) {
        if (this == other) return true;                 // cheap fast path
        if (!(other instanceof Employee that)) return false;   // handles null too
        return employeeId.equals(that.employeeId);
    }

    @Override
    public int hashCode() {
        return Objects.hash(employeeId);                // same field as equals
    }

    @Override
    public String toString() {
        return "Employee[id=%s, name=%s, department=%s]".formatted(employeeId, name, department);
    }
}

The rules this follows

  • Identical fields in both methods. If equals uses only employeeId, so must hashCode. Any mismatch breaks the contract.
  • instanceof handles null for free, because null instanceof Anything is false. No separate null check is needed.
  • Pattern-matching instanceof tests and binds in one step, replacing a separate cast.
  • Start with this == other. It is nearly free and often true.
  • Always add toString(). The default output tells you nothing in a log or a debugger.

instanceof or getClass

AspectinstanceofgetClass() comparison
Subclass equals parentPossible — may break symmetryNever
SymmetryAt risk if a subclass adds fieldsGuaranteed
Liskov substitutionPreservedA subclass is never equal to its parent
Works with proxies and ORM entitiesYesNo — Hibernate proxies fail
Recommended withA final class, or value semanticsAn open hierarchy needing strict symmetry
  • Subclass equals parent

    instanceofPossible — may break symmetry
    getClass() comparisonNever
  • Symmetry

    instanceofAt risk if a subclass adds fields
    getClass() comparisonGuaranteed
  • Liskov substitution

    instanceofPreserved
    getClass() comparisonA subclass is never equal to its parent
  • Works with proxies and ORM entities

    instanceofYes
    getClass() comparisonNo — Hibernate proxies fail
  • Recommended with

    instanceofA final class, or value semantics
    getClass() comparisonAn open hierarchy needing strict symmetry

Making the class final removes the dilemma entirely, which is the usual advice for value types.

Mutable keys break maps

This is the practical consequence people meet in real code.

Java
class MutableKey {
    String value;
    MutableKey(String value) { this.value = value; }

    @Override public boolean equals(Object o) {
        return o instanceof MutableKey k && Objects.equals(value, k.value);
    }
    @Override public int hashCode() { return Objects.hashCode(value); }
}
Java
MutableKey key = new MutableKey("original");
Map<MutableKey, String> map = new HashMap<>();
map.put(key, "stored");

key.value = "changed";                      // the hash code just changed

System.out.println(map.get(key));           // null — wrong bucket now
System.out.println(map.containsKey(key));   // false
System.out.println(map.size());             // 1 — still in there somewhere

The entry sits in the bucket chosen by the old hash code, and every lookup now computes the new one. The value is unreachable through the map and will stay in memory until the map is discarded — a genuine leak as well as a logic bug.

What a good hash code looks like

Java
// Preferred: clear, handles nulls, boxes a varargs array
@Override public int hashCode() {
    return Objects.hash(employeeId, department);
}

// Manual, avoids the varargs allocation in a very hot path
@Override public int hashCode() {
    int result = employeeId.hashCode();
    result = 31 * result + Objects.hashCode(department);
    return result;
}

The multiplier 31 is conventional because it is an odd prime and 31 * x compiles to a shift and a subtraction. The important property is that different field orderings produce different results, so ("a", "b") and ("b", "a") do not collide.

Objects.hash allocates a varargs array on every call. That is irrelevant almost everywhere, and worth avoiding only in a measured hot loop.

Common misreadings

  • "Unequal hash codes mean unequal objects." That direction is true and is exactly what makes the collection fast. The invalid direction is assuming equal hash codes mean equal objects.
  • "hashCode returns a memory address." It returns an int derived from identity by default, and whatever you compute once you override it.
  • "Records still need these methods." Records generate both from their components, along with toString. That is a large part of their appeal.
  • "Objects.hash is slow." It allocates an array; in normal code this is unmeasurable.
  • "equals should accept the specific type." equals(Employee) is an overload, not an override. Collections call equals(Object) and will ignore it entirely.

Quick recall

Everything you need if you only revisit this box.

  • hashCode picks the bucket; equals confirms the match. Override both or neither.
  • Equal objects must have equal hash codes. Equal hash codes do not imply equality — that is a collision.
  • Override only equals and a HashSet will contain an object it cannot find.
  • Use the same fields in both methods, start with this == other, and let instanceof handle null.
  • instanceof preserves substitutability; getClass() guarantees symmetry. Making the class final removes the choice.
  • Never use a mutable object as a map key. Changing it strands the entry in the wrong bucket.
  • Objects.hash(...) is the clear default; always add a real toString().

Test yourself

Answer these before moving on — recall is what makes it stick.