Menu

Object Class and Advanced OOP Concepts in Java

Object Class and Advanced OOP Concepts

This closing chapter returns to java.lang.Object, the root of every Java class, and looks much more closely at the methods it provides. Along the way, it covers cloning, dynamic binding, and runtime type identification, rounding out the deeper mechanics that sit underneath everything covered so far.

Introduction to the Object Class

Every class in Java, whether it says so explicitly or not, ultimately extends Object. This single fact is why methods like toString(), equals(), and hashCode() are available on literally any object you ever create, even one from a class with an entirely empty body.

Method

Default Behaviour (Inherited, Unoverridden)

toString()

Returns the class name plus a hash code, e.g. Book@1b6d3586

equals(Object o)

Returns true only if both references point to the exact same object in memory

hashCode()

Returns an integer derived from the object's memory address, by default

getClass()

Returns a Class object describing the object's actual runtime type

clone()

Creates a field-by-field copy, but only if the class implements Cloneable

The next three sections look at toString(), equals(), and hashCode() in detail, since overriding these three correctly, and consistently with each other, is one of the most common pieces of real-world Java code you will write.

toString() Method

Overriding toString() lets you control exactly what appears when an object is printed directly, or concatenated into a String, instead of the default, fairly useless class-name-plus-hash-code output.

class Product {

String name;

double price;

Product(String n, double p) {

name = n;

price = p;

}

@Override

public String toString() {

return "Product[name=" + name + ", price=" + price + "]";

}

}

Product p = new Product("Headphones", 49.99);

System.out.println(p);   // Product[name=Headphones, price=49.99]

equals() Method

The default equals(), inherited straight from Object, checks reference equality: whether both variables point to the literal same object in memory. For value-like classes, where two separate objects should be considered equal if their data matches, you override equals() to compare fields instead.

class Point {

int x, y;

Point(int x, int y) {

this.x = x;

this.y = y;

}

@Override

public boolean equals(Object obj) {

if (this == obj) return true;

if (!(obj instanceof Point)) return false;

Point other = (Point) obj;

return this.x == other.x && this.y == other.y;

}

}

Point p1 = new Point(2, 3);

Point p2 = new Point(2, 3);

System.out.println(p1 == p2);       // false — different objects in memory

System.out.println(p1.equals(p2));  // true — same field values, thanks to our override

hashCode() Method

Whenever you override equals(), Java's documented contract requires you to override hashCode() as well, and to do so consistently: if two objects are equal according to equals(), they must produce the identical hashCode() value. Breaking this contract causes subtle, confusing bugs in hash-based collections like HashMap and HashSet, where objects that should be treated as duplicates silently are not.

class Point {

int x, y;

Point(int x, int y) {

this.x = x;

this.y = y;

}

@Override

public boolean equals(Object obj) {

if (this == obj) return true;

if (!(obj instanceof Point)) return false;

Point other = (Point) obj;

return this.x == other.x && this.y == other.y;

}

@Override

public int hashCode() {

return java.util.Objects.hash(x, y);   // combines both fields consistently

}

}

java.util.Objects.hash(...) is the standard, recommended way to generate a hashCode() that respects the contract, since it combines all the given fields using a well-tested algorithm rather than something handwritten and potentially inconsistent.

Object Cloning

Cloning produces a new object with the same field values as an existing one, without going through that class's constructor. To make a class cloneable, it must implement the Cloneable marker interface and override clone(), which otherwise throws CloneNotSupportedException.

class Address implements Cloneable {

String city;

Address(String c) { city = c; }

@Override

public Object clone() throws CloneNotSupportedException {

return super.clone();   // Object's clone() does the actual field-by-field copy

}

}

Address original = new Address("Chennai");

try {

Address copy = (Address) original.clone();

System.out.println(copy.city);   // Chennai

} catch (CloneNotSupportedException e) {

e.printStackTrace();

}

The default clone() performs a shallow copy: primitive fields are copied directly, but if a field is itself an object reference, the clone and the original both end up pointing to the very same inner object, not separate copies of it. Achieving a true deep copy, where nested objects are also independently duplicated, requires manually cloning those nested fields yourself inside an overridden clone() method.

Dynamic Binding

Dynamic binding, introduced briefly in the Beginner tutorial, is the mechanism that powers runtime polymorphism: the JVM decides which overridden method implementation to run by checking an object's actual runtime type, not the type of the variable referencing it, and it makes that decision while the program is actually executing, not while it is being compiled.

class Shape {

double area() { return 0; }

}

class Circle extends Shape {

double radius;

Circle(double r) { radius = r; }

@Override

double area() { return Math.PI * radius * radius; }

}

Shape shape = new Circle(4);

System.out.println(shape.area());   // Uses Circle's version — decided at runtime

This stands in contrast to static binding, used for overloaded, private, and static methods, where the compiler already knows exactly which method will run just by reading the code.

Runtime Type Identification (RTTI)

Runtime Type Identification is the ability to inspect an object's actual type while a program is running, useful when you are holding a reference of a general type but need to know, or act differently based on, the specific subclass underneath.

class Animal { }

class Dog extends Animal { void bark() { System.out.println("Woof!"); } }

class Cat extends Animal { void meow() { System.out.println("Meow!"); } }

public class Main {

static void identify(Animal animal) {

if (animal instanceof Dog dog) {     // pattern matching instanceof (Java 16+)

dog.bark();

} else if (animal instanceof Cat cat) {

cat.meow();

}

System.out.println("Runtime class: " + animal.getClass().getSimpleName());

}

public static void main(String[] args) {

identify(new Dog());   // Woof! / Runtime class: Dog

identify(new Cat()); // Meow! / Runtime class: Cat

}

}

instanceof checks whether an object is of a given type (or a subtype of it) and returns a boolean; getClass() returns the precise, exact runtime class. The newer pattern-matching form of instanceof, shown above, both checks the type and casts the variable in one step, removing the separate explicit cast that older Java code required.

•        Relying heavily on instanceof checks scattered throughout your code is often a sign that polymorphism, via method overriding, should be used instead, letting each subclass simply provide its own behaviour rather than the caller branching on type.

•        RTTI remains genuinely useful in situations like deserialisation, debugging tools, or frameworks that must operate generically over objects whose exact type is unknown until runtime.

Best Practices for Object-Oriented Design

•        Always override equals() and hashCode() together, never just one, to keep your classes safe to use inside hash-based collections.

•        Override toString() for any class whose objects are likely to be logged, printed, or debugged; the default output is rarely useful.

•        Prefer composition and interfaces over deep inheritance hierarchies, and reserve cloning for genuine value-like objects where copying makes real conceptual sense.

•        Lean on polymorphism rather than instanceof chains wherever behaviour needs to vary by type; let each subclass decide its own behaviour through overriding.

•        Keep static, final, and access modifiers as restrictive as the design genuinely requires, revisiting Chapters 1, 4, and 5 of this tutorial whenever you're unsure which to reach for.

Did You Know?

The equals()-hashCode() contract problem is common enough that most modern IDEs can auto-generate a correct, consistent pair for you directly from a class's fields. Many teams also use libraries like Lombok specifically to avoid hand-writing this boilerplate at all, generating it automatically from simple annotations instead.