LLD Dojo

Java Bridge · J6

equals, hashCode, toString, compareTo — and the == trap

The idea

== compares references, and no compiler will warn you

The lot finds a parked car's ticket from its plate, so Registration becomes a HashMap key. In C++ that class earns value semantics from operator==, and you write one. Java has no operator overloading, so there is nothing of that shape to write.

The first version therefore has no equals at all, and the lookup misses:

contains(equal plate)     : false
map.get(equal plate)      : null

The plate is in the list. == on objects always means "same reference", and Object.equals means the same thing. Nothing failed to compile and no warning printed, because equals was inherited rather than absent. That is the difference which costs hours: in C++ the compiler stops you at the comparison site, and here there is nothing to stop.

Add an equals comparing the two codes, then run it again:

contains(equal plate)     : true
map.get(equal plate)      : null

The list is right and the map is wrong, from one object in one program. HashMap compares hash codes before it calls equals, so equal objects with unequal hashes are never considered. This version is worse than the first, because a code review and a list-based test both pass it.

hashCode, derived from the same field equals compares, is the other half. Override one and you owe the other.

Two further methods replace operators C++ handed you. toString for anything you will read in a log, since the default prints LooseRegistration@1c20c684, and compareTo for ordering, since there is no operator<.


Coming from C++

From C++ — the four operators that became four methods

In C++ you overload operators and value comparison works everywhere. Java has no operator overloading, and the replacements are ordinary methods that the standard library calls by name. Fail to write them and the library calls Object's versions instead, which answer a different question.

operator== becomes equals(Object)

C++

class Registration {
    std::string code_;
public:
    explicit Registration(std::string code) : code_(std::move(code)) {}

    bool operator==(const Registration& other) const { return code_ == other.code_; }
};

Registration a{"KA01AB1234"};
Registration b{"KA01AB1234"};
a == b;                                    // true
std::vector<Registration> v{a};
std::find(v.begin(), v.end(), b) != v.end();   // true

Java

public final class Registration {

    private final String code;

    public Registration(String code) { this.code = code; }

    @Override
    public boolean equals(Object other) {
        if (this == other) return true;
        if (!(other instanceof Registration that)) return false;
        return code.equals(that.code);
    }
}
Registration a = new Registration("KA01AB1234");
Registration b = new Registration("KA01AB1234");
a == b;              // false, always, and no override can change it
a.equals(b);         // true, because of the method above
C++Java
a == b calls your operator==a == b compares references. Not overridable
Omit operator== and a == b is a compile errorOmit equals and a.equals(b) compiles, returning a == b
const Registration& parameterObject parameter. Narrowing it makes an overload, not an override
std::find uses operator==List.contains uses equals

The two rows that cost hours. In C++ the compiler stops you at the comparison site when operator== is missing. In Java there is nothing to stop, because Object already declares equals. So the missing method is not a compile error — it is a lookup that returns the wrong answer.

And the parameter type is Object, not Registration. The natural C++ translation is public boolean equals(Registration other), which compiles and is a different method:

Plate p = new Plate("KA01AB1234");
Plate q = new Plate("KA01AB1234");

p.equals(q);              // true   — the compiler picked your overload
list.contains(q);         // false  — List called Object.equals
p.equals((Object) q);     // false  — same

Verified output from that exact code, compiled with -Xlint:all, zero warnings. @Override converts it into a compile error:

Registration.java:28: error: method does not override or implement a method from a supertype
    @Override
    ^

That is the reason to write @Override on every equals you ever type.

std::hash specialisation becomes hashCode()

C++ — a separate opt-in, and the compiler makes you do it

namespace std {
    template <> struct hash<Registration> {
        size_t operator()(const Registration& r) const { return hash<string>{}(r.code()); }
    };
}
std::unordered_map<Registration, std::string> tickets;   // fails to compile without the above

Java — already inherited, already wrong

    @Override
    public int hashCode() {
        return code.hashCode();
    }
Map<Registration, String> tickets = new HashMap<>();     // compiles either way
C++Java
No std::hash specialisation, no unordered_map — compile errorHashMap accepts any key. Object.hashCode is inherited
operator== and hash are separately declared, both requiredequals and hashCode are separately overridable, and only convention links them

Object.hashCode returns a per-instance identity value. So an equals without a matching hashCode gives you two equal objects with different hashes, and HashMap.getNode compares hashes before it calls equals. The entry is never considered. Real output, one entry in the map:

--- 3. equals without hashCode: the list works, the map does not
contains(equal plate)     : true
map.get(equal plate)      : null
set size after 2 equal adds: 2

The list is right and the map is wrong, from the same object. This is worse than forgetting equals entirely, because a review and a list-based test both pass.

Plain javac says nothing. javac -Xlint:overrides does:

warning: [overrides] Class HalfEqualRegistration overrides equals, but neither it nor any
superclass overrides hashCode method

operator<< becomes toString()

C++

std::ostream& operator<<(std::ostream& os, const Registration& r) {
    return os << "Registration[" << r.code() << "]";
}

Java

    @Override
    public String toString() {
        return "Registration[" + code + "]";
    }

Without it, Object.toString returns the class name plus @ plus the hex identity hash:

no override : LooseRegistration@1c20c684
override    : Registration[KA01AB1234]

The hex changes between runs, so it is worse than useless in a log or an assertion failure. Unlike operator<<, toString is called implicitly by string concatenation, println, and every JUnit failure message — so overriding it changes the output of code you did not write.

operator< becomes compareTo, or a Comparator

C++ — one operator serves sorting and ordered containers

bool operator<(const Registration& a, const Registration& b) { return a.code() < b.code(); }

std::sort(v.begin(), v.end());                      // uses operator<
std::map<Registration, std::string> byPlate;        // uses operator<
std::sort(v.begin(), v.end(), byLengthThenCode);    // or an explicit comparator

Java — two mechanisms, and they mean different things

public final class Registration implements Comparable<Registration> {

    @Override
    public int compareTo(Registration other) {
        return code.compareTo(other.code);
    }
}
plates.sort(null);                                              // natural order, via compareTo
plates.sort(Comparator.comparing(Registration::code).reversed()); // one alternative order
Map<Registration, String> byPlate = new TreeMap<>();            // needs Comparable, or a Comparator
C++Java
operator<, returning boolcompareTo, returning negative/zero/positive int
Free function or member, your choiceImplementing Comparable<T> on the class
A comparator functor passed to std::sortA Comparator<T> passed to sort or to TreeMap
std::map needs a strict weak orderingTreeMap needs Comparable, or throws at run time

Comparable is the type's one natural order. Comparator is every other order, and it lives outside the type — which is how you sort by three different keys without touching the class.

Omit Comparable and a TreeMap fails at run time, not compile time:

ClassCastException: class LooseRegistration cannot be cast to class java.lang.Comparable
(LooseRegistration is in unnamed module of loader 'app'; java.lang.Comparable is in module
java.base of loader 'bootstrap')

Note where the failure lands: on the first put, not at the new TreeMap<>(), and not at compile time. TreeMap<K, V> places no extends Comparable bound on K, because a Comparator is the other legal way to supply the order.

compareTo must agree with equals. TreeMap uses compareTo alone and never calls equals. So a compareTo returning 0 for two objects that are not equals gives you a TreeMap and a HashMap that disagree about whether you have one key or two.

The shortcut: record

The corpus contract uses one:

public record Vehicle(String registration, VehicleType type) {}

A record generates equals, hashCode and toString from its components, correctly and consistently. It generates no compareTo. So the four-method drill above applies whenever you write a class rather than a record. That means whenever the type needs mutable state, extra invariants, or identity semantics. record is J11; the reason to know it now is that it removes three of the four traps on this page.


Worked walkthrough

NOTES — three versions of one value type, and what each one breaks

Run it first

.toolchain\jdk-21\bin\javac.exe -d out *.java
.toolchain\jdk-21\bin\java.exe -cp out Main

Real output, verbatim:

--- 1. == on two objects with the same value
a == b        : false
a.equals(b)   : true

--- 2. no equals at all: the list lookup that misses
size                      : 1
contains(equal plate)     : false
map.get(equal plate)      : null

--- 3. equals without hashCode: the list works, the map does not
contains(equal plate)     : true
map.get(equal plate)      : null
set size after 2 equal adds: 2

--- 4. equals and hashCode together: every lookup lands
contains(equal plate)     : true
map.get(equal plate)      : T-9001
set size after 2 equal adds: 1

--- 5. toString: default versus overridden
no override : LooseRegistration@1c20c684
override    : Registration[KA01AB1234]

--- 6. ordering without operator<
natural order   : [Registration[KA01AB1234], Registration[KA03CD5678], Registration[KA05ZZ0002]]
reversed        : [Registration[KA05ZZ0002], Registration[KA03CD5678], Registration[KA01AB1234]]
TreeMap keys    : [Registration[KA01AB1234], Registration[KA03CD5678], Registration[KA05ZZ0002]]

--- 7. a TreeMap key that is not Comparable
ClassCastException: class LooseRegistration cannot be cast to class java.lang.Comparable (LooseRegistration is in unnamed module of loader 'app'; java.lang.Comparable is in module java.base of loader 'bootstrap')

The hex in LooseRegistration@1c20c684 is an identity hash and changes between JVM runs. Every other line is deterministic, including the two nulls and the set size of 2.

Read block 3 twice. contains says the plate is there and get says it is not, for the same object, in the same program. That is the shape of the bug this lesson exists to prevent, and it is the one that reaches production, because a list-based test passes.


Registration.java — the version that works

public final class Registration implements Comparable<Registration> {

    private final String code;

final on the class as well as the field. A subclass could override equals and break symmetry: sub.equals(base) true while base.equals(sub) false. HashMap then behaves differently depending on which object it happens to hold, which is a bug with no reliable reproduction. Sealing the class removes the possibility. The alternative, getClass() != other.getClass() inside equals, is the other accepted answer and is what an interviewer may ask you to compare it to.

    public Registration(String code) {
        if (code == null || code.isBlank()) {
            throw new IllegalArgumentException("registration code must not be blank");
        }
        this.code = code;
    }

The null check is load-bearing for hashCode. hashCode delegates to code.hashCode(), which would throw NullPointerException on a null field. Rejecting null in the constructor means no Registration exists that cannot be hashed. That invariant is what A5 calls "no invalid instance can exist", and here it is the difference between a clean exception at construction and a NullPointerException from inside HashMap.put.

    @Override
    public boolean equals(Object other) {
        if (this == other) {
            return true;
        }

The parameter is Object. Narrow it to Registration and you have written an overload that the whole standard library ignores. @Override is what makes that a compile error rather than a silent wrong answer — see experiment 1 below.

this == other is a fast path, not a correctness requirement. It matters because HashMap.getNode calls equals after finding a bucket match, and in the common case the key found is the key given. Removing this line keeps the code correct and slower.

        if (!(other instanceof Registration that)) {
            return false;
        }

One line covers null and wrong-type. instanceof is false for null, so no separate null check is needed. The that on the end is a pattern variable, in scope only where the test succeeded, and it removes the cast that would otherwise throw ClassCastException.

The contract equals must satisfy: reflexive, symmetric, transitive, consistent, and x.equals(null) false. Symmetry is the one people break, and instanceof against a non-final class is the usual way they break it.

    @Override
    public int hashCode() {
        return code.hashCode();
    }

Derived from exactly the fields equals compares. The contract runs one way only: equal objects must have equal hashes; unequal objects may share a hash. Break it and every hash collection breaks, because HashMap compares hashes first and never reaches equals.

Objects.hash(code) is the usual form for several fields and is correct here too. For one field it allocates a varargs array on every call, which is measurable when a key is hashed inside a hot loop. Two fields or more: Objects.hash(a, b).

Nothing requires the hash to be unique or well distributed. A hashCode returning 0 for every instance is legal, correct, and turns every HashMap operation into a linear scan.

    @Override
    public String toString() {
        return "Registration[" + code + "]";
    }

This changes the output of code you did not write. String concatenation, println, and every JUnit failure message call toString implicitly. Compare the two lines in block 5: the default tells you nothing, and it is what a failing assertion will show you at 1am.

    @Override
    public int compareTo(Registration other) {
        return code.compareTo(other.code);
    }

Delegating to String.compareTo gets the sign convention right for free. The contract is negative, zero, or positive — never specifically -1 and 1. Writing return this.code.length() - other.code.length() is the classic bug: correct for short strings, and wrong on overflow for large int values.

compareTo must agree with equals. TreeMap uses compareTo alone and never calls equals. A compareTo returning 0 for objects that are not equals therefore gives you a TreeMap and a HashMap that disagree about how many keys you have.


LooseRegistration.java — no equals, no hashCode

The whole class is a field, a constructor and an accessor. It is what a C++ developer writes first, because in C++ the missing operator== would be a compile error at the comparison site.

Here equals is inherited from Object, where it is defined as this == other. So:

size                      : 1
contains(equal plate)     : false
map.get(equal plate)      : null

The list holds one element, and contains says it does not hold an equal one. Both statements are true, because "equal" means "the same object" until you say otherwise.

The invariant this class fails to hold: two registrations with the same code are interchangeable. Every lookup in the parking lot depends on it — find a ticket from a plate, check whether a vehicle is already parked, count distinct vehicles.


HalfEqualRegistration.javaequals without hashCode

contains(equal plate)     : true
map.get(equal plate)      : null
set size after 2 equal adds: 2

contains works and get does not, deterministically. Not "usually" and not "depending on timing". HashMap.getNode reads:

if (first.hash == hash && ((k = first.key) == key || (key != null && key.equals(k))))

The hash comparison comes first and short-circuits. Two equal objects with different identity hashes never reach the equals call, whatever bucket they land in. Same reason the HashSet holds two elements it considers equal.

Plain javac prints nothing. javac -Xlint:overrides prints this:

HalfEqualRegistration.java:6: warning: [overrides] Class HalfEqualRegistration overrides equals, but neither it nor any superclass overrides hashCode method
public final class HalfEqualRegistration {
             ^
1 warning

-Xlint:all includes it. Adding that flag once is the cheapest defence against the only mistake here a compiler can see.


Two experiments, run for real

1 · The equals overload that is not an override

Write the C++-shaped signature, with no @Override:

public final class Plate {
    private final String code;
    public Plate(String code) { this.code = code; }

    public boolean equals(Plate other) { return code.equals(other.code); }

    @Override public int hashCode() { return code.hashCode(); }
}

Compiled with -Xlint:all. Zero warnings. Then:

p.equals(q) directly     : true
list.contains(q)         : false
p.equals((Object) q)     : false

The first line is your overload, chosen by the compiler because the static type is Plate. The second and third are Object.equals, chosen because List.contains holds Object references. So the method works everywhere you call it directly and nowhere the library calls it.

Add @Override and the file stops compiling:

Registration.java:28: error: method does not override or implement a method from a supertype
    @Override
    ^
1 error

That is the entire argument for writing @Override on equals every time.

2 · A TreeMap key with no Comparable

new TreeMap<>() compiles for any key type, because supplying a Comparator is the other legal way to give an order. The failure lands on the first put:

ClassCastException: class LooseRegistration cannot be cast to class java.lang.Comparable (LooseRegistration is in unnamed module of loader 'app'; java.lang.Comparable is in module java.base of loader 'bootstrap')

Read the message past the module noise: the key type is not Comparable. Two fixes, and they mean different things. implements Comparable<Registration> declares the type's one natural order. new TreeMap<>(comparator) supplies an order for this map only, and leaves the type alone.


Which type to write

Reach for a record first:

public record Vehicle(String registration, VehicleType type) {}

That generates equals, hashCode and toString from the components, and none of this lesson's traps apply. It generates no compareTo.

Write the four methods by hand when a record will not do the job: the type has mutable state, or identity semantics are what you actually want. A ParkingLot is not a value and should have no equals — two lots with equal contents are not the same lot. Overriding equals on an entity is its own bug, and the tell is that the type has a lifecycle rather than a value.


Worked source

The 4 files of the worked design

Every file below is the one the app opens, verbatim. This is the part worth reading slowly: the prose above argues for a shape, and these are the lines that have it.

worked/src/HalfEqualRegistration.java37 lines

// HalfEqualRegistration.java
//
// equals overridden, hashCode not. This is the more dangerous of the two mistakes, because
// list.contains works and only the hash collections lie. Code review passes, unit tests on
// lists pass, and the HashMap lookup misses in production.
public final class HalfEqualRegistration {

    private final String code;

    public HalfEqualRegistration(String code) {
        this.code = code;
    }

    public String code() {
        return code;
    }

    @Override
    public boolean equals(Object other) {
        if (this == other) {
            return true;
        }
        if (!(other instanceof HalfEqualRegistration that)) {
            return false;
        }
        return code.equals(that.code);
    }

    // hashCode is deliberately absent. Object.hashCode gives a per-instance identity value,
    // so two equal HalfEqualRegistrations report different hashes. HashMap compares hashes
    // before it calls equals, so the entry is never considered.
    //
    // Plain javac is silent about this. `javac -Xlint:overrides` is not:
    //     warning: [overrides] Class HalfEqualRegistration overrides equals, but neither it
    //     nor any superclass overrides hashCode method
    // Worth adding to your build once, because it is the one mistake here a compiler can see.
}

worked/src/LooseRegistration.java18 lines

// LooseRegistration.java
//
// The same type with equals and hashCode left out. This is what you get by writing the class
// and stopping, and it is what a C++ developer does not expect: in C++ you would reach for
// operator==, and its absence is a compile error at the comparison site. Here the comparison
// compiles and answers the wrong question.
public final class LooseRegistration {

    private final String code;

    public LooseRegistration(String code) {
        this.code = code;
    }

    public String code() {
        return code;
    }
}

worked/src/Registration.java80 lines

// Registration.java
//
// A vehicle registration, used as a HashMap key so the lot can find a parked car's ticket
// from its plate. Four methods make that work, and Java generates none of them for a class.
public final class Registration implements Comparable<Registration> {

    private final String code;

    public Registration(String code) {
        if (code == null || code.isBlank()) {
            throw new IllegalArgumentException("registration code must not be blank");
        }
        this.code = code;
    }

    public String code() {
        return code;
    }

    /**
     * Two registrations are equal when their codes are equal. Without this method, `equals`
     * is inherited from Object, where it means `==` — reference identity. Then
     * list.contains(new Registration("KA01AB1234")) returns false with that plate in the list.
     *
     * The parameter is Object, not Registration. Narrow it and you have written an overload,
     * not an override, and every collection keeps calling Object's version.
     */
    @Override
    public boolean equals(Object other) {
        // Identity fast path. Not required for correctness; it short-circuits the common
        // map-lookup case where the key found is the key given.
        if (this == other) {
            return true;
        }
        // instanceof is false for null, so this one line covers both the null case and the
        // wrong-type case. A cast without it throws ClassCastException.
        if (!(other instanceof Registration that)) {
            return false;
        }
        return code.equals(that.code);
    }

    /**
     * Derived from exactly the field equals() compares. That is the contract: equal objects
     * must return equal hash codes.
     *
     * Break it and every hash collection breaks with it. HashMap.getNode compares hashes
     * before it calls equals, so a wrong hash means the entry is never even considered.
     */
    @Override
    public int hashCode() {
        // One field, so delegate. Objects.hash(code) also works and allocates a varargs
        // array on every call — measurable when a key is hashed inside a hot loop.
        return code.hashCode();
    }

    /**
     * Object.toString() returns getClass().getName() + "@" + hex identity hash, which tells
     * you nothing in a debugger or an assertion failure. Override it on every type you will
     * read in a log.
     */
    @Override
    public String toString() {
        return "Registration[" + code + "]";
    }

    /**
     * Java has no operator overloading. compareTo is the replacement for operator<, and
     * implementing Comparable is what lets a Registration be a TreeMap key or be sorted by
     * Collections.sort with no comparator.
     *
     * The contract: consistent with equals, so compareTo returns 0 exactly when equals
     * returns true. TreeMap uses compareTo alone and ignores equals entirely, so an
     * inconsistent pair gives you two collections that disagree about duplicates.
     */
    @Override
    public int compareTo(Registration other) {
        return code.compareTo(other.code);
    }
}

worked/src/Main.java107 lines

// Main.java
//
// Runs the trap and prints what actually happens. Every line of output in NOTES.md came from
// this file.
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.TreeMap;

public class Main {

    private static final String PLATE = "KA01AB1234";

    public static void main(String[] args) {
        System.out.println("--- 1. == on two objects with the same value");
        Registration a = new Registration(PLATE);
        Registration b = new Registration(PLATE);
        System.out.println("a == b        : " + (a == b));
        System.out.println("a.equals(b)   : " + a.equals(b));

        System.out.println();
        System.out.println("--- 2. no equals at all: the list lookup that misses");
        List<LooseRegistration> parkedLoose = new ArrayList<>();
        parkedLoose.add(new LooseRegistration(PLATE));
        System.out.println("size                      : " + parkedLoose.size());
        System.out.println("contains(equal plate)     : "
                + parkedLoose.contains(new LooseRegistration(PLATE)));

        Map<LooseRegistration, String> ticketsLoose = new HashMap<>();
        ticketsLoose.put(new LooseRegistration(PLATE), "T-9001");
        System.out.println("map.get(equal plate)      : "
                + ticketsLoose.get(new LooseRegistration(PLATE)));

        System.out.println();
        System.out.println("--- 3. equals without hashCode: the list works, the map does not");
        List<HalfEqualRegistration> parkedHalf = new ArrayList<>();
        parkedHalf.add(new HalfEqualRegistration(PLATE));
        System.out.println("contains(equal plate)     : "
                + parkedHalf.contains(new HalfEqualRegistration(PLATE)));

        Map<HalfEqualRegistration, String> ticketsHalf = new HashMap<>();
        ticketsHalf.put(new HalfEqualRegistration(PLATE), "T-9001");
        System.out.println("map.get(equal plate)      : "
                + ticketsHalf.get(new HalfEqualRegistration(PLATE)));

        Set<HalfEqualRegistration> halfSet = new HashSet<>();
        halfSet.add(new HalfEqualRegistration(PLATE));
        halfSet.add(new HalfEqualRegistration(PLATE));
        System.out.println("set size after 2 equal adds: " + halfSet.size());

        System.out.println();
        System.out.println("--- 4. equals and hashCode together: every lookup lands");
        List<Registration> parked = new ArrayList<>();
        parked.add(new Registration(PLATE));
        System.out.println("contains(equal plate)     : " + parked.contains(new Registration(PLATE)));

        Map<Registration, String> tickets = new HashMap<>();
        tickets.put(new Registration(PLATE), "T-9001");
        System.out.println("map.get(equal plate)      : " + tickets.get(new Registration(PLATE)));

        Set<Registration> set = new HashSet<>();
        set.add(new Registration(PLATE));
        set.add(new Registration(PLATE));
        System.out.println("set size after 2 equal adds: " + set.size());

        System.out.println();
        System.out.println("--- 5. toString: default versus overridden");
        System.out.println("no override : " + new LooseRegistration(PLATE));
        System.out.println("override    : " + new Registration(PLATE));

        System.out.println();
        System.out.println("--- 6. ordering without operator<");
        List<Registration> plates = new ArrayList<>(List.of(
                new Registration("KA05ZZ0002"),
                new Registration("KA01AB1234"),
                new Registration("KA03CD5678")));

        // Natural order. No comparator argument, because Registration is Comparable.
        plates.sort(null);
        System.out.println("natural order   : " + plates);

        // A different order, without touching Registration. Comparator is the replacement
        // for passing a comparison functor to std::sort.
        plates.sort(Comparator.comparing(Registration::code).reversed());
        System.out.println("reversed        : " + plates);

        // TreeMap uses compareTo and never calls equals.
        Map<Registration, String> byPlate = new TreeMap<>();
        for (Registration r : plates) {
            byPlate.put(r, "T-" + r.code().substring(2, 4));
        }
        System.out.println("TreeMap keys    : " + byPlate.keySet());

        System.out.println();
        System.out.println("--- 7. a TreeMap key that is not Comparable");
        try {
            Map<LooseRegistration, String> broken = new TreeMap<>();
            broken.put(new LooseRegistration(PLATE), "T-9001");
        } catch (ClassCastException e) {
            System.out.println("ClassCastException: " + e.getMessage());
        }
    }
}

The faded stage is not here, on purpose

In the app, the third stage of a lesson hands you the worked design with a few lines replaced by // GAP: markers, then compiles your completion and runs a JUnit suite against it. That needs javac, and a static site has no compiler — so rather than show a control that cannot work, this page stops at the worked source.

Run the app for the drill: it is the download in the header, and it works offline once unpacked.

← J5 · Interfaces vs abstract classes — default methods, and where state is allowed to live J7 · Collections, mapped from the STL →

← all lessons