Layers of Logic

8.3

The Object Class: `equals`, `hashCode`, `toString`

Every class you have ever written already extends one class you never mentioned. Three of its methods decide whether your objects can be compared, printed, or found again, and getting one of them wrong makes data disappear.

Core24 min read5 exercises
01

Previously on

You have been collecting the pieces of this section since Section 1.2.

In that section’s javap exercise you found a constructor you never wrote, calling something on java/lang/Object, in a class where you had written nothing of the kind. That was the clue.

In Section 3.2, == on two Strings gave you an answer that depended on how the String was made. In Section 4.1 you met references. In Section 6.3 you learned that == compares reference identity. In Section 7.1 you learned inheritance, and were told the last piece was waiting here.

Here it is:

class Student { }
class Student extends Object { }      // identical. Java wrote the second one for you.

Every class you have ever written already extends Object. That is why every object you have ever made already had toString(), equals() and hashCode() without you writing a line.

02

The problem

Three things that should work, and do not.

Student a = new Student("Aditya", 101);
Student b = new Student("Aditya", 101);

One. Print one.

System.out.println(a);       // Student@29453f44

Not the name. Not the roll number. A class name, an at sign, and a hexadecimal number.

Two. Compare them.

System.out.println(a.equals(b));      // false

Same name. Same roll number. Java says they are different students.

Three. Put them in a set.

Set<Student> set = new HashSet<>();
set.add(a);
set.add(b);
System.out.println(set.size());              // 2
System.out.println(set.contains(new Student("Aditya", 101)));   // false

A Set is supposed to reject duplicates. It let one in. And it cannot find a student it clearly contains.

None of this is broken. All three are Java doing exactly what it was told, using the versions of these methods you inherited without knowing.

03

The idea

Object is the root of every class in Java. It provides eleven methods, and three of them are your responsibility.

MethodWhat the inherited version does
toString()printing your objectclass name, @, and the hash in hexadecimal
equals(Object)comparing two objectsinherits identity comparison unless you override it
hashCode()a number standing for the objectderived from the address
All three defaults are about identity. None of them look at your fields, because Object has never seen your fields.

That is the key insight. Object was written before your class existed. It cannot know that a Student is identified by its roll number, or that a Point is identified by x and y. Only you know that. So the default answers everything in terms of the only thing Object can see: the address.

For most classes that is wrong, and telling Java what “the same” means is your job.

04

Under the hood

Going deeper

toString(): the easy one

@Override
public String toString() {
    return "Student{name='" + name + "', rollNumber=" + rollNumber + "}";
}

Now printing works, and so does string concatenation, and so does the debugger, and so does every log line.

equals(): what “the same” means

The default compares object identity, so two distinct objects with identical contents are not equal.

A correct equals() has a standard shape, and every part of it is there for a reason:

@Override
public boolean equals(Object o) {
    if (this == o) return true;                       // 1. same object: fast path
    if (o == null || getClass() != o.getClass()) return false;   // 2. wrong type or null
    Student other = (Student) o;                      // 3. now the cast is safe
    return rollNumber == other.rollNumber              // 4. compare the identifying fields
            && Objects.equals(name, other.name);
}

Why each line is there

  1. this == oIf it is literally the same object, the answer is yes and there is no point doing any work. This is an optimisation, and it matters because collections call equals constantly.
  2. null and type checkNote the short circuit from Section 3.1: o == null || runs first, so getClass() is never called on null.
  3. The castOnly safe because line 2 already proved the type. Without it, this is the ClassCastException from Section 7.2.
  4. Compare the fields that define identityNot necessarily all the fields. You decide which ones make two students the same student.

The five rules equals() must follow

The contract is written into Object’s documentation, and collections rely on all five.

RuleWhat it means
Reflexivex.equals(x)must always be true
Symmetricx.equals(y)must equal y.equals(x)
Transitivex=y and y=zthen x must equal z
Consistentcall it twicesame answer, unless the object changed
Null safex.equals(null)must be false, never an exception

Symmetry is the one that breaks in real code, usually via inheritance. If Student.equals accepts any subclass but GraduateStudent.equals requires an exact type, then student.equals(grad) is true and grad.equals(student) is false. A HashSet containing both then behaves differently depending on insertion order, which is a horrible thing to debug.

Using getClass() != o.getClass() rather than instanceof keeps symmetry safe, and that is why the template above uses it.

hashCode(): the one people skip

Of everything in this section, this part matters most. Skipping hashCode produces a bug that looks impossible.

Override equals alone and run the three failures from the top of this section:

set size (want 1): 2          <- the duplicate got in
contains         : false      <- cannot find something it contains

Meanwhile a List works perfectly:

List.contains with equals only: true

That inconsistency is the clue. List.contains walks every element calling equals, so it needs nothing else. HashSet does something much cleverer, and the cleverness has a requirement.

What HashSet actually does when you add something

  1. Call hashCode() on the objectOne int. Cheap.
  2. Turn that int into a bucket numberUsing the bit trick from Section 3.1: hash & (numberOfBuckets - 1), which is a fast modulo because the bucket count is a power of two.
  3. Look only in that one bucketNot the whole set. One bucket, usually holding zero or one thing.
  4. Call equals() on whatever is in thereJust to be sure, because two different objects can share a hash code.

So a HashSet answers “do you contain this?” in the same time on a million elements as on ten. It does not search. It works out where the answer would be.

The bucket number provides direct indexed access into an internal table, using the array semantics from Section 4.2. Java does not expose a physical address during this operation.

Now the failure is visible. Your two equal students are distinct objects, so their inherited identity hash codes may differ. Step 2 can send them to different buckets, which means step 4 never compares them. Your correct equals() is never called.

The contract

If two objects are equal, they must have the same hash code.

One sentence. Everything else follows.

The reverse is not required. Two unequal objects may share a hash code, which is called a collision. Collisions are unavoidable, because a hash code is an int with about four billion values and there are infinitely many possible objects. Collisions cost speed, not correctness, and equals resolves them.

What you didWhat happens
Neither method overriddenboth identity basedconsistent, but no two distinct objects are ever equal
equals onlyBROKENduplicates get into sets, contains fails, map lookups miss
hashCode onlywasteful, not brokenequal objects land in one bucket, then equals says no
Both, consistentlycorrecteverything works

Writing hashCode()

The easy way, and the right default:

@Override
public int hashCode() {
    return Objects.hash(rollNumber, name);      // same fields as equals. Always.
}

The classic manual version, which you will read in older code:

@Override
public int hashCode() {
    int result = 17;
    result = 31 * result + rollNumber;
    result = 31 * result + (name == null ? 0 : name.hashCode());
    return result;
}

The rule that matters more than the algorithm: use the same fields in hashCode as in equals. Every time. Different fields means the contract breaks, and the breakage is silent.

The bug that eats data

Here is the worst one, and it follows directly from everything above.

Map<Unit, String> map = new HashMap<>();
Unit key = new Unit(1);
map.put(key, "value");

System.out.println(map.get(key));       // "value"

key.id = 99;                            // change a field used in hashCode

System.out.println(map.get(key));       // null
System.out.println(map.containsKey(key));  // false
System.out.println(map.size());         // 1

Read those last three lines again.

The map still says it holds one entry. It cannot find it. It cannot remove it. The value sits in the bucket the key hashed to when you put it in, and every later lookup computes the new hash and looks somewhere else.

No exception. No warning. The data is in memory, counted, and permanently unreachable.

05

What it costs

These methods are boilerplate, and there is a lot of it. Three per data class, all mechanical. Your IDE generates them, records generate them, Lombok generates them, which is a fair sign the language should have done it sooner.

They also have to be kept in step forever. Add a field and you must remember to add it to equals and hashCode. Nothing warns you, and this is the commonest way a correct pair quietly becomes incorrect.

A weak hashCode destroys performance without breaking anything. Return a constant and every entry lands in one bucket. Your HashMap is now a list, constant time is now linear time, and no error appears anywhere.

equals is called constantly, too. Every contains, every remove, every map lookup. A slow equals on a large collection is a real cost.

What you get is the ability to use your own types like built-in ones. Put them in sets, use them as map keys, call contains, print them usefully. Three methods buy your class entry to the entire Collections Framework.

06

Check yourself

Answer from memory before opening the explanation. Then compare your rule with the answer and revisit the example if they differ.

  1. You override `equals()` correctly and leave `hashCode()` alone. You add two equal objects to a `HashSet`. What size does the set report, and why?

    Show the answer

    2. The duplicate got in, and the set is now wrong.

    HashSet does not compare every element with every other element, because that would be slow. It uses hashCode() to jump to one bucket, and only compares within that bucket.

    You left hashCode() as the inherited identity-based implementation. Distinct objects may then produce different hash codes, even when your override calls them equal. They can land in different buckets, so the set never compares them. Your correct equals() is never called.

    Worse, contains() then returns false for an object equal to one already in the set, because it looks in the wrong bucket too.

    Note that List.contains() works fine, because a list has no buckets and just walks every element calling equals(). That is why this bug hides.

  2. Two objects have the same `hashCode()`. Must they be equal?

    Show the answer

    No, and this direction of the contract is the one people invert.

    The contract says: if two objects are equal, their hash codes must be equal. It says nothing about the reverse.

    Two unequal objects sharing a hash code is called a collision, and it is unavoidable. A hash code is an int, so there are about four billion possible values, and there are infinitely many possible objects. Collisions must happen.

    Collisions are a performance problem, not a correctness problem. Both objects land in the same bucket, and equals() sorts them out from there. That is exactly why a hash based collection needs both methods: hashCode to find the bucket fast, equals to be right.

  3. You put an object in a `HashMap` as a key, then change one of its fields. `map.size()` still says 1 and `map.get(key)` returns null. Where did the value go?

    Show the answer

    Nowhere. It is still in the map, in the bucket the object hashed to when you put it in. It is unreachable, and that is all.

    put computed the hash, picked bucket 5, and stored the entry there. Then you changed a field, so the object now hashes to bucket 12. get computes the new hash, looks in bucket 12, finds nothing, and returns null.

    The entry is still occupying memory and still counted by size(). You cannot retrieve it, and you cannot remove it, because remove looks in bucket 12 as well.

    This is the strongest practical argument for Section 8.2: use immutable objects as keys, and this cannot happen.

07

Exercises

Write each solution in VS Code, predict its result, and then run the checks. Reading an example does not replace implementing it.

5 exercises135 pointsabout 145 minutes

The Layers of Logic VS Code extension runs the checks for exercises marked checked. For a manual exercise, run the program and compare its behaviour with the stated requirements and sample output.
A

Three Things That Should Work

Warm up·20 min·15 points

ex-8-3-a

Five results, and none of them are what you want. Run it and record all five before changing anything.

Then explain each one in terms of what the inherited method is actually looking at. There is one answer that covers all three methods.

Add toString and equals, run again, and note carefully what changed and what did not. The direct comparison starts working. So does the list. The set does not.

That difference is the whole point of this exercise, and working out why leads directly to the next one.

What your program must do

  • Run it and record all five results
  • Explain what each inherited method is actually comparing or printing
  • Add toString and equals, then run again and record what changed
  • Explain why the set is still wrong after adding equals
Broken.java
import java.util.*;

class Student {
    String name;
    int rollNumber;
    Student(String name, int rollNumber) { this.name = name; this.rollNumber = rollNumber; }
}

public class Broken {
    public static void main(String[] args) {
        Student a = new Student("Aditya", 101);
        Student b = new Student("Aditya", 101);

        System.out.println("print    : " + a);
        System.out.println("equals   : " + a.equals(b));

        Set<Student> set = new HashSet<>();
        set.add(a);
        set.add(b);
        System.out.println("set size : " + set.size());
        System.out.println("contains : " + set.contains(new Student("Aditya", 101)));

        List<Student> list = new ArrayList<>(List.of(a));
        System.out.println("list has : " + list.contains(new Student("Aditya", 101)));
    }
}
Hint 1
All three inherited methods answer in terms of the only thing Object can see: the address. It has never seen your name or roll number fields.
Hint 2
After adding equals, the direct comparison works and List.contains works, because a list walks every element calling equals. The set does not, and that is the clue.
Hint 3almost the answer
A HashSet uses hashCode() first to pick a bucket, and only calls equals inside that bucket. You have not overridden hashCode, so the two students land in different buckets and are never compared. Your correct equals is never called.
What this is really testing

Whether you can connect three unrelated looking failures to one cause. The inherited methods know nothing about your fields, and all three problems come from that.

B

Write the Pair Correctly

Real work·30 min·25 points

ex-8-3-b

Write the pair properly, and be able to defend every line.

Note that college is deliberately not part of identity. Two students with the same name and roll number are the same student even if the college field differs. That is a design decision, and it is yours to make, which is the whole reason Java cannot write these methods for you.

Get the guard clauses right. Both equals(null) and equals("Aditya") must return false rather than throwing, and the order of your checks is what makes that work.

Then write one sentence per line of your equals, saying what it is guarding against. If any line has no answer, you copied it rather than understood it.

What your program must do

  • Implement all three methods, using the same two fields in equals and hashCode
  • Handle null and a wrong type without throwing
  • Confirm the set collapses two students to one
  • Explain what each line of your equals is guarding against
Pair.java
import java.util.*;

class Student {
    private final String name;
    private final int rollNumber;
    private final String college;      // deliberately NOT part of identity

    Student(String name, int rollNumber, String college) {
        this.name = name; this.rollNumber = rollNumber; this.college = college;
    }

    // TODO: toString
    // TODO: equals  - two students are the same if name and rollNumber match
    // TODO: hashCode - using exactly the same fields
}

public class Pair {
    public static void main(String[] args) {
        Student a = new Student("Aditya", 101, "IIT Guwahati");
        Student b = new Student("Aditya", 101, "IIT Delhi");     // different college

        System.out.println("equal    : " + a.equals(b));         // should be true
        System.out.println("same hash: " + (a.hashCode() == b.hashCode()));

        Set<Student> set = new HashSet<>(List.of(a, b));
        System.out.println("set size : " + set.size());          // should be 1

        System.out.println("null safe: " + a.equals(null));      // must be false, not a crash
        System.out.println("wrong type: " + a.equals("Aditya")); // must be false, not a crash
    }
}
Hint 1
The standard shape is four steps: this == o fast path, then null and type check, then the cast, then compare the identifying fields.
Hint 2
The null check must come first in the ||, so that short circuiting from Section 3.1 stops getClass() being called on null.
Hint 3almost the answer
Objects.hash(name, rollNumber) gives you a correct hashCode in one line. The rule that matters is not the algorithm: it is that hashCode uses exactly the same fields as equals. Include college in one and not the other and the contract breaks silently.
What this is really testing

Whether you can write equals and hashCode from memory, with every guard clause in place and for a reason. This is code you will write hundreds of times.

C

The Four Combinations

Real work·30 min·30 points

ex-8-3-c

Four classes, four combinations, and they do not fail in the same way.

Test all four with a HashSet and with an ArrayList, and present the results as a table. The table is the deliverable, because seeing all eight results next to each other is what makes the pattern visible.

One combination is quietly destructive: the collection holds wrong data and reports wrong answers, with no exception anywhere. Identify it and explain the exact mechanism.

One combination is wasteful but harmless. Identify that one too, and be able to say why it is different in kind from the destructive one.

The ArrayList column matters as much as the HashSet column. It works in cases where the set fails, and that inconsistency is exactly why this bug survives testing.

What your program must do

  • Test all four classes with both a HashSet and an ArrayList
  • Present the results as a table
  • Say which combination is silently destructive, and why
  • Explain why HashOnly is wasteful but not incorrect
Four.java
import java.util.*;

class Neither { int id; Neither(int id){this.id=id;} }

class EqualsOnly {
    int id; EqualsOnly(int id){this.id=id;}
    @Override public boolean equals(Object o){ return o instanceof EqualsOnly e && e.id == id; }
}

class HashOnly {
    int id; HashOnly(int id){this.id=id;}
    @Override public int hashCode(){ return Integer.hashCode(id); }
}

class Both {
    int id; Both(int id){this.id=id;}
    @Override public boolean equals(Object o){ return o instanceof Both b && b.id == id; }
    @Override public int hashCode(){ return Integer.hashCode(id); }
}

public class Four {
    public static void main(String[] args) {
        // TODO: for each of the four classes, add two objects with id 1 to a HashSet
        //       and report the size and whether contains finds a third equal object.
        //       Then do the same with an ArrayList.
    }
}
Hint 1
Write one method that takes the objects and prints the set size and the contains result, then call it four times. Repeating the test code four times makes the table harder to compare.
Hint 2
EqualsOnly is the destructive one. Duplicates get into the set and contains returns false, so the collection silently holds wrong data and reports wrong answers.
Hint 3almost the answer
HashOnly puts equal objects in the same bucket, then equals falls back to the inherited address comparison and says no. So you get duplicates too, but nothing is silently lost: it behaves exactly like having neither, with extra work done.
What this is really testing

Whether you know what each way of getting this wrong actually costs. Three of the four combinations behave differently, and only one of the failures is silent and destructive.

D

Make a HashMap Lose Your Data

Hard·30 min·35 points

ex-8-3-d

Produce the worst bug in this phase deliberately, and look at it.

A map that reports size() == 1, returns null from get, returns false from containsKey, cannot remove the entry, and will happily show you that entry when you iterate.

Do all five. The iteration is the one that makes it land, because you can see the data sitting there while every lookup insists it does not exist.

Then work out the two ways to prevent it. One is a rule about which objects may be used as keys. The other is a rule about what you may do to a key while it is in a collection.

Both come from Section 8.2, and this is why that section came before this one.

What your program must do

  • Show that get returns null and containsKey returns false after the change
  • Show that size still counts the entry
  • Try to remove the entry and show that it cannot be removed
  • Iterate the map and show the entry is still physically there
Lost.java
import java.util.*;

class MutableKey {
    int id;
    MutableKey(int id) { this.id = id; }
    @Override public boolean equals(Object o) { return o instanceof MutableKey k && k.id == id; }
    @Override public int hashCode() { return Integer.hashCode(id); }
    @Override public String toString() { return "Key(" + id + ")"; }
}

public class Lost {
    public static void main(String[] args) {
        Map<MutableKey, String> map = new HashMap<>();
        MutableKey key = new MutableKey(1);
        map.put(key, "the value");

        System.out.println("before change:");
        System.out.println("  get         : " + map.get(key));
        System.out.println("  containsKey : " + map.containsKey(key));
        System.out.println("  size        : " + map.size());

        key.id = 99;                      // change a field used by hashCode

        System.out.println("after change:");
        // TODO: print the same three, plus try to remove it, plus iterate the map
    }
}
Hint 1
put computed the hash once, picked a bucket, and stored the entry there. Nothing goes back and moves it when you change the object.
Hint 2
remove computes the new hash and looks in the new bucket, which is empty. So the entry cannot be removed by key at all.
Hint 3almost the answer
Iterating with for (var e : map.entrySet()) walks all the buckets directly, without hashing anything. That is how you can see the entry that get cannot reach, which is the most convincing part of this exercise.
What this is really testing

Whether you can produce the worst bug in this phase on purpose. A map that counts an entry it cannot find is a genuinely disturbing thing to see, and seeing it once is what makes the immutability rule stick.

E

Registry Identity

Real work·35 min·30 points·The Registry

ex-8-3-e

Decide what makes two Registry units the same unit, then write the three methods to match.

This is a design decision, and it is the part Java cannot do for you. readiness changes every week. lastDeployed changes every mission. If either is part of identity, then a unit that gets deployed becomes a different unit, and your set will fill up with duplicates of the same thing.

Write your decision down as a comment and defend it. There is a conventional answer here, and it is the same reason a database has a primary key.

Then make the class immutable, and explain in one sentence how that turns the disappearing-value bug from something you have to remember to avoid into something that cannot happen.

What your program must do

  • Choose which fields define identity, and write a comment defending the choice
  • Implement toString, equals and hashCode consistently with that choice
  • Use a Unit as a HashMap key and show lookups work
  • Make the class immutable, and explain how that protects the map
RegistryIdentity.java
import java.util.*;

public class RegistryIdentity {
    // Give Unit: name, id, readiness, active, lastDeployed
    //
    // Decide: which fields make two Units the SAME unit?
    // Then write toString, equals and hashCode to match that decision.

    public static void main(String[] args) {
        // TODO: build units, put them in a HashSet and a HashMap
        // TODO: show that two units with the same identity collapse to one in the set
        // TODO: show that a unit works correctly as a map key
    }
}
Hint 1
readiness and lastDeployed change over a unit's life. If they are part of identity, then a unit that gets deployed becomes a different unit, which is almost certainly not what you mean.
Hint 2
id alone is usually the right answer for a registry, for the same reason a database uses a primary key. Two records with the same id are the same thing, whatever else has changed.
Hint 3almost the answer
Making the class immutable means the identity fields cannot change while the object is a map key, so the Section 8.3 disappearing-value bug is impossible by construction rather than by discipline.
What this is really testing

Whether you can decide what identity means for a real domain object rather than mechanically including every field. This is a design decision, not a mechanical one, and different answers are defensible.

08

After the credits

You just wrote equals() and hashCode(). Here is exactly what happens to them next.

In Phase X you will meet HashSet and HashMap properly. They will call your hashCode() to choose a bucket, then your equals() to confirm a match. Millions of times a second, from code written years before your class existed. Polymorphism from Section 7.2 is what makes that possible.

You will open the source of HashMap and find four things you already know. An array of buckets, from 4.2. hash & (n - 1) to pick one, from 3.1. >>> to mix high bits into low ones, from 2.2. And static class Node holding each entry, from 7.4.

That source is unreadable to most people. To you it will read like ordinary Java, because every piece of it arrived one section at a time.

And if your equals and hashCode disagree, HashMap will lose your data there. Silently. No exception, no warning, size() still counting an entry nobody can reach.

You already know that, because you just made it happen on purpose.

There is one more identity question left. equals answers “are these the same?”. It does not answer “which one comes first?”. Sorting needs an order, and that is Comparable, in Phase X. It has its own contract, and its own way of failing quietly when you break it.

Threads you opened in this section

The Object class will return in Phase X. The Collections Framework