Java Module 5 – Classes, objects, records and enums
Java equals, hashCode and toString contracts
Write equals, hashCode and toString that keep the Object contracts, and watch a missing hashCode or a changed key break HashSet and HashMap.
What you will learn
- Implement equals and hashCode that keep the contract of Object
- Predict how an overloaded equals, a missing hashCode or a changed key makes HashSet and HashMap miss an equal object
- Explain why a subclass that adds a field cannot keep equals symmetric, and choose a fix
- Estimate what a constant hash code costs HashMap lookups from the number of keys
- Write a toString that names the value and keeps secrets out of logs
Before you start
What you’ll learn · Before you start
What you will learn
- Implement equals and hashCode that keep the contract of Object
- Predict how an overloaded equals, a missing hashCode or a changed key makes HashSet and HashMap miss an equal object
- Explain why a subclass that adds a field cannot keep equals symmetric, and choose a fix
- Estimate what a constant hash code costs HashMap lookups from the number of keys
- Write a toString that names the value and keeps secrets out of logs
Before you start
On this page
Two Point objects for (3, 4) describe the same point, yet with the methods every class inherits from Object a
HashSet keeps both of them, 2 elements, and a set that holds one says it does not contain the other. Those methods
treat every object as unique: equals is true only for the very same object, hashCode gives each object a number
of its own, and toString prints the class name, an @ and a hexadecimal number.
When your objects stand for values, such as a point, a seat on a train or an amount of money, you override equals
so that equal values compare equal, and then hashCode as well: the Object documentation makes equal
hash code: The int that hashCode returns for an object. A hash table uses it to pick a bucket, so objects that equals calls equal must return the same hash code; unequal objects may share one, which only slows the table down. for equal objects a contract, and HashSet and HashMap rely on it without checking. This lesson writes all three
methods, breaks each one in a recorded run, and counts what a poor hash code costs a HashMap: 501,499 calls to
equals for 1,000 lookups.
Object treats every object as unique, so value classes override equals
Object.equals returns true only when both references lead to the very same object. Its documentation calls this
the most discriminating equivalence relation: A relation that is reflexive (x equals x), symmetric (if x equals y, then y equals x) and transitive (if x equals y and y equals z, then x equals z). The Object documentation requires equals to be one. possible: every object is equal
only to itself. Object.hashCode
returns a number tied to the object’s identity, its identity hash code: The hash code Object.hashCode gives an object whose class does not override it, and System.identityHashCode gives any object: a number tied to that one object rather than to its field values.; as far
as is reasonably practical, distinct objects get distinct numbers. That is the right behaviour for objects with an
identity of their own, such as an open file or a running thread, and the wrong one for values.
Every example of this lesson is a small program in a folder of its own: a Main.java compact source file and the
classes it uses, each in a file of its own. java points/Main.java compiles Main.java and the classes it needs from
the same folder, then runs it; the launcher has run programs made of several source files since JDK 22 (JEP 458).
Here are three classes for a point, each tried the way a HashSet uses them. PlainPoint overrides nothing,
EqualsOnlyPoint overrides only equals, and Point overrides both methods:
Java (Main.java) · points/Main.java
// Two objects for the point (3, 4) from each class, checked the way a HashSet uses them.
void main() {
IO.println("class equals same hash set size contains");
report("PlainPoint", new PlainPoint(3, 4), new PlainPoint(3, 4));
report("EqualsOnlyPoint", new EqualsOnlyPoint(3, 4), new EqualsOnlyPoint(3, 4));
report("Point", new Point(3, 4), new Point(3, 4));
}
void report(String name, Object a, Object b) {
Set<Object> both = new HashSet<>();
both.add(a);
both.add(b); // a second element, unless the set decides that b equals a
Set<Object> onlyA = new HashSet<>();
onlyA.add(a);
IO.println("%-16s %-7s %-10s %-9d %s".formatted(
name, a.equals(b), a.hashCode() == b.hashCode(), both.size(), onlyA.contains(b)));
} Java (PlainPoint.java) · points/PlainPoint.java
/** A point that keeps what Object gives every class: equals means "the very same object". */
final class PlainPoint {
private final int x;
private final int y;
PlainPoint(int x, int y) {
this.x = x;
this.y = y;
}
} Java (EqualsOnlyPoint.java) · points/EqualsOnlyPoint.java
/** Overrides equals but not hashCode: equal points still get the hash codes of two different objects. */
final class EqualsOnlyPoint {
private final int x;
private final int y;
EqualsOnlyPoint(int x, int y) {
this.x = x;
this.y = y;
}
@Override
public boolean equals(Object other) {
return other instanceof EqualsOnlyPoint p && x == p.x && y == p.y;
}
} Java (Point.java) · points/Point.java
import java.util.Objects;
/** equals and hashCode, both computed from the same two fields. */
final class Point {
private final int x;
private final int y;
Point(int x, int y) {
this.x = x;
this.y = y;
}
@Override
public boolean equals(Object other) {
return other instanceof Point p && x == p.x && y == p.y;
}
@Override
public int hashCode() {
return Objects.hash(x, y);
}
} from session import run
# Main.java uses the three point classes, so the launcher compiles their files from points/ too.
# Then javac with its "overrides" lint, which warns about an equals without a hashCode.
run("""
java points/Main.java
javac -Xlint:overrides -d build points/EqualsOnlyPoint.java
""") Output
# openjdk version "25.0.4.1" LTS
$ java points/Main.java
class equals same hash set size contains
PlainPoint false false 2 false
EqualsOnlyPoint true false 2 false
Point true true 1 true
$ javac -Xlint:overrides -d build points/EqualsOnlyPoint.java
points/EqualsOnlyPoint.java:2: warning: [overrides] Class EqualsOnlyPoint overrides equals, but neither it nor any superclass overrides hashCode method
final class EqualsOnlyPoint {
^
1 warning
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 points_session.py
Read the table one row at a time:
- PlainPoint behaves as
Objectintends. The two objects are different, so the set keeps both and does not contain the second one. For a value that is wrong, but at least it is consistent. - EqualsOnlyPoint is the dangerous one.
equalsnow saystrue, yet the set still holds two elements andcontainsstill saysfalse. The set never askedequalsat all, because the two hash codes differ. - Point gives equal objects equal hash codes, so the set keeps one element and finds it.
The middle row is a broken contract, and running the program reports nothing: it compiles, runs and prints wrong
answers. The second command shows the one place that can warn you. Compiled by javac with the overrides lint
switched on (-Xlint:overrides, or every lint with -Xlint:all), EqualsOnlyPoint gets a warning that it
overrides equals but not hashCode. Build tools pass such options to javac, so it is worth turning on.
Checkpoint
Check: What Object's equals and hashCode compare
A hash table asks hashCode first and equals only inside one bucket
A HashSet is backed by a HashMap that keeps its elements as keys. To look a key up, the map calls hashCode on
it, uses that number to pick one bucket: One slot of a hash table's array. A HashMap picks a key's bucket from its hash code, keeps the keys that land in the same bucket together, and searches them with equals. More in “String hashing, interning and locale traps”, and compares the key only with the entries in
that bucket. Each entry keeps the hash code it was stored with, and the map calls
equals only on an entry whose stored hash code is the same:
A hash code picks the bucket; equals confirms the match
Text description of the diagram
The diagram follows the call set.contains(new Point(3, 4)) through three steps.
- The set calls hashCode on the argument. Objects.hash(3, 4) gives 1058.
- The hash code picks one bucket. Every other bucket is ignored. This bucket holds two entries: Point(3, 20), whose stored hash code is 1074, and Point(3, 4), whose stored hash code is 1058. In a table of 16 buckets both numbers lead to the same bucket.
- For Point(3, 20) the stored hash code 1074 is not 1058, so equals is not called at all (a dashed arrow). For Point(3, 4) the hash codes match, so equals runs, returns true, and contains returns true.
If two equal points had different hash codes, step 2 would pick a different bucket and step 3 would never see the equal point.
The diagram shows where the lookup goes. Step through the same call as it happens, one message at a time, and then
the same call for EqualsOnlyPoint:
The drawing shows the whole scene; its 11 steps are listed below.
Every step as text
- contains is called. main asks the set whether it holds new Point(3, 4), a second object equal to the point the set already holds.
- hashCode first. Before it compares anything, the set calls hashCode on the argument.
- 1058. Objects.hash(3, 4) starts from 1 and works out 31 × (31 × 1 + 3) + 4 = 1058.
- One bucket. The number picks one bucket. The set ignores every other bucket and looks only at the entries filed there.
- A stored hash that differs. The first entry there, Point(3, 20), was stored with its hash code 1074. 1074 is not 1058, so the set moves on without calling equals.
- A stored hash that matches. The next entry was stored with 1058, the same number. Only now does the set call equals, on the argument, with that entry.
- equals compares x and y. equals finds that x and y match and returns true.
- Found. contains returns true. The set made one equals call, and only on the entry whose stored hash code matched.
- Now EqualsOnlyPoint. The same call on a set of EqualsOnlyPoint, a class that overrides equals but not hashCode.
- An identity number. The set calls hashCode again, and the argument answers with the hash code it inherits from Object, a number tied to that one object. The recorded run shows that the two points get different ones.
- equals never runs. No entry in that bucket was stored with that number, so the set never calls equals, and contains returns false although equals would have said true.
The set skipped Point(3, 20) without calling equals, because its stored hash code was another number, and it
called equals once, on the argument, as the HashMap documentation describes get. For EqualsOnlyPoint it
never got that far. That design is why the documentation of Object.hashCode lists three rules:
- During one run of a program, calling
hashCodeon the same object must give the same number every time, as long as nothing thatequalslooks at has changed. The number may differ from one run to the next. - If two objects are equal according to
equals, their hash codes must be the same. - Two unequal objects may share a hash code, a hash collision: Two different keys with the same hash code, such as Aa and BB, which both hash to 2112. A hash table keeps both and tells them apart with equals; many collisions in one place make its lookups slow. More in “String hashing, interning and locale traps”. The contract allows it, but every shared hash code makes the table slower, as a later section measures.
EqualsOnlyPoint breaks rule 2. Its two equal points have the identity hash codes of two different objects, so the
lookup goes to the wrong bucket and never meets the equal point. The documentation of Object.equals says it
directly: whenever you override equals, you generally have to override hashCode too.
Point.hashCode returns Objects.hash(x, y), which hashes its arguments as if they were the elements of a list:
start from 1, then for each value multiply by 31 and add the value’s own hash code. For (3, 4) that is
31 × (31 × 1 + 3) + 4 = 1058, the number in the diagram. Two cautions come with it. Objects.hash with a single
argument does not return that argument’s hash code (the Javadoc warns about exactly this), so for one field write
Objects.hashCode(field) instead. And it creates an array and boxes primitive values on every call, which can matter
in code that hashes millions of keys; a hand-written 31 * x + y avoids both.
Key idea
A hash table compares hash codes before it ever calls equals, so objects that equals calls equal must return
the same hash code, or the table looks for them in the wrong place.
Checkpoint
Check: How a hash table uses hashCode and equals
equals takes an Object, and @Override proves it
Point.equals is one line: return other instanceof Point p && x == p.x && y == p.y;. Each part has a job.
The parameter type is Object, because that is the method HashSet, HashMap, List.contains and every other
library method call. A method equals(Point other) is a new, overloading: Declaring several methods with the same name and different parameter lists in one class. The compiler picks one of them for each call from the types of its arguments: an exact or widened match first, then boxing, then varargs. More in “Methods, parameters, return and overloading” method that
those libraries never use. The @Override annotation turns that mistake into a compile error, which is the reason
to write it every time:
import java.util.Objects;
final class Point {
private final int x;
private final int y;
Point(int x, int y) {
this.x = x;
this.y = y;
}
// Takes a Point, not an Object: this overloads equals instead of overriding it.
@Override
public boolean equals(Point other) {
return other != null && x == other.x && y == other.y;
}
@Override
public int hashCode() {
return Objects.hash(x, y);
}
} void main() {
Set<Point> points = new HashSet<>();
points.add(new Point(3, 4));
IO.println(points.contains(new Point(3, 4)));
} Output (exit status 1)
Printed as an error (standard error)
Point.java:13: error: method does not override or implement a method from a supertype
@Override
^
1 error
error: compilation failed
Recorded with OpenJDK 25.0.4.1 LTS (Eclipse Temurin) on macOS 26 arm64. To run it yourself: java Main.java
Without the annotation the same class compiles, and a HashSet of these points behaves like PlainPoint above.
other instanceof Point p tests the type and, when it matches, names the same object p with the type Point, a
type pattern: A pattern such as Point p in other instanceof Point p: the test is true when other is a Point that is not null, and then p names the same object with the type Point, so no cast is needed. Part of Java since JDK 16. (JEP 394, part of Java since JDK 16). The test is false when other
is null, so x.equals(null) returns false without a separate check, as the contract requires. It is also
false for any object that is not a Point, so comparing a point with a string is simply false.
Then compare the fields that make up the value, and only those:
| Field type | Compare with | Why |
|---|---|---|
int, long, char, boolean … |
== |
The values are exact |
double, float |
Double.compare(a, b) == 0, Float.compare |
== says NaN is not equal to itself, and 0.0 equals -0.0 |
An object that may be null |
Objects.equals(a, b) |
It handles null on either side, then calls equals |
| An array | Arrays.equals(a, b) |
An array’s own equals is the identity test of Object |
The Double documentation explains why its equals and compare treat every NaN as equal and the two zeros as
different: it keeps equals reflexive, so an object holding NaN still equals itself.
The documentation of Object.equals asks for five properties, which make it an equivalence relation. It must be
reflexive (x.equals(x) is true),
symmetric (x.equals(y) and y.equals(x) agree), transitive (if x equals y and y equals z, x equals z),
consistent (the answer only changes if the compared fields change) and x.equals(null) must be false. A
final class that compares the same fields on both sides keeps all five with little effort. Symmetry and
transitivity are the ones that break once a subclass joins in.
Writing equals(Point) instead of equals(Object)
public boolean equals(Point other) compiles, and no collection ever calls it: HashSet and HashMap call
equals(Object), which stays the identity test. Declare the parameter as Object, test it with a type pattern, and
write @Override so that javac refuses the overload.
Checkpoint
Check: Writing equals(Object) that keeps the contract
A subclass that adds a field breaks symmetry
A shop wrote Amount when it sold in one currency only: a sum of money in the smallest unit of that currency, so
5,000 for 50.00, compared by that number alone. The class is not final. When the shop started to sell in other
currencies, someone extended it: CurrencyAmount adds a currency code, and its equals accepts only other
CurrencyAmount objects and compares the code too:
Java (Amount.java) · symmetry/Amount.java
/** An amount of money in the smallest unit of its currency (paise, cents). Not final, so a subclass can add a field. */
class Amount {
final long units;
Amount(long units) {
this.units = units;
}
@Override
public boolean equals(Object other) {
return other instanceof Amount a && units == a.units;
}
@Override
public int hashCode() {
return Long.hashCode(units);
}
} Java (CurrencyAmount.java) · symmetry/CurrencyAmount.java
import java.util.Objects;
/** Adds a currency code and compares it, so it accepts only other CurrencyAmounts. */
class CurrencyAmount extends Amount {
final String currency;
CurrencyAmount(long units, String currency) {
super(units);
this.currency = Objects.requireNonNull(currency, "currency");
}
@Override
public boolean equals(Object other) {
return other instanceof CurrencyAmount c && super.equals(c) && currency.equals(c.currency);
}
@Override
public int hashCode() {
return Objects.hash(units, currency);
}
} // The same 5,000 units with and without a currency, compared both ways round.
void main() {
Amount plain = new Amount(5_000);
Amount usd = new CurrencyAmount(5_000, "USD");
IO.println("plain.equals(usd) = " + plain.equals(usd));
IO.println("usd.equals(plain) = " + usd.equals(plain));
IO.println("same hash code? " + (plain.hashCode() == usd.hashCode()));
// Each collection calls equals on a different side, so they disagree.
Set<Amount> setOfPlain = new HashSet<>(List.of(plain));
List<Amount> listOfUsd = new ArrayList<>(List.of(usd));
IO.println("HashSet [plain] contains usd: " + setOfPlain.contains(usd));
IO.println("ArrayList [usd] contains plain: " + listOfUsd.contains(plain));
} Output
plain.equals(usd) = true usd.equals(plain) = false same hash code? false HashSet [plain] contains usd: false ArrayList [usd] contains plain: true
Recorded with OpenJDK 25.0.4.1 LTS (Eclipse Temurin) on macOS 26 arm64. To run it yourself: java Main.java
The plain amount says it equals the 5,000 US cents, because a CurrencyAmount is an instance of Amount and the
units match. The dollar amount says the opposite. Collections inherit the disagreement: a HashSet holding plain
says it does not contain usd, while an ArrayList holding usd says it contains plain, because
ArrayList.contains calls equals on its argument. Which answer you get depends on which object happens to be on
the left. The third line shows a second break: plain.equals(usd) is true while the two hash codes differ, which
is rule 2 of the hash code contract broken, so the HashSet never even asks equals.
Letting CurrencyAmount accept plain amounts as well would only move the problem. Then $50.00 equals the plain
5,000, which equals €50.00, while the dollars and the euros differ: transitivity is gone, and the shop would call two
different prices equal. As long as equals tests with instanceof, a subclass that adds a field to a value class
has to give up one of the two properties. There are three ways out:
| Way out | plain and usd, both ways |
What it costs |
|---|---|---|
Make value classes final |
Cannot arise | No subclass can exist at all |
Compare classes with getClass() |
false both ways |
A subclass that adds no state never equals its parent |
Hold an Amount instead of extending it |
false both ways |
CurrencyAmount is no longer an Amount for code that wants one |
A final value class leaves nobody a way to subclass it, and records, three lessons on, are always final. Comparing
classes exactly with getClass() == other.getClass() makes an Amount and a CurrencyAmount unequal either way
round, at the price of a subclass that adds no state never equalling its parent. A CurrencyAmount that holds an
Amount and a currency code, instead of extending Amount, has its own equals and no relationship to break; the
module on inheritance comes back to that choice.
Checkpoint
Check: Symmetry when a subclass adds a field
A constant hash code keeps answers right and makes lookups slow
return 42; is a legal hashCode: equal objects get equal hash codes, so the contract holds and every answer is
right. This program counts how many times HashMap calls equals while it looks up every key once, for keys with a
spread-out hash code, keys that all return 42, and keys that return 42 but also implement Comparable:
// Counts the equals calls HashMap makes while it looks up every key once, for three hashCode methods.
static long equalsCalls = 0;
/** A spread-out hash code: every id gets its own. */
static final class SpreadKey {
final int id;
SpreadKey(int id) { this.id = id; }
@Override public boolean equals(Object o) { equalsCalls++; return o instanceof SpreadKey k && k.id == id; }
@Override public int hashCode() { return Integer.hashCode(id); }
}
/** Legal but useless: every key gets the same hash code. */
static final class ConstantKey {
final int id;
ConstantKey(int id) { this.id = id; }
@Override public boolean equals(Object o) { equalsCalls++; return o instanceof ConstantKey k && k.id == id; }
@Override public int hashCode() { return 42; }
}
/** The same constant hash code, but the keys can be put in order. */
static final class SortableConstantKey implements Comparable<SortableConstantKey> {
final int id;
SortableConstantKey(int id) { this.id = id; }
@Override public boolean equals(Object o) { equalsCalls++; return o instanceof SortableConstantKey k && k.id == id; }
@Override public int hashCode() { return 42; }
@Override public int compareTo(SortableConstantKey other) { return Integer.compare(id, other.id); }
}
long lookups(int n, IntFunction<Object> newKey) {
Map<Object, Integer> map = new HashMap<>();
for (int i = 0; i < n; i++) map.put(newKey.apply(i), i);
equalsCalls = 0;
for (int i = 0; i < n; i++) {
if (map.get(newKey.apply(i)) == null) throw new AssertionError("lost key " + i); // a new, equal key
}
return equalsCalls;
}
void main() {
IO.println("keys spread hash constant 42 constant 42 + Comparable");
for (int n : new int[] {1_000, 10_000}) {
IO.println("%-7d %-12d %-12d %d".formatted(n,
lookups(n, SpreadKey::new), lookups(n, ConstantKey::new), lookups(n, SortableConstantKey::new)));
}
} Output
keys spread hash constant 42 constant 42 + Comparable 1000 1000 501499 10405 10000 10000 50014999 138852
Recorded with OpenJDK 25.0.4.1 LTS (Eclipse Temurin) on macOS 26 arm64. To run it yourself: java Main.java
With a spread-out hash code, each lookup finds its key at the first entry it compares: 1,000 lookups, 1,000 calls. With a constant hash code every key lands in the same bucket. Since JDK 8 a bucket that grows past a small size turns from a list into a balanced tree (JEP 180), but a tree needs an order, and keys with equal hash codes give it none. So each lookup searches the bucket until it meets its key: 501,499 calls for 1,000 keys, about n² ÷ 2. Ten times the keys means a hundred times the work, 50,014,999 calls for 10,000 lookups.
The third column is the escape hatch the HashMap documentation mentions: when keys are Comparable, the map may
use their order to break ties. The tree is then searched like a sorted tree, about log₂ n comparisons per lookup,
and 10,000 lookups cost 138,852 calls instead of fifty million. That rescues a hash code that collides by accident,
but it is still about fourteen times the work of a spread-out hash, so the fix for return 42; is a real hash code.
Counting calls rather than timing them gives the same numbers on every computer, which is why the program does it.
Key idea
A constant hash code keeps every answer right and puts every key in one bucket, so the work of n lookups grows
like n² ÷ 2: 501,499 equals calls for 1,000 keys instead of 1,000.
Checkpoint
Check: What a constant hash code costs
A key that changes after it goes in is lost
A hash table remembers each entry under the hash code the key had when it went in. If a field that hashCode uses
changes afterwards, the entry sits in the bucket for the old number while the key now produces a new one. This
Seat can move to another coach:
import java.util.Objects;
/** A seat whose coach can be changed after it is created: the mistake this example shows. */
final class Seat {
private String coach;
private final int number;
Seat(String coach, int number) {
this.coach = coach;
this.number = number;
}
void moveTo(String newCoach) {
coach = newCoach;
}
@Override
public boolean equals(Object other) {
return other instanceof Seat s && number == s.number && coach.equals(s.coach);
}
@Override
public int hashCode() {
return Objects.hash(coach, number);
}
@Override
public String toString() {
return coach + "-" + number;
}
} void main() {
Map<Seat, String> bookings = new HashMap<>();
Seat seat = new Seat("S4", 23);
bookings.put(seat, "Asha");
IO.println("booked: " + bookings + ", filed under hash code " + seat.hashCode());
seat.moveTo("S5"); // changes a field that hashCode uses, while seat is a key
IO.println("map now prints: " + bookings + ", key's hash code now " + seat.hashCode());
IO.println("get(seat): " + bookings.get(seat));
IO.println("get(new Seat S5-23): " + bookings.get(new Seat("S5", 23)));
IO.println("get(new Seat S4-23): " + bookings.get(new Seat("S4", 23)));
IO.println("remove(seat): " + bookings.remove(seat) + ", size still " + bookings.size());
bookings.keySet().removeIf(key -> key == seat); // only a full scan by identity finds it
IO.println("after removeIf: size " + bookings.size());
} Output
booked: {S4-23=Asha}, filed under hash code 82359
map now prints: {S5-23=Asha}, key's hash code now 82390
get(seat): null
get(new Seat S5-23): null
get(new Seat S4-23): null
remove(seat): null, size still 1
after removeIf: size 0
Recorded with OpenJDK 25.0.4.1 LTS (Eclipse Temurin) on macOS 26 arm64. To run it yourself: java Main.java
The map was given the hash code 82359, the number of S4-23, and after the move the same key says 82390. Step through where each lookup goes:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- put files the entry. bookings.put(seat, "Asha") files the entry under the hash code the key has at that moment, 82359, as the first line of the run prints. The entry keeps that number.
- The key moves. seat.moveTo("S5") changes coach, a field that both equals and hashCode read. The key in the map is the same object, so it now says S5-23 and hashes to 82390. The entry stays where it was filed.
- get(seat). get(seat) works out 82390 and searches the bucket that number picks, where nothing was ever filed, so it returns null.
- A new S5-23. A new Seat for S5-23 hashes to 82390 too and searches the same empty bucket. null again.
- A new S4-23. A new Seat for S4-23 hashes to 82359 and finds the entry, stored with 82359. Then equals compares it with the key, which now says S5, and returns false, so get still returns null.
- Only a scan finds it. remove(seat) fails for the same reason as get(seat). Only removeIf over keySet(), which visits every key and compares each with == as the program asks, finds the entry and removes it.
Printing the map shows the booking under S5-23, yet every lookup fails. get(seat) and a new S5-23 compute the
new hash code and search the wrong bucket. A new S4-23 finds the right bucket and a matching stored hash code,
but equals now compares it with a key that says S5. remove fails for the same reasons, and only a scan of every
key, comparing by identity, gets rid of the entry. The documentation of Map warns that a map’s behaviour is not
specified at all when a key changes in a way that affects equals; Set says the same for its elements.
The rule that follows: the fields that equals and hashCode read must not change while the object is a key or a
set element. The reliable way to keep that rule is to make those fields final, which is where the next lesson,
on immutability, starts.
Checkpoint
Check: Keys that change after they go in
toString is written for people, and leaves secrets out
Without an override, toString returns the class name, an @ and the hash code in hexadecimal. That text names
the class and nothing else. The Object documentation recommends that every class override it with a concise text
that a person can read easily, and requires the result never to be null.
Java (Order.java) · tostring/Order.java
import java.util.List;
/** A toString written for whoever reads the log: what the order is, without the card number. */
final class Order {
private final String id;
private final List<String> items;
private final String cardNumber;
Order(String id, List<String> items, String cardNumber) {
this.id = id;
this.items = List.copyOf(items);
this.cardNumber = cardNumber;
}
@Override
public String toString() {
String last4 = cardNumber.substring(cardNumber.length() - 4);
return "Order[id=" + id + ", items=" + items + ", card=**** " + last4 + "]";
}
} Java (PlainOrder.java) · tostring/PlainOrder.java
/** No toString: printing it shows Object's default. */
final class PlainOrder {
private final String id;
PlainOrder(String id) {
this.id = id;
}
} void main() {
IO.println(new PlainOrder("A-1009"));
Order order = new Order("A-1009", List.of("kettle", "mug"), "1234567812345678");
IO.println(order); // println calls toString
IO.println("placed " + order); // so does string concatenation
IO.println(List.of(order)); // and a list, which calls it for each element
} Output
PlainOrder@1f0f1111 Order[id=A-1009, items=[kettle, mug], card=**** 5678] placed Order[id=A-1009, items=[kettle, mug], card=**** 5678] [Order[id=A-1009, items=[kettle, mug], card=**** 5678]]
This output changes from run to run: The hexadecimal number after PlainOrder@ is the object's identity hash code, which can differ from one run to the next.
Recorded with OpenJDK 25.0.4.1 LTS (Eclipse Temurin) on macOS 26 arm64. To run it yourself: java Main.java
println, string concatenation and a collection’s own toString all call your toString, so the method ends up in
logs, exception messages and debugger views whether you planned it or not. Three habits keep it useful:
- Name the class and the fields that identify the value, in one line.
Order[id=A-1009, items=[kettle, mug]]tells you which order and what is in it. - Leave out secrets. The OWASP Logging Cheat Sheet lists passwords, session identifiers, access tokens and payment
card data among the things that should not reach logs as they are.
Orderprints only the last four digits of the card, so logging an order does not leak the card number. - Treat it as text for people. The
Objectdocumentation says the output is not necessarily stable over time or across runs of the JVM, and a format meant for reading changes when someone improves it, so never parse it back into an object. When you need the identity form even though a class overridestoString,Objects.toIdentityString(object)(JDK 19 and later) returns it.
Records, three lessons on, generate all three methods from their fields, in the same shape as Order’s toString.
Checkpoint
Check: A toString for people and logs
Practise: a ticket that works as a key
Ticket stands for one seat on one train: a train number, a coach and a seat. Two tickets with the same three
values must count as the same ticket, in a HashSet and as a HashMap key. The starter class has the fields and a
constructor, and the sample checks use it the way a booking system would:
import java.util.Objects;
/** One seat on one train. Two tickets for the same train, coach and seat should count as the same ticket. */
final class Ticket {
private final int train;
private final String coach;
private final int seat;
Ticket(int train, String coach, int seat) {
this.train = train;
this.coach = Objects.requireNonNull(coach, "coach");
this.seat = seat;
}
// Your turn: override equals(Object), hashCode() and toString().
} // Sample checks for Ticket. Edit Ticket.java, then run: java ticket/TicketChecks.java
int passed = 0;
int total = 0;
void check(String what, boolean ok) {
total++;
if (ok) passed++;
IO.println((ok ? "pass " : "FAIL ") + what);
}
void main() {
Ticket a = new Ticket(12951, "B2", 34);
Ticket same = new Ticket(12951, "B2", 34);
check("equal tickets are equal, both ways", a.equals(same) && same.equals(a));
check("another seat is not equal", !a.equals(new Ticket(12951, "B2", 35)));
check("another coach is not equal", !a.equals(new Ticket(12951, "B3", 34)));
check("another train is not equal", !a.equals(new Ticket(12952, "B2", 34)));
check("equals(null) is false", !a.equals(null));
check("a String is never equal to a Ticket", !a.equals("12951 B2 34"));
check("equal tickets have equal hash codes", a.hashCode() == same.hashCode());
Set<Ticket> unique = new HashSet<>(List.of(a, same, new Ticket(12951, "B2", 35)));
check("a HashSet keeps one of two equal tickets", unique.size() == 2);
Map<Ticket, String> holders = new HashMap<>();
holders.put(a, "Asha");
check("a HashMap finds the holder by an equal ticket", "Asha".equals(holders.get(same)));
check("toString is Ticket[train=12951, coach=B2, seat=34]", a.toString().equals("Ticket[train=12951, coach=B2, seat=34]"));
IO.println(passed + " of " + total + " checks passed");
if (passed < total) System.exit(1);
} Output (exit status 1)
FAIL equal tickets are equal, both ways pass another seat is not equal pass another coach is not equal pass another train is not equal pass equals(null) is false pass a String is never equal to a Ticket FAIL equal tickets have equal hash codes FAIL a HashSet keeps one of two equal tickets FAIL a HashMap finds the holder by an equal ticket FAIL toString is Ticket[train=12951, coach=B2, seat=34] 5 of 10 checks passed
Recorded with OpenJDK 25.0.4.1 LTS (Eclipse Temurin) on macOS 26 arm64. To run it yourself: java TicketChecks.java
Edit Ticket.java until java ticket/TicketChecks.java prints 10 of 10 checks passed:
- Override
equals(Object)with@Override. Use a type pattern and compare all three fields: the numbers with==and the coach withequals. - Override
hashCodeso that it reads the same three fields, for example withObjects.hash. - Override
toStringto return exactlyTicket[train=12951, coach=B2, seat=34]for that ticket.
Then break it on purpose: change the parameter of equals to Ticket, keep the body working (with a null check),
and delete @Override. The checks that call equals directly still pass, because the compiler picks the overload,
but the HashSet and HashMap checks fail, as they did for EqualsOnlyPoint.
Then test yourself on the seven ideas, from what Object compares to toString. The idea you have answered worst
so far comes up first, and each choice explains itself as soon as you pick it:
Practice that adapts to you
Practise this lesson
16 questions about 7 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
Fresher · What is the contract between equals and hashCode in Java?
Equal objects must have equal hash codes: if a.equals(b) is true, a.hashCode() == b.hashCode() must be true
too. The reverse is not required, so unequal objects may share a hash code, but every collision makes hash tables
slower. A hash code must also stay the same during a run as long as the fields that equals compares do not
change. The reason is how HashMap and HashSet work: they use the hash code to pick a bucket and call equals
only inside it, so an object whose equal twin has another hash code is never found. That is why overriding equals
alone is a bug, and why both methods should read exactly the same fields. A good answer adds that fields used by
both should be final, because a key whose hash code changes after insertion is lost in the map.
Two longer questions of this module start from this lesson: a set of orders that keeps growing, and a cache whose
key class returns 42 from hashCode.
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Mid to senior
A service keeps Order objects in a HashSet to drop duplicates. The set keeps growing, and contains() returns false for orders you can see in it when you print the set. What could cause this, and how do you find out which cause it is?
-
Mid to senior
A key class returns 42 from hashCode "to be safe". Every answer is still right, but a cache keyed by it got slower and slower as it grew. Explain why, estimate the work for 10,000 lookups, and say what changes if the key class implements Comparable.
Key takeaways
Objecttreats every object as unique; a value class overridesequalsso that equal values compare equal, andhashCodewith it, because hash tables compare hash codes before they ever callequals.- Override
equals(Object)with@Override, a type pattern and the same fields ashashCode: anequals(Point)overload compiles silently, and no collection ever calls it. - Compare integer fields with
==, floating-point ones withDouble.compare, objects withObjects.equalsand arrays withArrays.equals; a non-final class whose subclass adds a field cannot keepequalssymmetric. - A constant hash code keeps every answer right and makes lookups slow: 1,000 lookups cost 501,499
equalscalls instead of 1,000. - A key whose hash code changes after it goes into a map or a set is lost, so the fields that
equalsandhashCoderead should be final. - Write
toStringfor the person reading a log: the class and its identifying fields, never a password or a full card number, and never parse it back.
Terms in this lesson
- equivalence relation
- A relation that is reflexive (x equals x), symmetric (if x equals y, then y equals x) and transitive (if x equals y and y equals z, then x equals z). The Object documentation requires equals to be one.
- hash code
- The int that hashCode returns for an object. A hash table uses it to pick a bucket, so objects that equals calls equal must return the same hash code; unequal objects may share one, which only slows the table down.
- identity hash code
- The hash code Object.hashCode gives an object whose class does not override it, and System.identityHashCode gives any object: a number tied to that one object rather than to its field values.
- type pattern
- A pattern such as Point p in other instanceof Point p: the test is true when other is a Point that is not null, and then p names the same object with the type Point, so no cast is needed. Part of Java since JDK 16.
References
- Class Object: equals, hashCode and toString (Java SE 25) (Oracle)
- Class Objects: equals, hash, hashCode and toIdentityString (Java SE 25) (Oracle)
- Interface List: contains and hashCode (Java SE 25) (Oracle)
- Class HashSet (Java SE 25) (Oracle)
- Class HashMap (Java SE 25) (Oracle)
- Interface Map: mutable objects as keys (Java SE 25) (Oracle)
- Class Double: floating-point equality, equivalence and comparison (Java SE 25) (Oracle)
- Annotation Interface Override (Java SE 25) (Oracle)
- The Java Language Specification, Java SE 25: 15.20.2 The instanceof Operator (Oracle)
- JEP 394: Pattern Matching for instanceof (OpenJDK)
- The javac Command (Java SE 25): -Xlint keys (Oracle)
- JEP 458: Launch Multi-File Source-Code Programs (OpenJDK)
- JEP 180: Handle Frequent HashMap Collisions with Balanced Trees (OpenJDK)
- Logging Cheat Sheet: data to exclude (OWASP Foundation)
Report a problem with this lesson
Kept only in this browser. Your Learn progress