Java Fundamentals Recap
Object Class, equals & hashCode, HashMap Internals, and Type Casting
Advanced Object-Oriented Programming — Class Notes
Sitare University
1. The Object Class — The Root of Everything
In Java, every class implicitly extends [Link]. This means every object you create —
whether it’s a String, an ArrayList, or your own custom Student class — inherits a set of
methods from Object. Understanding these methods is crucial because they define how objects
behave in collections, comparisons, and debugging.
1.1 Key Methods in the Object Class
The Object class provides several methods. The three most important ones for day-to-day
programming are:
Method Signature Purpose
toString() public String toString() Returns a string representation of the object.
Used by [Link]().
equals() public boolean Checks logical equality between two objects
equals(Object obj) (content comparison).
hashCode() public int hashCode() Returns an integer hash value. Used by
hash-based collections like HashMap and
HashSet.
1.2 Default Behavior of toString()
By default, toString() returns the class name followed by @ and the hexadecimal hash code.
This is almost never useful for debugging. Overriding it gives you meaningful output.
[Link]
public class Student {
private String name;
private int rollNumber;
public Student(String name, int rollNumber) {
[Link] = name;
[Link] = rollNumber;
}
// Without overriding toString(), printing a Student gives:
// Student@1b6d3586 (not helpful at all!)
public static void main(String[] args) {
Student s = new Student("Alice", 101);
[Link](s); // Output: Student@1b6d3586
}
}
[Link]
public class Student {
private String name;
private int rollNumber;
public Student(String name, int rollNumber) {
[Link] = name;
[Link] = rollNumber;
}
// Override toString() for a human-readable representation
@Override
public String toString() {
return "Student{name='" + name + "', rollNumber=" + rollNumber + "}";
}
public static void main(String[] args) {
Student s = new Student("Alice", 101);
[Link](s); // Output: Student{name='Alice', rollNumber=101}
// Much more useful for debugging!
}
}
✔ Best Practice
Always override toString() in your classes. It makes debugging dramatically easier, especially when
you have objects inside collections.
2. Reference Equality (==) vs Logical Equality (equals)
This is one of the most common sources of bugs for beginners. The == operator and the
equals() method do fundamentally different things.
2.1 What == Actually Does
The == operator compares references, not content. It checks whether two variables point to the
exact same object in memory. Think of it as asking: “Are these two variables holding the same
memory address?”
[Link]
public class ReferenceVsEquals {
public static void main(String[] args) {
// Create two Student objects with IDENTICAL data
Student s1 = new Student("Alice", 101);
Student s2 = new Student("Alice", 101);
// == checks: do s1 and s2 point to the SAME object?
[Link](s1 == s2); // false!
// They are two separate objects in memory, even though
// they have the same name and roll number.
// Now make s3 point to the SAME object as s1
Student s3 = s1;
[Link](s1 == s3); // true!
// s1 and s3 refer to the exact same object in heap memory.
// Visual representation of memory:
// s1 ----> [ Student: Alice, 101 ] <---- s3
// s2 ----> [ Student: Alice, 101 ] (different object!)
}
}
2.2 Why We Need equals()
In real applications, we almost always care about logical equality — whether two objects
represent the same entity based on their data. For example, two Student objects with the same
roll number should be considered equal, even if they are separate objects in memory.
The default equals() method inherited from Object behaves exactly like == — it only checks
reference equality. So you must override it to get content-based comparison.
[Link]
// Without overriding equals():
Student s1 = new Student("Alice", 101);
Student s2 = new Student("Alice", 101);
[Link]([Link](s2)); // false!
// Default equals() from Object class just does: this == obj
// So it's the same as s1 == s2, which is reference comparison.
⚠ Common Gotcha with Strings
Strings in Java can be tricky. String literals are interned (cached), so "hello" == "hello" returns
true. But new String("hello") == "hello" returns false. Always use .equals() for String
comparison.
3. Type Casting in Java
Before we can write a proper equals() method, we need to understand type casting — the
mechanism that lets us convert an object from one type to another.
3.1 Upcasting (Implicit)
Upcasting is converting a subclass reference to a superclass reference. It happens
automatically because every Student IS-A Object. No explicit cast needed.
[Link]
Student s = new Student("Alice", 101);
// Upcasting: Student -> Object (implicit, always safe)
Object obj = s; // No cast required
// obj now refers to the same Student, but through an Object
// reference. We can only call Object's methods on obj.
[Link]([Link]()); // Works (Object has toString)
// [Link](); // Compile error! Object doesn't have getRollNumber
3.2 Downcasting (Explicit)
Downcasting is converting a superclass reference back to a subclass reference. This requires
an explicit cast and is potentially unsafe — if the object isn’t actually of the target type, you get
a ClassCastException at runtime.
[Link]
Object obj = new Student("Alice", 101); // Upcasted
// Downcasting: Object -> Student (explicit cast required)
Student s = (Student) obj; // Works! obj actually IS a Student
[Link]([Link]()); // Now we can access Student methods
// DANGEROUS: Casting to the wrong type
Object obj2 = new String("Hello");
// Student s2 = (Student) obj2; // ClassCastException at runtime!
// obj2 is a String, not a Student. The compiler allows it,
// but the JVM catches the error when the code actually runs.
3.3 The instanceof Operator
To safely downcast, always check the type first using instanceof. This returns true if the object
is an instance of the specified class (or a subclass of it).
[Link]
Object obj = new Student("Alice", 101);
// Safe downcasting pattern
if (obj instanceof Student) {
Student s = (Student) obj; // Safe: we've confirmed the type
[Link]([Link]());
}
// Java 16+ pattern matching (cleaner syntax)
if (obj instanceof Student s) {
// s is automatically cast and available here
[Link]([Link]());
}
3.4 Why Casting Matters in equals()
The equals() method signature is equals(Object obj). The parameter is of type Object, not your
specific class. So inside the method, you must downcast the parameter to your class type
before you can compare fields. This is where instanceof and casting become essential.
4. Overriding equals() — The Right Way
Writing a correct equals() method requires careful attention. Here is the standard pattern with
detailed comments explaining every step.
4.1 The Complete equals() Implementation
[Link]
public class Student {
private String name;
private int rollNumber;
public Student(String name, int rollNumber) {
[Link] = name;
[Link] = rollNumber;
}
@Override
public boolean equals(Object obj) {
// Step 1: Check if comparing with itself (same reference)
// This is a performance optimization - avoids unnecessary work
if (this == obj) return true;
// Step 2: Check for null - no object equals null
if (obj == null) return false;
// Step 3: Check if obj is the same type as this class
// We can't compare a Student with a String!
if (!(obj instanceof Student)) return false;
// Step 4: DOWNCAST obj from Object to Student
// This is safe because we already checked instanceof above
Student other = (Student) obj;
// Step 5: Compare the fields that define equality
// For rollNumber (primitive int): use ==
// For name (object String): use [Link]() to handle null safely
return [Link] == [Link]
&& [Link]([Link], [Link]);
}
}
4.2 The equals() Contract
The equals() method must satisfy these properties (from the Java documentation):
Property Description
Reflexive [Link](x) must return true.
Symmetric If [Link](y) is true, then [Link](x) must also be true.
Transitive If [Link](y) and [Link](z), then [Link](z) must be true.
Consistent Multiple calls to [Link](y) must return the same result if x and y haven't
changed.
Non-null [Link](null) must return false for any non-null x.
5. Understanding hashCode()
The hashCode() method returns an integer value that is used by hash-based data structures
(HashMap, HashSet, Hashtable) to quickly locate objects. Think of it as an index that tells the data
structure which “bucket” to look in.
5.1 The hashCode Contract
The critical rule: If two objects are equal according to equals(), they MUST have the same
hashCode(). Violating this rule breaks HashMap and HashSet.
Note: Two unequal objects CAN have the same hash code (this is called a “collision”). The hash
code is just a quick filter — equals() is the final check for actual equality.
⚠ The Golden Rule
If you override equals(), you MUST also override hashCode(). If you forget, hash-based collections
will not work correctly with your objects.
5.2 How [Link]() Works Internally
Java provides [Link](Object... values) as a convenient way to compute hash codes.
Internally, it delegates to [Link](), which uses a polynomial hash function.
[Link]
// What [Link]() does internally (simplified):
// It calls [Link](Object[] a) which does:
//
// int result = 1;
// for (Object element : a) {
// result = 31 * result + (element == null ? 0 : [Link]());
// }
// return result;
//
// The multiplier 31 is chosen because:
// 1. It's an odd prime, which reduces collisions
// 2. 31 * x can be optimized by the JVM to (x << 5) - x (bit shift)
// 3. It provides a good distribution of hash values
import [Link];
public class Student {
private String name;
private int rollNumber;
// ... constructor and equals() ...
@Override
public int hashCode() {
// [Link]() internally computes:
// result = 1
// result = 31 * 1 + [Link]() = 31 + [Link]()
// result = 31 * prev + rollNumber
return [Link](name, rollNumber);
}
// The above is equivalent to manually writing:
// @Override
// public int hashCode() {
// int result = 1;
// result = 31 * result + (name != null ? [Link]() : 0);
// result = 31 * result + rollNumber;
// return result;
// }
}
5.3 Good vs Bad Hash Functions
A Good Hash Function
A good hash function distributes objects evenly across buckets, minimizing collisions. It should
use all fields that are part of the equals() comparison.
[Link]
// GOOD hash function: uses all fields from equals()
@Override
public int hashCode() {
return [Link](name, rollNumber);
}
// Example hash values for different students:
// Student("Alice", 101) -> hashCode = 63462512 (bucket 512)
// Student("Bob", 102) -> hashCode = 78231445 (bucket 445)
// Student("Charlie", 103)-> hashCode = 94120331 (bucket 331)
// Each student goes to a different bucket -> O(1) lookup!
A Terrible Hash Function
Returning a constant value is the worst possible hash function. It’s technically legal (equal
objects will have the same hash code), but it destroys all performance benefits of hashing.
[Link]
// TERRIBLE hash function: returns same value for ALL objects
@Override
public int hashCode() {
return 42; // Every single object gets hash code 42
}
// What happens:
// Student("Alice", 101) -> hashCode = 42 (bucket 42)
// Student("Bob", 102) -> hashCode = 42 (bucket 42)
// Student("Charlie", 103)-> hashCode = 42 (bucket 42)
// ALL students go to the SAME bucket!
// HashMap lookup degrades from O(1) to O(n) - like a linked list.
// With 10,000 students, every lookup checks all 10,000 entries!
6. How HashMap Uses equals() and hashCode()
Understanding how HashMap works internally is key to understanding why the equals() and
hashCode() contract matters.
6.1 HashMap Internal Structure
A HashMap is essentially an array of “buckets.” When you call [Link](key, value), two things
happen in sequence:
Step 1: The HashMap calls [Link]() and uses modular arithmetic to determine which
bucket to place the entry in. For example: bucketIndex = hashCode % numberOfBuckets.
Step 2: If the bucket already contains entries (a collision), HashMap walks through the entries in
that bucket and calls [Link](existingKey) on each one to check if the key already exists. If
a match is found, the value is updated. Otherwise, a new entry is added to the bucket.
When you call [Link](key), the same two-step process is repeated: first find the bucket via
hashCode(), then find the exact entry via equals().
[Link] (Pseudocode)
// Simplified view of what [Link]() does internally:
public V put(K key, V value) {
// Step 1: Find the bucket using hashCode()
int hash = [Link]();
int bucketIndex = hash % [Link];
// Step 2: Search within the bucket using equals()
for (Entry entry : buckets[bucketIndex]) {
if ([Link]() == hash && [Link](key)) {
// Key already exists -> update value
[Link] = value;
return;
}
}
// Key not found in bucket -> add new entry
buckets[bucketIndex].add(new Entry(key, value));
}
6.2 What Breaks When the Contract is Violated
Here’s a complete example showing exactly how HashMap fails when equal objects have
different hash codes.
[Link]
import [Link];
import [Link];
class BrokenStudent {
String name;
int rollNumber;
BrokenStudent(String name, int rollNumber) {
[Link] = name;
[Link] = rollNumber;
}
@Override
public boolean equals(Object obj) {
if (this == obj) return true;
if (!(obj instanceof BrokenStudent)) return false;
BrokenStudent other = (BrokenStudent) obj;
return rollNumber == [Link]
&& [Link](name, [Link]);
}
// BUG: We override equals() but DO NOT override hashCode()!
// Default hashCode() from Object uses memory address,
// so two equal objects will have DIFFERENT hash codes.
}
public class BrokenHashMapDemo {
public static void main(String[] args) {
HashMap<BrokenStudent, String> grades = new HashMap<>();
// Create two Student objects with identical data
BrokenStudent s1 = new BrokenStudent("Alice", 101);
BrokenStudent s2 = new BrokenStudent("Alice", 101);
// They are equal according to equals():
[Link]([Link](s2)); // true
// But they have different hashCodes!
[Link]([Link]()); // e.g., 12345678
[Link]([Link]()); // e.g., 87654321
// Put a grade using s1 as the key
[Link](s1, "A+");
// Try to retrieve using s2 (which is .equals() to s1)
String grade = [Link](s2); // null !!
// WHY? Because:
// 1. [Link](s1, "A+") -> hashCode=12345678 -> bucket #78
// Entry stored in bucket #78
// 2. [Link](s2) -> hashCode=87654321 -> bucket #21
// HashMap looks in bucket #21, which is EMPTY!
// It never even checks bucket #78 where the entry is.
// So it returns null, even though [Link](s2) is true.
[Link](grade); // null (WRONG! Should be "A+")
// The map now also contains "duplicates" that shouldn't exist:
[Link](s2, "B");
[Link]([Link]()); // 2 (should be 1!)
// Two entries for "Alice, 101" - one with A+, one with B
}
}
6.3 The Fixed Version
[Link]
import [Link];
import [Link];
class CorrectStudent {
String name;
int rollNumber;
CorrectStudent(String name, int rollNumber) {
[Link] = name;
[Link] = rollNumber;
}
@Override
public boolean equals(Object obj) {
if (this == obj) return true;
if (!(obj instanceof CorrectStudent)) return false;
CorrectStudent other = (CorrectStudent) obj;
return rollNumber == [Link]
&& [Link](name, [Link]);
}
@Override
public int hashCode() {
// CORRECT: Use the same fields as equals()
return [Link](name, rollNumber);
}
}
public class FixedHashMapDemo {
public static void main(String[] args) {
HashMap<CorrectStudent, String> grades = new HashMap<>();
CorrectStudent s1 = new CorrectStudent("Alice", 101);
CorrectStudent s2 = new CorrectStudent("Alice", 101);
[Link]([Link](s2)); // true
[Link]([Link]()); // same value!
[Link]([Link]()); // same value!
[Link](s1, "A+");
[Link]([Link](s2)); // "A+" (correct!)
[Link](s2, "B");
[Link]([Link]()); // 1 (correct!)
[Link]([Link](s1)); // "B" (value updated)
}
}
7. Performance Impact of Bad Hash Functions
Let’s see a concrete performance comparison between a good and bad hash function using a
simple benchmark.
[Link]
import [Link];
import [Link];
class GoodHashStudent {
String name;
int rollNumber;
GoodHashStudent(String name, int rollNumber) {
[Link] = name;
[Link] = rollNumber;
}
@Override
public boolean equals(Object obj) {
if (!(obj instanceof GoodHashStudent)) return false;
GoodHashStudent o = (GoodHashStudent) obj;
return rollNumber == [Link] && [Link](name, [Link]);
}
@Override
public int hashCode() {
return [Link](name, rollNumber); // GOOD: distributes evenly
}
}
class BadHashStudent {
String name;
int rollNumber;
BadHashStudent(String name, int rollNumber) {
[Link] = name;
[Link] = rollNumber;
}
@Override
public boolean equals(Object obj) {
if (!(obj instanceof BadHashStudent)) return false;
BadHashStudent o = (BadHashStudent) obj;
return rollNumber == [Link] && [Link](name, [Link]);
}
@Override
public int hashCode() {
return 42; // TERRIBLE: everything in one bucket
}
}
public class HashPerformanceBenchmark {
public static void main(String[] args) {
int n = 10_000;
// Benchmark GOOD hash function
HashMap<GoodHashStudent, Integer> goodMap = new HashMap<>();
long start = [Link]();
for (int i = 0; i < n; i++) {
[Link](new GoodHashStudent("Student" + i, i), i);
}
long goodInsert = [Link]() - start;
start = [Link]();
for (int i = 0; i < n; i++) {
[Link](new GoodHashStudent("Student" + i, i));
}
long goodLookup = [Link]() - start;
// Benchmark BAD hash function
HashMap<BadHashStudent, Integer> badMap = new HashMap<>();
start = [Link]();
for (int i = 0; i < n; i++) {
[Link](new BadHashStudent("Student" + i, i), i);
}
long badInsert = [Link]() - start;
start = [Link]();
for (int i = 0; i < n; i++) {
[Link](new BadHashStudent("Student" + i, i));
}
long badLookup = [Link]() - start;
[Link]("=== Performance Results (10,000 entries) ===");
[Link]("Good hash - Insert: " + (goodInsert/1_000_000) + " ms");
[Link]("Good hash - Lookup: " + (goodLookup/1_000_000) + " ms");
[Link]("Bad hash - Insert: " + (badInsert/1_000_000) + " ms");
[Link]("Bad hash - Lookup: " + (badLookup/1_000_000) + " ms");
[Link]("Bad hash is ~" + (badLookup/[Link](goodLookup,1))
+ "x slower for lookups!");
// Typical output: Bad hash is 100-1000x slower!
}
}
Good hashCode() Bad hashCode() (constant)
put() / get() O(1) average O(n) — linear scan
10,000 lookups ~5 ms ~5,000 ms (1000x slower)
Bucket distribution Even across buckets All in ONE bucket
8. HashSet and Deduplication
A HashSet is built on top of HashMap internally (it uses a HashMap where all values are a dummy
object). This means the exact same equals()/hashCode() rules apply. If you put objects in a
HashSet without proper overrides, “duplicate” objects won’t be detected.
[Link]
import [Link];
import [Link];
class Student {
String name;
int rollNumber;
Student(String name, int rollNumber) {
[Link] = name;
[Link] = rollNumber;
}
@Override
public boolean equals(Object obj) {
if (this == obj) return true;
if (!(obj instanceof Student)) return false;
Student other = (Student) obj;
return rollNumber == [Link]
&& [Link](name, [Link]);
}
@Override
public int hashCode() {
return [Link](name, rollNumber);
}
@Override
public String toString() {
return name + " (" + rollNumber + ")";
}
}
public class HashSetDedup {
public static void main(String[] args) {
HashSet<Student> enrolled = new HashSet<>();
[Link](new Student("Alice", 101));
[Link](new Student("Bob", 102));
[Link](new Student("Alice", 101)); // Duplicate!
[Link]([Link]()); // 2 (duplicate filtered out)
[Link](enrolled);
// [Alice (101), Bob (102)]
// Without equals()/hashCode() overrides, size would be 3!
}
}
9. Java Records — The Modern Shortcut (Java 16+)
If you’re using Java 16 or later, records automatically generate equals(), hashCode(), and
toString() based on all the record’s components. This eliminates boilerplate and removes the
risk of forgetting to override these methods.
[Link]
// A record automatically provides:
// - A constructor
// - Getter methods (name(), rollNumber())
// - equals() based on all fields
// - hashCode() based on all fields
// - toString() showing all fields
public record Student(String name, int rollNumber) {}
// That single line above is equivalent to writing an entire class
// with constructor, getters, equals(), hashCode(), and toString()!
// Usage:
Student s1 = new Student("Alice", 101);
Student s2 = new Student("Alice", 101);
[Link]([Link](s2)); // true (auto-generated)
[Link]([Link]() == [Link]()); // true
[Link](s1); // Student[name=Alice, rollNumber=101]
✔ When to Use Records
Use records for simple data-carrier classes where all fields define the object’s identity. For classes
with complex behavior, mutable state, or custom equality logic, stick with regular classes and override
the methods manually.
10. Bonus: Mutable Keys in HashMap — A Hidden Bug
Another subtle issue arises when you use mutable objects as keys in a HashMap. If you modify
a key after inserting it, the hash code changes, and the entry becomes “lost” in the wrong
bucket.
[Link]
import [Link];
import [Link];
class MutableStudent {
String name;
int rollNumber;
MutableStudent(String name, int rollNumber) {
[Link] = name;
[Link] = rollNumber;
}
// Setter that allows mutation
void setName(String name) { [Link] = name; }
@Override
public boolean equals(Object obj) {
if (!(obj instanceof MutableStudent)) return false;
MutableStudent o = (MutableStudent) obj;
return rollNumber == [Link] && [Link](name, [Link]);
}
@Override
public int hashCode() {
return [Link](name, rollNumber);
}
}
public class MutableKeyBug {
public static void main(String[] args) {
HashMap<MutableStudent, String> map = new HashMap<>();
MutableStudent key = new MutableStudent("Alice", 101);
[Link](key, "A+");
[Link]([Link](key)); // "A+" (works fine)
// Now MUTATE the key after insertion!
[Link]("Bob"); // hashCode changes!
[Link]([Link](key)); // null! Entry is lost!
// The entry is still in the old bucket (based on "Alice")
// but we're now looking in a new bucket (based on "Bob")
[Link]([Link]()); // 1 (entry exists but is unreachable)
// LESSON: Use immutable objects as HashMap keys!
// This is why String (immutable) is the most common key type.
}
}
✔ Best Practice for HashMap Keys
Always use immutable objects as HashMap keys. String, Integer, and other wrapper types are
immutable and safe. If you must use a custom class as a key, make it immutable (final class, final
fields, no setters).
11. Complete Template — Copy and Adapt
Here is a complete, production-ready class that correctly implements all the methods discussed
in this document. Use this as a template for your own classes.
[Link]
import [Link];
/**
* A well-written class demonstrating proper Object method overrides.
* Use this as a template for your own classes.
*/
public final class Student {
// Fields are private and final (immutable) - safe for use as HashMap keys
private final String name;
private final int rollNumber;
private final String department;
public Student(String name, int rollNumber, String department) {
// Validate inputs in constructor
if (name == null || department == null) {
throw new IllegalArgumentException("Name and department cannot be null");
}
[Link] = name;
[Link] = rollNumber;
[Link] = department;
}
// Getters (no setters - the class is immutable)
public String getName() { return name; }
public int getRollNumber() { return rollNumber; }
public String getDepartment() { return department; }
/**
* toString: Human-readable representation for debugging.
* Called automatically by println, string concatenation, debugger, etc.
*/
@Override
public String toString() {
return "Student{" +
"name='" + name + '\'' +
", rollNumber=" + rollNumber +
", department='" + department + '\'' +
'}';
}
/**
* equals: Logical equality based on ALL fields.
* Contract: reflexive, symmetric, transitive, consistent, non-null.
*/
@Override
public boolean equals(Object obj) {
if (this == obj) return true; // Same reference
if (!(obj instanceof Student)) return false; // Type check + null check
Student other = (Student) obj; // Safe downcast
return rollNumber == [Link] // Primitive: use ==
&& [Link](name, [Link]) // Object: null-safe equals
&& [Link](department, [Link]);
}
/**
* hashCode: MUST be consistent with equals.
* Rule: if [Link](b) then [Link]() == [Link]()
* Uses same fields as equals().
*/
@Override
public int hashCode() {
return [Link](name, rollNumber, department);
}
}
12. Quick Reference Summary
Concept Key Takeaway
Object class Root of all Java classes. Provides toString(), equals(), and
hashCode().
== operator Compares references (memory addresses). Only true if both
variables point to the same object.
equals() Override to compare object content. Must be reflexive, symmetric,
transitive, consistent, non-null.
hashCode() Override whenever you override equals(). Equal objects MUST
have same hash code.
[Link]() Convenient utility that uses 31-based polynomial hashing. Use
same fields as equals().
HashMap internals Uses hashCode() to find bucket, then equals() to find exact match
within bucket.
Bad hash function Constant return value puts all entries in one bucket. O(1) degrades
to O(n).
Type casting Upcasting is implicit and safe. Downcasting needs instanceof check
+ explicit cast.
instanceof Checks runtime type. Essential before downcasting. Java 16+ has
pattern matching form.
Immutable keys Always use immutable objects as HashMap keys. Mutating a key
makes it unreachable.
Java Records Auto-generate equals(), hashCode(), toString() from all
components. Use for data classes.
Remember: Every time you create a class that will be used in collections or compared for
equality, ask yourself: Have I overridden equals(), hashCode(), and toString()? If not, your code
likely has subtle bugs waiting to happen.