Java Inheritance

Inheritance is the mechanism that lets one Java class acquire the fields and methods of another. Instead of writing the same code in every related class, you write it once in a parent class and let child classes reuse, extend, or specialize it. It is one of the four pillars of object-oriented programming (alongside encapsulation, abstraction, and polymorphism), and it is the foundation for polymorphism in Java: the ability to treat objects of different but related types through a single, common reference type.

Overview / How it works

When a class Dog extends a class Animal, Dog automatically gets every non-private field and method that Animal defines. Animal is called the superclass (or parent/base class), and Dog is the subclass (or child/derived class). The relationship models an “is-a” connection: a Dog is an Animal. This is different from composition, which models a “has-a” relationship (a Car has an Engine).

Java supports only single inheritance for classes: a class can extend exactly one direct superclass. This is a deliberate design decision to avoid the “diamond problem” that multiple inheritance causes in languages like C++. If you need behavior from several sources, Java gives you interfaces instead, which a class can implement any number of.

Under the hood, every class you write that does not explicitly extend anything implicitly extends java.lang.Object, so every object in Java, no matter how deep the hierarchy, shares the baseline methods defined there (toString(), equals(), hashCode(), getClass(), and others). When the JVM loads a class, it also loads its entire superclass chain, and it stores a method table (the virtual method table, conceptually) for each class. When you call an instance method through a reference, the JVM does not simply look at the compile-time (declared) type of the variable — it performs dynamic dispatch: it looks at the actual runtime type of the object and walks up the hierarchy to find the most specific overriding implementation of that method. This is exactly what makes polymorphism work: a variable declared as Animal can hold a Dog, and calling a method on it executes Dog‘s version if Dog overrides it.

Fields behave differently from methods. Field access in Java is resolved at compile time based on the declared (static) type of the reference, not the runtime type. This means fields can be “hidden” by a subclass but never truly “overridden” the way methods are — a subtlety that trips up many learners and is covered in Common Mistakes below.

Constructors are not inherited. Every subclass must have its own constructor (or rely on the compiler-generated default one), and the very first thing any constructor does — explicitly or implicitly — is call a constructor of its superclass, via super(...). If you do not write a call to super(...) yourself, the compiler inserts an implicit no-argument super() call. If the superclass has no no-argument constructor available, you must call super(...) explicitly with matching arguments, or the code will not compile.

Syntax

class SubclassName extends SuperclassName {
    // additional fields
    // additional or overriding methods

    SubclassName(...) {
        super(...); // must be the first statement, calls the superclass constructor
        // subclass-specific initialization
    }

    @Override
    ReturnType methodName(ParameterTypes) {
        // new implementation, optionally calling super.methodName(...)
    }
}
  • extends – the keyword that establishes the inheritance relationship; a class may extend only one other class.
  • super(...) – used inside a constructor to invoke the superclass’s constructor; must be the first statement if present.
  • super.methodName(...) – calls the superclass’s version of a method from inside an overriding method.
  • @Override – an annotation (not required, but strongly recommended) that tells the compiler you intend to override a superclass method; it will produce a compile error if the signature does not actually match anything in the superclass.
  • protected – an access modifier commonly used for fields/methods that subclasses need to access directly, but that should stay hidden from unrelated classes.

Examples

Example 1: Basic inheritance and overriding

public class Main {
    public static void main(String[] args) {
        Animal a = new Animal("Generic Animal");
        a.makeSound();

        Dog d = new Dog("Rex", "Labrador");
        d.makeSound();
        d.fetch();

        Animal ref = d; // upcasting: Dog treated as Animal
        ref.makeSound(); // still runs Dog's version at runtime
    }
}

class Animal {
    protected String name;

    public Animal(String name) {
        this.name = name;
    }

    public void makeSound() {
        System.out.println(name + " makes a generic animal sound.");
    }
}

class Dog extends Animal {
    private String breed;

    public Dog(String name, String breed) {
        super(name);
        this.breed = breed;
    }

    @Override
    public void makeSound() {
        System.out.println(name + " (" + breed + ") barks: Woof!");
    }

    public void fetch() {
        System.out.println(name + " fetches the ball.");
    }
}

Output:

Generic Animal makes a generic animal sound.
Rex (Labrador) barks: Woof!
Rex fetches the ball.
Rex (Labrador) barks: Woof!

Even though ref is declared as Animal, the last call still prints the Dog message. That is dynamic dispatch: Java always runs the override that matches the object’s actual runtime type, regardless of the type of the variable you used to call it.

Example 2: Constructor chaining with super

public class Main {
    public static void main(String[] args) {
        Car c = new Car("Toyota");
        c.describe();
    }
}

class Vehicle {
    protected int wheels;

    public Vehicle(int wheels) {
        this.wheels = wheels;
        System.out.println("Vehicle constructor: " + wheels + " wheels");
    }

    public void describe() {
        System.out.println("This vehicle has " + wheels + " wheels.");
    }
}

class Car extends Vehicle {
    private String brand;

    public Car(String brand) {
        super(4);
        this.brand = brand;
        System.out.println("Car constructor: " + brand);
    }

    @Override
    public void describe() {
        super.describe();
        System.out.println("It's a " + brand + " car.");
    }
}

Output:

Vehicle constructor: 4 wheels
Car constructor: Toyota
This vehicle has 4 wheels.
It's a Toyota car.

Notice the order: the superclass constructor always finishes running before the subclass constructor’s body continues, guaranteeing that inherited fields (like wheels) are fully initialized before the subclass adds its own setup. Inside describe(), super.describe() explicitly reuses the parent’s implementation instead of duplicating it.

Example 3: Polymorphism across multiple subclasses

public class Main {
    public static void main(String[] args) {
        Employee[] staff = new Employee[3];
        staff[0] = new Employee("Alice", 50000);
        staff[1] = new Manager("Bob", 60000, 15000);
        staff[2] = new Developer("Carol", 55000, 20);

        for (Employee e : staff) {
            e.printDetails();
        }
    }
}

class Employee {
    protected String name;
    protected double baseSalary;

    public Employee(String name, double baseSalary) {
        this.name = name;
        this.baseSalary = baseSalary;
    }

    public double calculateSalary() {
        return baseSalary;
    }

    public void printDetails() {
        System.out.println(name + "'s salary: $" + calculateSalary());
    }
}

class Manager extends Employee {
    private double bonus;

    public Manager(String name, double baseSalary, double bonus) {
        super(name, baseSalary);
        this.bonus = bonus;
    }

    @Override
    public double calculateSalary() {
        return baseSalary + bonus;
    }
}

class Developer extends Employee {
    private int overtimeHours;

    public Developer(String name, double baseSalary, int overtimeHours) {
        super(name, baseSalary);
        this.overtimeHours = overtimeHours;
    }

    @Override
    public double calculateSalary() {
        return baseSalary + overtimeHours * 25;
    }
}

Output:

Alice's salary: $50000.0
Bob's salary: $75000.0
Carol's salary: $55500.0

The loop treats every element as a plain Employee, yet each call to calculateSalary() runs the correct subclass logic. This is the entire point of inheritance combined with overriding: you write one loop that works for any current or future Employee subtype.

How it works step by step / Under the hood

  • When the JVM loads Dog, it also loads Animal (and Object), building a chain of class metadata.
  • Each class has a method table; when a subclass overrides a method, its table entry for that method points to the subclass’s bytecode instead of the superclass’s.
  • When you write new Dog(...), memory is allocated for a single object containing all fields declared in Object, Animal, and Dog combined — there is one object, not three.
  • Construction runs top-down: Object‘s constructor runs first, then Animal‘s, then Dog‘s, because each constructor’s first act is to call its superclass constructor via super(...) (implicitly or explicitly).
  • When you call ref.makeSound() where ref is declared as Animal but holds a Dog, the JVM uses the object’s actual runtime type to select which method body to execute — this lookup is called dynamic (virtual) dispatch and happens for every non-private, non-static instance method call.
  • Field access, by contrast, is resolved using the compile-time declared type of the reference, decided entirely at compile time with no runtime lookup involved.

Common Mistakes

Mistake 1: Assuming fields are polymorphic like methods

Only methods are dynamically dispatched; fields are resolved by the declared type of the reference. This means if a subclass declares a field with the same name as one in its superclass, it does not override it — it hides it, and which one you see depends on the type of the variable, not the type of the object.

public class Main {
    public static void main(String[] args) {
        Base b = new Derived();
        System.out.println(b.label);              // Base label -- NOT Derived label
        System.out.println(((Derived) b).label);   // Derived label
    }
}

class Base {
    String label = "Base label";
}

class Derived extends Base {
    String label = "Derived label";
}

Output:

Base label
Derived label

The fix is to avoid public/protected fields with the same name in a subclass entirely. Keep fields private, expose them through getter methods, and let overriding (which does work correctly) handle any specialization.

Mistake 2: Calling a subclass-only method through a superclass reference

A superclass reference only exposes the members declared in the superclass, even if the object it points to is really a subclass instance. The compiler checks against the declared type, not the runtime type, so this fails to compile:

Animal ref = new Dog("Rex", "Labrador");
ref.fetch(); // compile error: cannot find symbol -- fetch() is not declared in Animal

To call fetch() you must either declare the variable as Dog in the first place, or explicitly downcast: ((Dog) ref).fetch();. A safer downcast checks the type first with instanceof: if (ref instanceof Dog d) { d.fetch(); }.

Mistake 3: Forgetting that super() must come first

If a subclass constructor tries to use this fields or call other methods before calling super(...), or places super(...) anywhere but the first line, the code will not compile. Java enforces that the superclass portion of an object is fully constructed before the subclass constructor body runs, so super(...) (or an implicit no-arg super()) is always the first statement executed.

Best Practices

  • Always add @Override to methods you intend to override — it turns typos in the method signature into compile errors instead of silent bugs.
  • Favor protected over public for fields and helper methods that subclasses need but outside code should not touch directly.
  • Keep fields private whenever possible and expose behavior through methods, so you never run into field-hiding surprises.
  • Use inheritance only for genuine “is-a” relationships; if the relationship is really “has-a” or “uses-a”, prefer composition instead.
  • Call super.method(...) when you want to extend, not replace, the parent’s behavior.
  • Keep inheritance hierarchies shallow (two or three levels at most); deep hierarchies become hard to reason about and to change safely.
  • Mark classes not designed for extension as final, and mark methods not designed to be overridden as final, to communicate intent clearly.

Practice Exercises

  • Create a Shape superclass with a double area() method returning 0, then create Circle and Rectangle subclasses that override area() correctly. Store several shapes in a Shape[] array and print each area in a loop.
  • Write a BankAccount class with a withdraw(double amount) method that rejects overdrafts, then write a SavingsAccount subclass that overrides withdraw to also apply a small penalty fee when the balance would go below a minimum. Test both classes with the same driver code.
  • Given a Person superclass with a name field and a Student subclass with a studentId field, write constructors for both using super(...), and add an overridden toString() method to each that includes the superclass’s string via super.toString().

Summary

  • Inheritance lets a subclass reuse and extend the fields and methods of a superclass using extends.
  • Java classes support single inheritance only; a class can extend just one other class.
  • Constructors are never inherited; every constructor’s first action is a call to super(...), explicit or implicit.
  • Method overriding enables polymorphism through dynamic dispatch: the JVM runs the method body matching the object’s actual runtime type.
  • Fields are not polymorphic — they are resolved by the declared type of the reference and can be hidden, not overridden.
  • Use super.method(...) to reuse a parent implementation while adding new behavior in an override.
  • Prefer composition over inheritance unless the relationship is genuinely “is-a”.