Skip to main content
Java · lesson 14 of 66
Open the Java page →
Java beginner Lesson 14 of 66

Inheritance in Java

Learn how to build class hierarchies using extends, super, method overriding, and the Liskov Substitution Principle with real Java examples.

By SkillByExample · Updated

Inheritance lets a subclass acquire the fields and methods of a superclass and then add to or change that behaviour. It models “is-a” relationships: a Car is a Vehicle. Each example is a single file run with the JDK 21 source launcher (java File.java).

extends and super(…)

A subclass constructor must call a superclass constructor first, so the inherited part of the object is initialised before the subclass adds its own.

public class CarDemo {
    public static void main(String[] args) {
        Car car = new Car("Toyota", 2023, 4);
        car.accelerate(60);
        car.openTrunk();
        System.out.println(car.info());
    }
}

class Vehicle {
    protected final String brand;
    protected final int year;
    protected double speed;

    Vehicle(String brand, int year) {
        this.brand = brand;
        this.year = year;
        System.out.println("Vehicle constructor");
    }
    void accelerate(double amount) { speed += amount; System.out.printf("%s at %.0f km/h%n", brand, speed); }
    String info() { return year + " " + brand; }
}

class Car extends Vehicle {
    private final int doors;

    Car(String brand, int year, int doors) {
        super(brand, year);
        this.doors = doors;
        System.out.println("Car constructor");
    }
    void openTrunk() { System.out.println("Trunk opened"); }
    @Override String info() { return super.info() + ", " + doors + " doors"; }
}
$ java CarDemo.java
Vehicle constructor
Car constructor
Toyota at 60 km/h
Trunk opened
2023 Toyota, 4 doors

Car never declares accelerate or brand, yet uses both. Car.info() calls super.info() and appends to it instead of duplicating the formatting.

Forgetting the parent constructor

If the superclass has no no-argument constructor, the subclass must call one explicitly.

public class NoSuper {
    public static void main(String[] args) {
        new Truck();
    }
}

class Vehicle {
    Vehicle(String brand) { }
}

class Truck extends Vehicle {
    Truck() {
        System.out.println("building a truck");
    }
}
$ java NoSuper.java
NoSuper.java:12: error: constructor Vehicle in class Vehicle cannot be applied to given types;
    Truck() {
            ^
  required: String
  found:    no arguments
  reason: actual and formal argument lists differ in length
1 error
error: compilation failed

Without an explicit call, the compiler inserts super() — and Vehicle has no such constructor. Adding super("Volvo"); as the first line of Truck() fixes it.

Overriding and dynamic dispatch

An overriding method replaces the parent’s version for instances of the subclass. Which version runs is decided by the object’s runtime class, even when the call comes from the parent’s code.

public class Shapes {
    public static void main(String[] args) {
        Shape[] shapes = { new Shape(), new Circle(1), new Rect(4, 6) };
        for (Shape s : shapes) System.out.println(s.describe());
    }
}

class Shape {
    double area() { return 0; }
    String describe() { return getClass().getSimpleName() + " with area " + String.format("%.2f", area()); }
}

class Circle extends Shape {
    private final double r;
    Circle(double r) { this.r = r; }
    @Override double area() { return Math.PI * r * r; }
}

class Rect extends Shape {
    private final double w, h;
    Rect(double w, double h) { this.w = w; this.h = h; }
    @Override double area() { return w * h; }
}
$ java Shapes.java
Shape with area 0.00
Circle with area 3.14
Rect with area 24.00

describe() is written once in Shape, but its call to area() reaches the subclass version because the array elements really are a Circle and a Rect. @Override makes the compiler check that a matching parent method exists, so a typo like aera() is an error instead of a silent new method.

Multi-level hierarchies

Each level can call the level above with super. Keep hierarchies shallow; two or three levels is plenty.

public class Electric {
    public static void main(String[] args) {
        ElectricCar tesla = new ElectricCar("Tesla", 0.90);
        tesla.accelerate(100);
        tesla.accelerate(900);
        System.out.println(tesla.info());
    }
}

class Vehicle {
    protected final String brand; protected double speed;
    Vehicle(String brand) { this.brand = brand; }
    void accelerate(double amount) { speed += amount; System.out.printf("%s at %.0f km/h%n", brand, speed); }
    String info() { return brand + " at " + speed + " km/h"; }
}

class Car extends Vehicle {
    Car(String brand) { super(brand); }
    @Override String info() { return "Car: " + super.info(); }
}

class ElectricCar extends Car {
    private double battery;                         // 0.0 to 1.0
    ElectricCar(String brand, double battery) { super(brand); this.battery = battery; }

    @Override void accelerate(double amount) {
        double cost = amount * 0.001;               // 0.1% per km/h
        if (cost > battery) { System.out.println("Not enough battery for +" + amount); return; }
        battery -= cost;
        super.accelerate(amount);
    }
    @Override String info() { return super.info() + String.format(", battery %.0f%%", battery * 100); }
}
$ java Electric.java
Tesla at 100 km/h
Not enough battery for +900.0
Car: Tesla at 100.0 km/h, battery 80%

ElectricCar.accelerate adds a check, then delegates to Vehicle.accelerate for the speed update. info() is built from all three levels: Car adds the prefix, Vehicle the speed, and ElectricCar the battery.

final: no subclasses, no overrides

public class FinalDemo {
    public static void main(String[] args) {
        System.out.println(new Point3D(1, 2, 3));
    }
}

final class Point {
    final double x, y;
    Point(double x, double y) { this.x = x; this.y = y; }
}

class Point3D extends Point {
    final double z;
    Point3D(double x, double y, double z) { super(x, y); this.z = z; }
}
$ java FinalDemo.java
FinalDemo.java:12: error: cannot inherit from final Point
class Point3D extends Point {
                      ^
1 error
error: compilation failed

A final class guarantees no subclass can add mutable state or change behaviour — String and Integer are final for this reason. A final method can’t be overridden, but its class can still be extended.

Inherited from Object: toString, equals, hashCode

Every class extends java.lang.Object. Its defaults print a hash and compare identity, which is rarely what a value class wants — and equals without hashCode breaks hash collections.

import java.util.*;

public class ObjectMethods {
    public static void main(String[] args) {
        Set<Product> set = new HashSet<>();
        set.add(new Product("Laptop", 999));
        Product same = new Product("Laptop", 999);
        System.out.println("equals: " + same.equals(set.iterator().next()));
        System.out.println("contains: " + set.contains(same));

        Set<FixedProduct> fixed = new HashSet<>(List.of(new FixedProduct("Laptop", 999)));
        System.out.println("fixed contains: " + fixed.contains(new FixedProduct("Laptop", 999)) + " " + fixed);
    }
}

class Product {
    final String name; final int price;
    Product(String name, int price) { this.name = name; this.price = price; }
    @Override public boolean equals(Object o) {
        return o instanceof Product p && p.name.equals(name) && p.price == price;
    }
}

class FixedProduct extends Product {
    FixedProduct(String name, int price) { super(name, price); }
    @Override public int hashCode() { return Objects.hash(name, price); }
    @Override public String toString() { return "Product(" + name + ", $" + price + ")"; }
}
$ java ObjectMethods.java
equals: true
contains: false
fixed contains: true [Product(Laptop, $999)]

The two Products are equal, yet the set says it doesn’t contain one: HashSet checked the bucket for same’s identity-based hash code, found nothing there, and never called equals. Overriding hashCode consistently fixes it. A record generates all three methods for you.

Liskov Substitution Principle

A subclass must work anywhere its parent is expected. Square extends Rectangle looks natural but breaks code written for rectangles:

public class Liskov {
    static void stretch(Rectangle r) {
        r.setWidth(5);
        r.setHeight(4);
        System.out.println(r.getClass().getSimpleName() + ": expected area 20, got " + r.area());
    }

    public static void main(String[] args) {
        stretch(new Rectangle());
        stretch(new Square());
    }
}

class Rectangle {
    protected int w, h;
    void setWidth(int w)  { this.w = w; }
    void setHeight(int h) { this.h = h; }
    int area() { return w * h; }
}

class Square extends Rectangle {
    @Override void setWidth(int w)  { this.w = w; this.h = w; }
    @Override void setHeight(int h) { this.w = h; this.h = h; }
}
$ java Liskov.java
Rectangle: expected area 20, got 20
Square: expected area 20, got 16

Square keeps its own rule (equal sides) by breaking Rectangle’s (width and height are independent). Code that is correct for every Rectangle now fails for one subclass. When a subclass must weaken the parent’s guarantees, it shouldn’t be a subclass.

Practice

  1. Add a Motorcycle extends Vehicle with a wheelie() method. Store one in a Vehicle variable and call accelerate and wheelie. Why doesn’t v.wheelie() compile, and how do you call wheelie safely?
Solution
public class Bikes {
    public static void main(String[] args) {
        Vehicle v = new Motorcycle("Ducati");
        v.accelerate(40);
        if (v instanceof Motorcycle m) m.wheelie();
    }
}

class Vehicle {
    protected final String brand; protected double speed;
    Vehicle(String brand) { this.brand = brand; }
    void accelerate(double amount) { speed += amount; System.out.println(brand + " at " + speed); }
}

class Motorcycle extends Vehicle {
    Motorcycle(String brand) { super(brand); }
    void wheelie() { System.out.println(brand + " pulls a wheelie"); }
}
$ java Bikes.java
Ducati at 40.0
Ducati pulls a wheelie

v.wheelie() wouldn’t compile: the compiler only knows the static type Vehicle, which has no such method. The pattern-matching instanceof checks the runtime type and gives a Motorcycle variable in one step.

  1. Make Shape.describe() final and try to override it in Circle. What happens?
Solution
public class FinalMethod {
    public static void main(String[] args) {
        System.out.println(new Circle().describe());
    }
}

class Shape {
    double area() { return 0; }
    final String describe() { return "area " + area(); }
}

class Circle extends Shape {
    @Override double area() { return 3.14; }
    @Override String describe() { return "a circle"; }
}
$ java FinalMethod.java
FinalMethod.java:14: error: describe() in Circle cannot override describe() in Shape
    @Override String describe() { return "a circle"; }
                     ^
  overridden method is final
1 error
error: compilation failed

area() can still be overridden; only describe() is locked. This is how a parent fixes an algorithm while leaving steps open.

  1. Replace Product/FixedProduct with a record Product(String name, int price) and repeat the HashSet.contains test.
Solution
import java.util.HashSet;
import java.util.Set;

public class RecordProduct {
    record Product(String name, int price) {}

    public static void main(String[] args) {
        Set<Product> set = new HashSet<>();
        set.add(new Product("Laptop", 999));
        System.out.println(set.contains(new Product("Laptop", 999)) + " " + set);
    }
}
$ java RecordProduct.java
true [Product[name=Laptop, price=999]]

Records generate equals, hashCode and toString from their components, so they are always consistent. Records are implicitly final, so they cannot be extended either.

  1. Fix the Liskov problem by giving Rectangle and Square a common interface Shape with area() and no setters, and make both immutable.
Solution
import java.util.List;

public class LiskovFixed {
    interface Shape { int area(); }
    record Rectangle(int w, int h) implements Shape { public int area() { return w * h; } }
    record Square(int side) implements Shape { public int area() { return side * side; } }

    public static void main(String[] args) {
        List<Shape> shapes = List.of(new Rectangle(5, 4), new Square(4));
        for (Shape s : shapes) System.out.println(s + " area " + s.area());
    }
}
$ java LiskovFixed.java
Rectangle[w=5, h=4] area 20
Square[side=4] area 16

Neither type promises independent setters any more, so neither can break the other’s contract.

Frequently Asked Questions

Does Java support multiple inheritance?
Not for classes — a class can only extend one parent class. However, a class can implement multiple interfaces, which is Java's mechanism for achieving multiple-inheritance behaviour.
When should I use inheritance vs composition?
Use inheritance only when a true 'is-a' relationship exists (a Dog IS an Animal). For 'has-a' relationships (a Car HAS an Engine), prefer composition. Overusing inheritance creates rigid, brittle hierarchies.
What does the super keyword do?
super refers to the parent class. Use super() to call the parent constructor, and super.methodName() to call the parent's version of an overridden method.
Why must hashCode be overridden whenever equals is?
Hash-based collections such as HashMap and HashSet first use hashCode to pick a bucket, then use equals inside it. If two equal objects return different hash codes, they land in different buckets, so a lookup for an equal key can fail even though the object is in the collection.