Suppose you build two Point objects with the same coordinates and ask whether they are equal. Java's first answer is surprising: the == operator returns false. That is because == does not look at the numbers inside the objects — it compares the references, the memory addresses where each object lives. Two objects built separately live at two addresses, so == sees them as different even when they hold identical data.
For value objects — a point, a money amount, a user identifier — you almost never want identity. You want to know whether two objects mean the same thing. Java gives every object a method called equals for exactly that question, and learning to override it correctly is one of the most consequential skills in everyday Java. The reason is practical: the collections you will reach for constantly, such as HashSet and HashMap, rely on equals and its partner hashCode to decide whether two keys match.
flowchart LR A["Point a = new Point(1,2)"] B["Point b = new Point(1,2)"] A --> C["identity check<br/>a == b"] B --> C C --> D["result: false<br/>two separate objects"] A --> E["value check<br/>a.equals(b)"] B --> E E --> F["result: true<br/>both hold 1 and 2"]
The default equals is identity
The version of equals you inherit from Object behaves exactly like ==: it returns true only when both sides refer to the exact same object. That is safe but useless for comparing values. When you want two Point(1, 2) objects to count as equal, you override equals yourself.
A correct override follows a fixed shape. Handle the cheap cases first: an object equals itself, and it never equals null or something of a completely different type. Then compare the fields that define the value — for Point, that is x and y. The parameter is always typed Object, so you cast to your own type after confirming it with instanceof. Getting this shape right matters because every hash-based collection will eventually call your equals to decide whether two keys are the same.
public class Main {
static class Point {
int x, y;
Point(int x, int y) { this.x = x; this.y = y; }
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof Point)) return false;
Point p = (Point) o;
return x == p.x && y == p.y;
}
}
public static void main(String[] args) {
Point a = new Point(1, 2);
Point b = new Point(1, 2);
System.out.println(a == b);
System.out.println(a.equals(b));
}
}
The hashCode contract
Overriding equals is only half the job. The other half is a method called hashCode, and the rule binding them is strict: if a.equals(b) is true, then a.hashCode() must equal b.hashCode(). Equal objects must produce equal hash codes.
A hash code is an integer used to spread objects across buckets inside collections such as HashSet and HashMap. When you ask whether a set contains an object, it does not compare your object against every element. It computes the hash code, jumps straight to the matching bucket, and runs equals only on the few objects sitting there. If two equal objects had different hash codes, they would land in different buckets, and the set could never find one when handed the other — even though equals says they match. Notice the contract runs one way only: unequal objects are allowed to share a hash code, because buckets may hold several items and equals settles the rest.
flowchart TD A["rule: if a.equals(b)<br/>then hashCode must match"] A --> B["equal objects<br/>must share a bucket"] B --> C["Point(1,2) hashed<br/>into bucket A"] B --> D["a second Point(1,2)<br/>also lands in bucket A"] E["Point(9,9) has<br/>different field values"] --> F["may land in<br/>a different bucket"]
public class Main {
static class Point {
int x, y;
Point(int x, int y) { this.x = x; this.y = y; }
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof Point)) return false;
Point p = (Point) o;
return x == p.x && y == p.y;
}
public int hashCode() {
return 31 * x + y;
}
}
public static void main(String[] args) {
Point a = new Point(1, 2);
Point b = new Point(1, 2);
System.out.println(a.hashCode() == b.hashCode());
}
}
toString: what gets printed
Every object inherits a toString method that converts it to text. When you pass an object to System.out.println, or concatenate it onto a String with +, Java calls toString automatically. You do not have to call it yourself.
The default toString is barely useful: it returns the class name followed by @ and the identity hash code in hexadecimal, something like Point@1a2b3c4d. That tells you the type but nothing about the data inside. Overriding toString to return something readable — such as (1, 2) for a point, or $10.99 for a money amount — turns your objects into something you can actually read in a log or a debugger. It is the smallest method with the largest payoff for debugging: a good toString turns a wall of cryptic identifiers into meaningful output the moment something goes wrong.
flowchart LR Q["set.contains(new Point(1,2))"] --> S["step 1: hashCode<br/>chooses the bucket"] S --> T["step 2: equals()<br/>confirms a match"] T --> U["result: true"] Q --> V["wrong hashCode<br/>wrong bucket chosen"] V --> W["equals never runs<br/>result: false"]
public class Main {
static class Point {
int x, y;
Point(int x, int y) { this.x = x; this.y = y; }
public String toString() {
return "(" + x + ", " + y + ")";
}
}
public static void main(String[] args) {
Point p = new Point(1, 2);
System.out.println(p);
System.out.println("The point is " + p);
}
}
Value equality. Read each comparison and predict the exact four-line output.
public class Main {
static class Point {
int x, y;
Point(int x, int y) { this.x = x; this.y = y; }
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof Point)) return false;
Point p = (Point) o;
return x == p.x && y == p.y;
}
}
public static void main(String[] args) {
Point a = new Point(1, 2);
Point b = new Point(1, 2);
Point c = new Point(3, 4);
System.out.println(a == b);
System.out.println(a.equals(b));
System.out.println(a.equals(c));
System.out.println(a.equals("no"));
}
}
a and b are two separate objects, so == is false even though their fields match.
a.equals(b) compares the fields, so it is true.
A String can never be an instance of Point, so the last comparison is false.
Which statement is a genuine part of the contract between equals and hashCode?
The contract is one-directional: equal objects must share a hash code. Unequal objects are allowed to collide, because a bucket can hold several items and equals disambiguates them. Returning a constant is legal but ruins performance, and the two methods are tightly coupled.
You override equals but forget hashCode. You add new Tag("red") to a HashSet, then call set.contains(new Tag("red")). What is the most likely result and why?
HashSet uses hashCode to choose a bucket before it ever calls equals. Without an override, the two equal-by-value tags keep their identity hashCodes, which differ, so the lookup lands in the wrong bucket and equals is never consulted. The set still reports size 1 because the original entry is untouched.
The contract honoured. With equals and hashCode both overridden, predict the exact three-line output.
import java.util.HashSet;
public class Main {
static class Point {
int x, y;
Point(int x, int y) { this.x = x; this.y = y; }
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof Point)) return false;
Point p = (Point) o;
return x == p.x && y == p.y;
}
public int hashCode() {
return 31 * x + y;
}
}
public static void main(String[] args) {
HashSet<Point> set = new HashSet<>();
set.add(new Point(1, 2));
set.add(new Point(1, 2));
set.add(new Point(3, 4));
System.out.println(set.size());
System.out.println(set.contains(new Point(1, 2)));
System.out.println(set.contains(new Point(9, 9)));
}
}
The first two adds are equal-by-value points, so the set keeps only one of them.
Three distinct values are added but one is a duplicate, so size is 2.
contains(new Point(1,2)) shares the hash and equals a stored point; (9,9) matches nothing.
toString in action. toString is called by println and by string concatenation. Predict the exact three-line output.
public class Main {
static class Money {
int cents;
Money(int cents) { this.cents = cents; }
public String toString() {
return "$" + (cents / 100) + "." + String.format("%02d", cents % 100);
}
}
public static void main(String[] args) {
Money a = new Money(1099);
Money b = new Money(5);
System.out.println(a);
System.out.println(b);
System.out.println("Total: " + a + " and " + b);
}
}
1099 cents is 10 dollars and 99 cents, formatted with two decimal places.
5 cents is 0 dollars and 05 cents, so the leading zero appears.
The + operator on a String calls toString on each Money in turn.
Recap
==compares references;equalscompares values. Overrideequalsfor any value object.- A correct
equalshandles self, type, andnull, then compares the fields that define the value. - The contract: if
a.equals(b), thena.hashCode() == b.hashCode(). Equal objects must share a bucket. HashSetandHashMapfind a bucket withhashCode, then confirm withequals.- Overriding
equalswithouthashCodemakes equality look right while silently breaking every hash-based collection. - Override
toStringsoprintlnand string concatenation show readable values, notClass@hexhash.
Next you will organise many classes into packages and learn how import brings names into scope.