The Java Object Class in Depth

Every Java class silently extends java.lang.Object: the root of the entire class hierarchy. Object provides methods that every object inherits: toString(), equals(), hashCode(), getClass(), clone(), wait(), notify(), notifyAll(), and the deprecated finalize(). Understanding what each does, when to override it, and when not to is essential for writing correct, idiomatic Java.

Why Every Class Extends Object

Java has a single-root class hierarchy: every class, no matter how it is declared, implicitly extends java.lang.Object. Writing class Dog is exactly equivalent to writing class Dog extends Object. This design choice means that every object in the JVM shares a common set of behaviours and can be treated as an Object when needed: stored in a pre-generics collection, passed to a method that accepts Object, or assigned to an Object variable.

The guarantee extends beyond ordinary classes. Arrays are objects too: int[], String[], and even int[][] are all Object instances. Interfaces do not extend Object at the source level, but an interface reference can still invoke the nine Object methods because the object behind it is always a concrete class that does extend Object.

The methods Object provides fall into three groups: identity and equality (equals, hashCode, getClass), copying (clone), representation (toString), thread coordination (wait, notify, notifyAll), and the deprecated cleanup hook (finalize). The rest of this article covers each group in turn.

toString(): Human-Readable Representation

toString() is called whenever an object is converted to a string: in string concatenation, System.out.println(), logging frameworks, and debuggers. The default implementation inherited from Object returns ClassName@hexHashCode, for example com.example.Dog@7852e922. That hex code is derived from the object's identity hash code and has no business meaning, so the default is almost always useless for anything other than confirming an object is not null.

Override toString() in any class whose instances you will log, print, or embed in error messages. A good override lists the fields that identify or describe the object. Modern Java provides Records, which generate toString() automatically in the form ClassName[field1=value1, field2=value2].

Java
public class Point {
    private final int x;
    private final int y;

    public Point(int x, int y) {
        this.x = x;
        this.y = y;
    }

    @Override
    public String toString() {
        return "Point{x=" + x + ", y=" + y + "}";
    }
}

Point p = new Point(3, 7);
System.out.println(p);           // Point{x=3, y=7}
System.out.println("Move to " + p); // Move to Point{x=3, y=7}

// With Records — toString() is generated automatically
record PointR(int x, int y) {}
System.out.println(new PointR(3, 7)); // PointR[x=3, y=7]
Never rely on toString() output for parsing or persistence: its format is not guaranteed. It is for human consumption (logs, debugging) only.

equals() and hashCode(): Logical Equality

By default, equals() compares object references: two variables are equal only if they point to the exact same object in memory. This is the same as the == operator. For value objects like Point, Money, or Employee, you almost always want logical equality instead: two points with the same coordinates should be equal regardless of whether they are the same instance.

The equals-hashCode contract requires that if a.equals(b) is true then a.hashCode() == b.hashCode() must also be true. Violating this contract causes objects to be lost in HashMap and HashSet because the map may compute a different bucket index for two objects that should be considered the same key.

Java
public class Point {
    private final int x;
    private final int y;

    public Point(int x, int y) { this.x = x; this.y = y; }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof Point other)) return false;
        return x == other.x && y == other.y;
    }

    @Override
    public int hashCode() {
        return Objects.hash(x, y);
    }
}

Point a = new Point(3, 7);
Point b = new Point(3, 7);
System.out.println(a.equals(b)); // true — logical equality
System.out.println(a == b);      // false — different instances

Set<Point> set = new HashSet<>();
set.add(a);
System.out.println(set.contains(b)); // true — hashCode contract upheld
For a complete treatment of the equals/hashCode contract, edge cases, and IDE/Record generation, see the dedicated equals() and hashCode() article.

getClass(): Runtime Type Information

getClass() returns the Class<?> object that represents the actual runtime type of an object, not the compile-time type of the variable holding it. If a variable declared as Animal holds a Dog instance at runtime, getClass() returns Dog.class, not Animal.class. This is the entry point into the reflection API: from a Class object you can discover fields, methods, constructors, annotations, and more.

A common use is checking exact type equality. Note that getClass() == other.getClass() is stricter than instanceof: two objects of the same interface or parent type will have different Class objects. Most equals() implementations use instanceof (with pattern matching) rather than getClass() comparison, so that subclasses can be considered equal to their parent when the fields match.

Java
Object obj = "Hello";
System.out.println(obj.getClass());             // class java.lang.String
System.out.println(obj.getClass().getName());   // java.lang.String
System.out.println(obj.getClass().getSimpleName()); // String

// Runtime type vs compile-time type
Number n = Integer.valueOf(42);
System.out.println(n.getClass());               // class java.lang.Integer
System.out.println(n.getClass().getSuperclass()); // class java.lang.Number

// Checking exact type
Object a = new ArrayList<>();
Object b = new LinkedList<>();
System.out.println(a.getClass() == b.getClass()); // false
System.out.println(a instanceof List<?>);         // true
System.out.println(b instanceof List<?>);         // true

clone(): Copying Objects

The clone() method is intended to produce a copy of an object. The implementation inherited from Object performs a shallow copy: it allocates a new instance of the same class and copies every field by value. For primitive fields that is correct, but for reference fields it copies the reference: both original and clone then point to the same nested object.

To make clone() accessible you must implement the Cloneable marker interface and override clone() to make it public. Without Cloneable, calling Object.clone() throws CloneNotSupportedException. The design is widely considered a mistake in the Java API: the marker interface does not declare clone(), the checked exception is awkward, and deep-copy semantics must be hand-rolled. Most experts today prefer a copy constructor or a static factory method over clone().

Java
// Option 1 — Cloneable (legacy, fragile)
public class IntList implements Cloneable {
    private int[] data;
    private int size;

    public IntList(int[] data) {
        this.data = data.clone(); // already a deep copy for primitive arrays
        this.size = data.length;
    }

    @Override
    public IntList clone() {
        try {
            IntList copy = (IntList) super.clone(); // shallow copy of fields
            copy.data = this.data.clone();          // manually deep-copy the array
            return copy;
        } catch (CloneNotSupportedException e) {
            throw new AssertionError(); // cannot happen — we implement Cloneable
        }
    }
}

// Option 2 — Copy constructor (preferred)
public class IntList2 {
    private int[] data;
    private int size;

    public IntList2(IntList2 source) {          // copy constructor
        this.data = source.data.clone();
        this.size = source.size;
    }
}
Records and modern immutable value types do not need clone() at all, because they are immutable, sharing the same instance is safe.

wait(), notify() and notifyAll(): Low-Level Thread Coordination

Every Java object has an intrinsic monitor: a built-in lock that the synchronized keyword acquires. The wait(), notify(), and notifyAll() methods operate on this monitor and allow threads to coordinate: one thread can release the lock and wait until another thread signals it to continue. These methods were Java's original threading primitives, predating java.util.concurrent.

The rules are strict: all three must be called from within a synchronized block or method on the same object, or an IllegalMonitorStateException is thrown at runtime. wait() atomically releases the lock and suspends the thread. notify() wakes one waiting thread (which one is arbitrary). notifyAll() wakes all waiting threads. Because spurious wakeups can occur, wait() should always be called inside a while loop that re-checks the condition.

Java
import java.util.ArrayDeque;
import java.util.Queue;

public class MonitorQueue<T> {
    private final Queue<T> queue = new ArrayDeque<>();
    private final int capacity;

    public MonitorQueue(int capacity) { this.capacity = capacity; }

    public synchronized void put(T item) throws InterruptedException {
        while (queue.size() == capacity) {
            wait(); // release lock and suspend until space is available
        }
        queue.add(item);
        notifyAll(); // wake consumers that may be waiting for items
    }

    public synchronized T take() throws InterruptedException {
        while (queue.isEmpty()) {
            wait(); // release lock and suspend until an item is available
        }
        T item = queue.poll();
        notifyAll(); // wake producers that may be waiting for space
        return item;
    }
}
In modern Java, prefer higher-level constructs from java.util.concurrent over raw wait/notify:
  • For producer-consumer queues, use BlockingQueue (e.g., LinkedBlockingQueue).
  • For conditional waiting with explicit locks, use ReentrantLock and Condition.
  • For one-shot coordination, use CountDownLatch.

finalize(): Why It Was Deprecated

finalize() was originally intended as a cleanup hook: the garbage collector would call it on an object just before reclaiming its memory, giving the object a chance to release native resources. In practice, the design turned out to be deeply flawed. The GC gives no timing guarantee: finalize() might run seconds, minutes, or never before a long-lived JVM shuts down. A misbehaving finalize() can resurrect objects (by storing a reference to this), causing the GC to run it again. Finalization also delays garbage collection and hurts throughput.

finalize() was deprecated in Java 9 and is scheduled for removal. The correct replacements are:

  • try-with-resources: for any resource that implements AutoCloseable. The compiler guarantees close() is called even if an exception is thrown.
  • Cleaner API (java.lang.ref.Cleaner, Java 9+): for native or off-heap resources that genuinely cannot be wrapped in AutoCloseable. A Cleaner registers a Runnable that runs after the object becomes phantom-reachable, in a dedicated thread.
Java
import java.lang.ref.Cleaner;

public class NativeBuffer implements AutoCloseable {
    private static final Cleaner CLEANER = Cleaner.create();

    private final long nativePtr;               // pointer to off-heap memory
    private final Cleaner.Cleanable cleanable;

    public NativeBuffer(int size) {
        this.nativePtr = allocateNative(size);  // hypothetical native call
        // Register cleanup action — must NOT reference 'this' to avoid memory leak
        long ptr = this.nativePtr;
        this.cleanable = CLEANER.register(this, () -> freeNative(ptr));
    }

    @Override
    public void close() {
        cleanable.clean(); // explicit close — runs cleanup immediately
    }

    private static native long allocateNative(int size);
    private static native void freeNative(long ptr);
}

// Use with try-with-resources — close() called automatically
try (NativeBuffer buf = new NativeBuffer(1024)) {
    // use buf
} // close() called here; Cleaner acts as a safety net if close() was missed

Note: Prefer explicit close() via try-with-resources over relying on the Cleaner. The Cleaner is a safety net, not a primary resource management strategy.