The Object Class — equals, hashCode, toString
Every Java class secretly inherits from Object. These three methods determine whether your objects work correctly in collections, comparisons, and logs.
The class you didn't know you extended
Every class in Java — every single one — extends java.lang.Object, whether you wrote extends Object or not. It's the implicit root of every class hierarchy. When you write:
public class User { ... }
what the compiler actually compiles is:
public class User extends Object { ... }
Object provides a handful of methods that every Java object inherits: equals(), hashCode(), toString(), getClass(), notify(), notifyAll(), wait(), finalize() (deprecated since Java 9), and clone() (rarely used in modern code).
The first three — equals(), hashCode(), toString() — are the ones you'll override constantly. Get them right and your objects work correctly in collections, comparisons, and logs. Get them wrong and you'll have bugs that look magical: data goes into a HashMap, but get() returns null. A HashSet contains an "equal" object but contains() says false. Logs show User@1a2b3c4d instead of useful data.
This chapter walks through all three, the rules, the contracts, and the modern shortcuts.
Default behaviour — and why it's almost never what you want
Without overrides, Object's default implementations are:
**equals(Object other)** — returns true only if this == other. Reference equality, not content equality. Two different User objects with identical names and ages are NOT equal by default.
**hashCode()** — returns an int derived from the object's memory address (well, an opaque identity hash). Two different instances always have different hash codes (almost always).
**toString()** — returns something like com.example.User@1a2b3c4d — the class name plus the hex representation of the identity hash.
Try this:
public class User {
String name;
int age;
}
User a = new User();
a.name = "Alice"; a.age = 30;
User b = new User();
b.name = "Alice"; b.age = 30;
System.out.println(a.equals(b)); // false — different memory addresses
System.out.println(a.hashCode()); // some int like 1234567890
System.out.println(b.hashCode()); // different int
System.out.println(a); // "com.example.User@499f6791"
For data classes — where equality should be based on field values — the default is wrong. Every time you put a User in a HashMap as a key, the default behaviour bites: two "equal" Users hash to different buckets, the map can't find one given the other.
equals() — the rules of comparison
When you override equals(), you're defining what it means for two objects of your class to be equal. The Java contract says your implementation must be:
**Reflexive.** x.equals(x) must always return true. (Trivial; hard to break.)
**Symmetric.** If x.equals(y) is true, then y.equals(x) must also be true. (Easy to break with mixed types.)
**Transitive.** If x.equals(y) and y.equals(z) both true, then x.equals(z) must be true.
**Consistent.** Repeated calls return the same result, provided no fields used in the comparison have changed.
**Null-safe.** x.equals(null) must return false (never throw NPE).
A correct equals() looks like:
public class User {
private String name;
private int age;
@Override
public boolean equals(Object obj) {
if (this == obj) return true; // fast path: same reference
if (!(obj instanceof User other)) return false; // pattern matching (Java 16+)
return age == other.age && Objects.equals(name, other.name);
}
}
Walk through that:
this == obj— same reference is definitely equal. Fast path.instanceof User other— checks the type AND null in one operation. The "pattern matching for instanceof" syntax (User otherafter the type) declares a variable that's bound only if the cast succeeds. Java 16+.- Compare the relevant fields.
Objects.equals(a, b)is null-safe (returns true if both are null, false if one is null, otherwisea.equals(b)).
**Pre-Java 16 version:**
@Override
public boolean equals(Object obj) {
if (this == obj) return true;
if (!(obj instanceof User)) return false;
User other = (User) obj;
return age == other.age && Objects.equals(name, other.name);
}
Same logic, more lines. Pattern matching is just syntactic sugar.
**Don't use getClass() for type checking** — it breaks symmetry with subclasses. instanceof is the standard choice unless you have a very specific reason to require exact type match.
hashCode() — and the contract that breaks everything
The hashCode contract is short and absolute:
**If a.equals(b) is true, then a.hashCode() == b.hashCode() must be true.**
The converse isn't required — two unequal objects can have the same hash code (that's called a collision). But equal objects must hash to the same int. Violate this and every hash-based collection misbehaves silently.
What does "silently" mean? Look at this scenario:
public class User {
String name;
int age;
@Override
public boolean equals(Object obj) {
// ... correct content-based equals
}
// hashCode NOT overridden — uses Object's default
}
Set<User> users = new HashSet<>();
users.add(new User("Alice", 30));
boolean found = users.contains(new User("Alice", 30));
System.out.println(found); // false!
The two User objects ARE equal by content. But HashSet uses hashCode() to figure out which internal bucket to look in. The two objects have different identity hashes, so they go to different buckets. contains() looks in the wrong bucket, doesn't find anything, returns false.
Same trap with HashMap. Put a User in as a key, try to look it up with another "equal" User — null. The data is there; you just can't find it.
A correct hashCode():
@Override
public int hashCode() {
return Objects.hash(name, age);
}
Objects.hash(...) is a varargs helper that combines hash codes of all the fields you pass it. It does the right thing — uses each field's hashCode(), combines them in a way that avoids trivial collisions, handles nulls.
**The cardinal rule: whenever you override equals(), you MUST override hashCode(). Always together.** Most IDEs offer "Generate equals() and hashCode()" as a single menu item for exactly this reason.
toString() — the method you'll appreciate at 3 AM
toString() is implicitly called whenever an object appears in:
- String concatenation: "User: " + user
- System.out.println(user)
- A log statement: log.info("Processing {}", user)
The default Object.toString() produces com.example.User@499f6791 — useless for debugging.
Override it to produce something readable:
@Override
public String toString() {
return "User{name='" + name + "', age=" + age + "}";
}
Now log.info("Processing {}", user) produces Processing User{name='Alice', age=30} — actually informative.
**Sensible patterns:**
- Include the class name (so logs distinguish a
Userfrom aCustomer). - Include the fields most useful for identification — usually IDs and the human-readable name.
- For sensitive fields (passwords, tokens, credit card numbers), do NOT include them. Or include a placeholder:
"password='***'". - Keep it concise. A toString that's 500 chars is unreadable in a log.
**A nice helper:** Lombok's @ToString annotation or Java 14+ records both generate sensible toString() for you. If you use Lombok:
@ToString
public class User {
private String name;
private int age;
@ToString.Exclude
private String password; // skip in toString
}
And if you use records:
public record User(String name, int age) {}
// toString() generated: User[name=Alice, age=30]
The record version is fine for data-only classes; the Lombok version is more flexible if you need control over what's included.
Records — when you don't have to write any of this
Records (Java 16+) generate equals(), hashCode(), and toString() automatically based on the component fields. This is often what you actually want.
public record User(String name, int age) {}
That single line gives you:
- Private final fields name and age
- A public constructor User(String name, int age)
- Accessor methods name() and age() (no get prefix)
- Correct equals() based on field values
- Correct hashCode() based on field values
- Sensible toString() like User[name=Alice, age=30]
You can still customise:
public record User(String name, int age) {
// Custom compact constructor — validation only, no boilerplate
public User {
if (age < 0) throw new IllegalArgumentException("age must be ≥ 0");
name = name.trim(); // allowed: reassign params before fields are set
}
// Custom methods on top of the auto-generated ones
public boolean isAdult() {
return age >= 18;
}
}
**When to use records:**
- Pure data carriers — DTOs, value objects, configuration holders.
- Anything where the identity is "these values."
- API responses, event payloads, immutable model objects.
**When NOT to use records:**
- Mutable state — records are immutable by design.
- Inheritance from a class (records can't extend other classes, only implement interfaces).
- Identity matters more than value (e.g., a User in a database, where two different users with the same name are NOT the same user).
For 80% of "I just need a data class," records are now the right tool. The remaining 20% (mutable entities, persistent objects, anything with rich behaviour) still uses regular classes with explicit equals/hashCode/toString.
Common mistakes
The most-repeated bugs in this corner of Java.
**1. Overriding equals without hashCode (or vice versa).** Most common bug. Use your IDE's "Generate equals() and hashCode()" to do them together.
**2. Using getClass() instead of instanceof.**
if (getClass() != obj.getClass()) return false;
This forbids subclasses from being equal to their parents. Sometimes desired, but it breaks the symmetry contract in subtle ways. instanceof is the conventional choice.
**3. Including mutable fields in equals/hashCode.**
If a field used in hashCode() changes after the object is added to a HashSet, the object is "lost" in the set — it's stored in the bucket for the OLD hash code, but contains() looks in the bucket for the NEW hash code. Either use only immutable fields in equals/hashCode, or don't mutate objects that are in hash-based collections.
**4. Comparing with == in equals().**
@Override
public boolean equals(Object obj) {
if (this == obj) return true;
if (!(obj instanceof User other)) return false;
return name == other.name; // BUG — compares references, not content
}
Use Objects.equals(name, other.name) (null-safe) or name.equals(other.name) (NPE if name is null).
**5. NullPointerException in toString.**
@Override
public String toString() {
return "User{name=" + name.toUpperCase() + "}"; // NPE if name is null
}
Be defensive in toString — it's often called during error handling, when state is suspect. Use Objects.toString(name, "<null>") if you want.
**6. Inconsistent equals — comparing some fields but using all of them for hash.**
Whatever fields you use in equals, use the same ones in hashCode. Generating both with the IDE prevents this.
**7. Forgetting equals/hashCode entirely on database entities.**
If you use an ORM (Hibernate, JPA), getting equals/hashCode right on entities is tricky. The conventional advice is: use the natural business key (if there is one), or use the database-generated ID — but be careful, the ID is null before the entity is persisted. Many teams skip equals/hashCode on entities and use identity equality. There's no universally right answer here.
The IDE shortcut and Lombok
No serious Java developer writes equals/hashCode/toString by hand in real code. The patterns are mechanical; the bugs are subtle. Use tools.
**IntelliJ IDEA / Eclipse:** Right-click → Generate → equals() and hashCode(). The IDE asks you which fields to include, then writes correct boilerplate. Same for toString.
**Lombok:** A library that generates the boilerplate via annotations.
@Data
public class User {
private String name;
private int age;
}
@Data is shorthand for @Getter, @Setter, @EqualsAndHashCode, @ToString, and a @RequiredArgsConstructor. The Lombok compiler plugin generates all the methods at compile time. Your .class file has them; your source file doesn't.
@EqualsAndHashCode(of = {"id"}) // use only the `id` field for equality
public class Entity {
private Long id;
private String volatileField;
}
Lombok adds compile-time dependency on the Lombok plugin, which some teams find too magical. Others love it. Pick a convention for your team.
**Records:** When applicable, records eliminate the need for tools — the language itself generates these methods.
The honest 2026 advice:
- Use records for pure data classes.
- Use Lombok or your IDE for classes that aren't records.
- Almost never write these methods by hand.
The point isn't to memorise the patterns. The point is to know what's being generated, why it has to be correct, and which fields belong in equals/hashCode vs which don't.
⁂ Back to all modules