Skip to content
Path to Engineer
All lessons
JavaCollections18 min read

The equals() and hashCode() contract

What the contract requires, and exactly what breaks when you override one without the other — demonstrated, not asserted.

Day 40 of the 488-day pathway. Published in full — nothing held back.

equals() / hashCode() contract — and exactly what breaks when you violate it Concurrency — race conditions, critical section, atomicity

One of the highest-frequency Java interview topics. And unlike most of them, violating this contract produces bugs that are silent, intermittent and extremely hard to diagnose — objects that vanish from sets, map lookups that fail for keys that are definitely there.


Part 1 — L-040 · The contract

equals — the five rules

For any non-null x, y, z:

Rule Meaning
Reflexive x.equals(x) is true
Symmetric x.equals(y)y.equals(x)
Transitive x.equals(y) and y.equals(z)x.equals(z)
Consistent Repeated calls return the same result, if nothing changed
Non-null x.equals(null) is false — never throws

hashCode — the two rules

Rule Meaning
Consistency with equals a.equals(b)a.hashCode() == b.hashCode()
The converse is not required Equal hash codes do not imply equality — that's a collision

The one-line version, and the one to say out loud:

Equal objects must have equal hash codes. Unequal objects may share a hash code.

What actually breaks

This is the part that matters, and most candidates can't explain it.

Violation 1 — equals without hashCode

class Point {
    int x, y;
    @Override public boolean equals(Object o) {
        return o instanceof Point p && p.x == x && p.y == y;
    }
    // no hashCode!  → inherits Object.hashCode, which is identity-based
}

Set<Point> set = new HashSet<>();
set.add(new Point(1, 1));
set.contains(new Point(1, 1));      // FALSE — even though equals says true

Why, mechanically (Day 048 covers HashMap internals in full):

   add(p1):      hash(p1) = 12345  →  bucket 5   → stored in bucket 5
   contains(p2): hash(p2) = 99999  →  bucket 22  → looks in bucket 22 — EMPTY
                                                    equals() is never even called

The lookup goes to the wrong bucket. equals is never consulted, so it doesn't matter that it would have returned true.

Symptom: objects appear to vanish from sets and maps. Duplicates accumulate in a HashSet.

Violation 2 — hashCode without equals

class Point {
    int x, y;
    @Override public int hashCode() { return Objects.hash(x, y); }
    // no equals!  → inherits Object.equals, which is reference identity
}

set.contains(new Point(1, 1));      // FALSE — right bucket, but equals fails

Right bucket this time, but the identity equals rejects the match.

Violation 3 — mutating a key after insertion

Set<Point> set = new HashSet<>();
Point p = new Point(1, 1);
set.add(p);                     // hashed into bucket 5

p.x = 99;                       // hashCode CHANGES

set.contains(p);                // FALSE — looks in bucket 22, entry is in bucket 5
set.remove(p);                  // FAILS

The entry is now unreachable by lookup but still reachable by GC — it can never be removed. This is leak pattern #6 from Day 027, and now you can see exactly why.

The fix: never use a mutable object as a key. Use records, or make the key type immutable (Day 034A).

Violation 4 — breaking symmetry

The classic, and it appears in Effective Java:

class CaseInsensitiveString {
    private final String s;
    @Override public boolean equals(Object o) {
        if (o instanceof CaseInsensitiveString c) return s.equalsIgnoreCase(c.s);
        if (o instanceof String str) return s.equalsIgnoreCase(str);    // 💀 breaks symmetry
        return false;
    }
}

CaseInsensitiveString cis = new CaseInsensitiveString("Hello");
String str = "hello";

cis.equals(str);      // true
str.equals(cis);      // FALSE — String has no idea what CaseInsensitiveString is

Collections behave unpredictably because they may compare in either direction. list.contains(x) can return different answers depending on the implementation's iteration order.

Never try to be equal to a type that doesn't know about you.

Violation 5 — instanceof vs getClass and inheritance

class Point { int x, y; }
class ColorPoint extends Point { Color color; }

You cannot extend an instantiable class, add a value component, and preserve the contract.

  • Use instanceof and ignore colour → symmetric but a ColorPoint equals a Point, losing transitivity when two different-coloured points both equal the same Point
  • Use getClass() → strict, but now a Point never equals a ColorPoint, violating Liskov substitution (Day 091)

There is no correct answer. This is a genuine limitation, and the accepted resolution is composition instead of inheritance (Day 037):

class ColorPoint {
    private final Point point;        // HAS-A
    private final Color color;
    public Point asPoint() { return point; }
}

Being able to state that this problem has no solution — rather than confidently picking one — is a strong senior signal.

Writing them correctly

public final class Point {
    private final int x, y;

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;                    // fast path for identity
        if (!(o instanceof Point)) return false;       // handles null too — instanceof is null-safe
        Point p = (Point) o;
        return x == p.x && y == p.y;                   // compare significant fields
    }

    @Override
    public int hashCode() {
        return Objects.hash(x, y);                     // consistent with equals
    }
}

Java 16+ pattern matching makes it tighter:

@Override public boolean equals(Object o) {
    return this == o || (o instanceof Point p && x == p.x && y == p.y);
}

Rules for writing them:

  • Use exactly the same fields in both. Any field in equals must be in hashCode.
  • Compare cheap fields first — an early false short-circuits the expensive comparisons.
  • Floating point: use Float.compare / Double.compare, not == (Day 030: NaN != NaN, and 0.0 == -0.0 is true but they hash differently).
  • Arrays: use Arrays.equals — an array's own equals is identity.
  • Objects.equals(a, b) for null-safe field comparison.
  • Always override toString too (Day 041) — debugging without it is painful.

Just use records

public record Point(int x, int y) { }

Records generate equals, hashCode and toString from all components, correctly. For value types this is the right answer and removes the entire class of bug. Day 042.

hashCode quality

@Override public int hashCode() { return 42; }     // legal! and catastrophic

This satisfies the contract — equal objects have equal hash codes — and destroys performance. Every entry lands in one bucket, so HashMap degrades from O(1) to O(n), or O(log n) once the bucket treeifies at eight entries (Day 048).

A good hash spreads values evenly. Objects.hash(...) is fine for most cases; note it allocates a varargs array (Day 032), so in a genuinely hot path write it manually:

@Override public int hashCode() {
    int result = Integer.hashCode(x);
    result = 31 * result + Integer.hashCode(y);
    return result;
}

Why 31? It's an odd prime, so it doesn't lose information the way an even multiplier would, and 31 * i compiles to (i << 5) - i — a shift and a subtract. Historical, and a nice detail to know.


Type this yourself

import java.util.*;

public class EqualsDemo {

    static class BrokenPoint {                         // equals WITHOUT hashCode
        int x, y;
        BrokenPoint(int x, int y) { this.x = x; this.y = y; }
        @Override public boolean equals(Object o) {
            return o instanceof BrokenPoint p && p.x == x && p.y == y;
        }
        @Override public String toString() { return "(" + x + "," + y + ")"; }
    }

    static class MutableKey {                          // correct, but MUTABLE
        int x;
        MutableKey(int x) { this.x = x; }
        @Override public boolean equals(Object o) { return o instanceof MutableKey m && m.x == x; }
        @Override public int hashCode() { return Integer.hashCode(x); }
        @Override public String toString() { return "K(" + x + ")"; }
    }

    record GoodPoint(int x, int y) { }                 // generated correctly

    public static void main(String[] args) {

        // ---- 1. equals without hashCode ----
        Set<BrokenPoint> broken = new HashSet<>();
        broken.add(new BrokenPoint(1, 1));
        System.out.println("--- equals without hashCode ---");
        System.out.println("   equals says:   " + new BrokenPoint(1,1).equals(new BrokenPoint(1,1)));
        System.out.println("   set.contains:  " + broken.contains(new BrokenPoint(1, 1)));
        broken.add(new BrokenPoint(1, 1));
        broken.add(new BrokenPoint(1, 1));
        System.out.println("   set size after 3 equal adds: " + broken.size() + "   ← should be 1");

        // ---- 2. records get it right ----
        Set<GoodPoint> good = new HashSet<>();
        good.add(new GoodPoint(1, 1));
        good.add(new GoodPoint(1, 1));
        System.out.println("\n--- record ---");
        System.out.println("   contains: " + good.contains(new GoodPoint(1, 1)));
        System.out.println("   size:     " + good.size());

        // ---- 3. mutating a key ----
        Set<MutableKey> keys = new HashSet<>();
        MutableKey k = new MutableKey(1);
        keys.add(k);
        System.out.println("\n--- mutating a key after insertion ---");
        System.out.println("   before mutation, contains: " + keys.contains(k));
        k.x = 99;                                       // hashCode changes
        System.out.println("   after  mutation, contains: " + keys.contains(k));
        System.out.println("   remove succeeded:          " + keys.remove(k));
        System.out.println("   set still holds:           " + keys + "   ← UNREACHABLE, unremovable");

        // ---- 4. a terrible but legal hashCode ----
        class ConstantHash {
            final int v;
            ConstantHash(int v) { this.v = v; }
            @Override public boolean equals(Object o) { return o instanceof ConstantHash c && c.v == v; }
            @Override public int hashCode() { return 42; }         // legal!
        }
        Set<ConstantHash> bad = new HashSet<>();
        long start = System.nanoTime();
        for (int i = 0; i < 40_000; i++) bad.add(new ConstantHash(i));
        System.out.printf("%n--- hashCode quality ---%n   constant hash, 40k inserts: %,d ms%n",
                (System.nanoTime() - start) / 1_000_000);

        Set<Integer> ok = new HashSet<>();
        start = System.nanoTime();
        for (int i = 0; i < 40_000; i++) ok.add(i);
        System.out.printf("   good hash,     40k inserts: %,d ms%n",
                (System.nanoTime() - start) / 1_000_000);
    }
}

Four results to record:

  1. equals returns true while contains returns false. That contradiction is the whole lesson.
  2. The record just works.
  3. The mutated key is in the set, unreachable and unremovable — a live leak.
  4. The constant hash is dramatically slower. Legal, contract-satisfying, and unusable.

Part 2 — B-06 · Concurrency fundamentals

Race condition

Two or more threads access shared data, and the result depends on timing.

count++;

You saw this on Day 025. Three bytecode operations (Day 024):

   getfield count      // READ
   iadd                // MODIFY
   putfield count      // WRITE
   Thread A          Thread B          count
   ────────          ────────          ─────
   read (0)                              0
                     read (0)            0
   add → 1                               0
                     add → 1             0
   write 1                               1
                     write 1             1     ← two increments, one result

A lost update. Both threads did their work correctly; one result was overwritten.

Critical section

A region of code that accesses shared state and must not be executed by more than one thread at a time.

Four properties a correct solution needs:

Property Means
Mutual exclusion At most one thread inside at a time
Progress If nobody's inside, someone waiting gets in
Bounded waiting No thread waits forever — no starvation
No assumptions about speed Correct regardless of relative thread speeds

Atomicity

An operation is atomic if it appears to happen instantaneously — no other thread can observe it half-done.

What is atomic in Java:

  • Reads and writes of most primitives (except long and double on some 32-bit JVMs, unless volatile)
  • Reads and writes of references
  • AtomicInteger and friends, via CAS (Day 068)

What is NOT atomic:

  • count++ — read-modify-write
  • if (x == null) x = new X(); — check-then-act
  • map.get(k) then map.put(k, v) — the reason computeIfAbsent exists

Compound actions are the problem, and they're everywhere:

// 💀 check-then-act — two threads can both pass the check
if (!map.containsKey(k)) map.put(k, compute());

// ✅ atomic
map.computeIfAbsent(k, key -> compute());

Atomicity is not the only problem — visibility

boolean running = true;                 // NOT volatile

// Thread A
while (running) { doWork(); }

// Thread B
running = false;                        // Thread A may NEVER see this

Thread A can loop forever. Not because of a race in the atomicity sense — the write is atomic — but because there's no guarantee A ever sees B's write. The JIT may hoist the read out of the loop entirely, and CPU caches may not propagate.

Three distinct problems, and interviews reward distinguishing them:

Problem Meaning Fixed by
Atomicity An operation can be interrupted mid-way Locks, atomics
Visibility One thread's write isn't seen by another volatile, locks
Ordering Operations are reordered by compiler or CPU volatile, locks, happens-before

synchronized fixes all three. volatile fixes visibility and ordering, but not atomicity — which is exactly why volatile int count; count++ is still broken. Days 065–066 in full.


Type this yourself

public class RaceDemo {
    static int unsafeCount = 0;
    static volatile boolean running = true;

    public static void main(String[] args) throws Exception {

        // ---- lost updates ----
        Runnable increment = () -> { for (int i = 0; i < 100_000; i++) unsafeCount++; };
        Thread t1 = new Thread(increment), t2 = new Thread(increment);
        t1.start(); t2.start(); t1.join(); t2.join();
        System.out.println("Expected 200000, got: " + unsafeCount);

        // ---- synchronized fixes it ----
        Counter safe = new Counter();
        Runnable safeIncrement = () -> { for (int i = 0; i < 100_000; i++) safe.increment(); };
        Thread t3 = new Thread(safeIncrement), t4 = new Thread(safeIncrement);
        t3.start(); t4.start(); t3.join(); t4.join();
        System.out.println("Synchronized:      " + safe.get());

        // ---- visibility: remove `volatile` above and this may hang forever ----
        Thread worker = new Thread(() -> {
            long i = 0;
            while (running) i++;
            System.out.println("Worker stopped after " + i + " iterations");
        });
        worker.start();
        Thread.sleep(100);
        running = false;
        worker.join(3000);
        System.out.println(worker.isAlive()
                ? "Worker STILL RUNNING — visibility failure"
                : "Worker saw the write");
    }

    static class Counter {
        private int c = 0;
        synchronized void increment() { c++; }
        synchronized int get() { return c; }
    }
}

Run the unsafe counter five times — you'll get five different answers, all below 200,000. Then remove volatile from running, compile with optimisations warm, and watch the worker potentially never stop. Two different bugs from the same shared variable, which is precisely the distinction between atomicity and visibility.


Interview questions

Q: What's the contract between equals and hashCode?

Equal objects must return equal hash codes. The converse isn't required — unequal objects may collide. equals must also be reflexive, symmetric, transitive, consistent, and false for null.

Q: What breaks if you override equals but not hashCode?

Hash-based collections stop working. HashMap and HashSet locate an entry by hash first, so two equal objects with different hash codes land in different buckets and equals is never even called. Lookups fail and duplicates accumulate in a set.

Q: What happens if you mutate an object used as a map key?

Its hash code changes, but the entry stays in the bucket chosen at insertion time. The entry becomes unreachable by lookup and can't be removed, so it's effectively a memory leak. Keys should be immutable.

Q: Can you extend a class, add a field, and preserve the equals contract?

Not for an instantiable class. Using instanceof breaks transitivity once subclass instances with different extra fields both equal the same superclass instance; using getClass preserves the contract but violates Liskov substitution, since a subclass instance can no longer equal a superclass one. The accepted answer is composition rather than inheritance.

Q: Is return 42; a legal hashCode?

Legal but pathological. It satisfies the contract, since equal objects return equal hashes, but every entry collides into one bucket, degrading HashMap from constant time to linear — or logarithmic once the bucket treeifies.

Q: What's the difference between atomicity and visibility?

Atomicity is whether an operation can be interrupted mid-way — count++ is three steps, so two threads can lose an update. Visibility is whether one thread's write is ever seen by another; without synchronisation a thread can read a stale cached value indefinitely. volatile fixes visibility and ordering but not atomicity, which is why volatile int count; count++ is still broken.


Mini task

  1. Run EqualsDemo. Record all four results.
  2. Write equals and hashCode by hand for a three-field class. Then replace it with a record and compare.
  3. Reproduce the mutable-key leak and confirm the entry can't be removed.
  4. Run RaceDemo five times. Record the counter each time. Then remove volatile and see what happens.
  5. Try to make Point/ColorPoint satisfy the contract. Convince yourself it's impossible.

Exit questions

  1. State the five equals rules and the two hashCode rules.
  2. Explain mechanically what breaks when hashCode is missing.
  3. What happens when a key is mutated after insertion? Why is it a leak?
  4. Give the symmetry-violation example.
  5. Why can't you add a value component to an instantiable class and keep the contract?
  6. What makes a hashCode good rather than merely legal? Why 31?
  7. What is a race condition? Show it with count++ and the bytecode.
  8. Name the four properties of a correct critical-section solution.
  9. Atomicity vs visibility vs ordering — what fixes each?

Articulation drill

Two minutes: "What's the equals/hashCode contract, and what exactly goes wrong if you break it?"

The second half is what distinguishes a good answer. Walk through the bucket lookup: wrong bucket, equals never called, object appears to vanish.


Previous: Day 039 · Tomorrow: Day 041 — toString, Comparable vs Comparator, and why Cloneable is broken