Back to Blog
Java

Java Class Inheritance: Syntax and Behavior Explained

java class inheritance: Understand Java class inheritance: extends, method overriding, super, access control, and object creation rules with practical examples.

inheritancemethod overridingsuper keywordabstract classespolymorphism
Illustration of a Java class hierarchy with a superclass and subclass connected by a line, representing inheritance and method overriding.

java class inheritance requires a clear understanding of the core syntax, runtime behavior, and practical implementation patterns demonstrated in the examples below.

In Java, class inheritance is the mechanism that lets one class acquire the fields and methods of another. The extends keyword establishes the relationship, and the compiler enforces strict rules about which members are visible and which methods can be replaced. This article focuses on the syntax and runtime behavior that matter when you actually write and maintain inherited code.

The extends Keyword and Single Inheritance

Java supports single inheritance for classes: a class can extend only one superclass. This is a deliberate design choice to avoid the ambiguities of multiple inheritance of state. The syntax is direct:

public class Animal { protected String name; public Animal(String name) { this.name = name; } public void speak() { System.out.println("Some sound"); } } public class Dog extends Animal { public Dog(String name) { super(name); } @Override public void speak() { System.out.println("Woof"); } }

The Dog class inherits the name field and the speak() method, but it overrides speak(). The @Override annotation is not required, but it lets the compiler verify that the method actually overrides a superclass method. If the signature does not match, compilation fails, which catches typos early.

Constructor Chaining and the super Keyword

When you create an instance of a subclass, the superclass constructor runs first. If the superclass has no no-argument constructor, the subclass must explicitly call a specific superclass constructor using super(...). If the superclass has a no-argument constructor, the compiler inserts an implicit super() call at the start of the subclass constructor.

Consider this example:

public class Vehicle { private final int wheels; public Vehicle(int wheels) { this.wheels = wheels; } public int getWheels() { return wheels; } } public class Car extends Vehicle { private final String model; public Car(String model) { super(4); this.model = model; } }

If Vehicle had no explicit constructor, Java would provide a default no-argument constructor, and Car would not need to call super(). But once you define a constructor with parameters, the default no-argument constructor disappears, so the subclass must call super(4). This is a common compile-time error for developers new to inheritance.

The super keyword also lets you call an overridden method from the superclass. A typical use is extending the superclass behavior rather than replacing it entirely:

public class SavingsAccount extends BankAccount { @Override public void deposit(double amount) { if (amount > 0) { super.deposit(amount); // add bonus logic } } }

This pattern preserves the original validation while adding subclass-specific behavior.

Method Overriding and Dynamic Dispatch

Method overriding is the core of polymorphism in Java. When you call a method on an object, the runtime selects the most specific override based on the actual object type, not the reference type. This is dynamic dispatch.

Animal a = new Dog("Rex"); a.speak(); // prints "Woof"

The reference type is Animal, but the object is a Dog, so Dog.speak() runs. This behavior is what enables code to be written against a superclass while still using subclass behavior at runtime.

For a method to override another, the method signature must match exactly, and the access level cannot be more restrictive. A public method in the superclass cannot become private in the subclass. A protected method can become public, but not private. This ensures that any code that could legally call the superclass method can also call the override.

Static methods are not polymorphic. If you define a static method with the same signature in a subclass, it hides the superclass method, but the choice is made at compile time based on the reference type, not the object type. This subtle difference often leads to confusion.

Access Control: private, protected, and Package-Private

Private members are never inherited. They exist in the superclass instance, but the subclass cannot reference them directly. Protected members are inherited and accessible from the subclass, even if the subclass is in a different package. Package-private (no modifier) members are inherited only if the subclass is in the same package.

Here is a practical example:

package com.example.animals; public class Mammal { protected int age; private String dna; void breathe() { } }

A subclass in another package can access age because it is protected, but it cannot access dna because it is private, and it cannot access breathe() because that is package-private. This design lets you expose extension points while keeping internal implementation details hidden.

Protected access also gives subclasses the ability to override helper methods that are not part of the public API. This is common in template method patterns where a base class defines an algorithm and subclasses fill in specific steps.

Preventing Overrides and Extension

The final keyword has two distinct uses in inheritance:

  • A final method cannot be overridden.
  • A final class cannot be extended at all.
public final class MathUtils { public static int clamp(int value, int min, int max) { return Math.max(min, Math.min(max, value)); } }

Making a class final is a strong statement about its design. It prevents subtyping and forces composition if you need to reuse its behavior. Many core Java classes, such as String and Integer, are final for security and immutability reasons. A final method is often used in a base class to enforce invariant behavior that subclasses must not change.

When you see a final method, it is worth respecting that boundary. Trying to "work around" it by changing the base class is usually a sign that the original design intended a fixed contract.

Abstract Classes and When to Use Them

An abstract class cannot be instantiated. It exists to provide a partial implementation that subclasses complete. This is different from an interface, which historically provided only abstract methods (though Java 8 added default and static methods).

public abstract class Report { protected String title; public void printHeader() { System.out.println("=== " + title + " ==="); } public abstract String formatData(); } public class CsvReport extends Report { public CsvReport(String title) { this.title = title; } @Override public String formatData() { return "Name,Value\nAlice,42"; } }

Here, printHeader() is concrete and reusable, while formatData() is abstract and forces each report type to provide its own formatting. Abstract classes are ideal when several subclasses share common state or behavior. They also allow protected constructors, which prevent direct instantiation but permit subclass construction.

A common rule of thumb: use an abstract class when the relationship is truly an "is-a" with shared code, and use an interface when you only need a contract. Since Java 8, interfaces can have static and default methods, which narrows the gap, but abstract classes still allow instance fields and constructors.

The protected Modifier and Package Access

There is a subtlety with protected that trips up many developers. A protected member is accessible within the same package and within any subclass, but the subclass access is limited to instances of the subclass type—not any instance of the superclass.

public class Base { protected int id; } public class Sub extends Base { void accessOther(Sub other) { System.out.println(other.id); // allowed } void accessBase(Base b) { // System.out.println(b.id); // compile error } }

In accessBase, the parameter is Base, not Sub. The compiler rejects it because the receiving object could be a completely different subclass located elsewhere. This rule protects the encapsulation of the superclass from arbitrary sibling classes.

Instantiation and Object Layout

When you write new Dog("Rex"), Java allocates an object that contains the fields of both Dog and Animal. The name field lives inside the same object, even though it is declared in Animal. This is why a subclass always requires a superclass constructor to run first—otherwise the inherited fields would not be initialized.

The constructor chain runs from the topmost superclass down to the most derived class. If a constructor throws an exception, the object is never fully created, and the caller receives the exception. This is why it is important to validate constructor arguments early.

Consider the classic initialization trap:

public class Parent { public Parent() { configure(); } protected void configure() { } } public class Child extends Parent { private int mode; public Child() { mode = 5; } @Override protected void configure() { System.out.println("mode is still " + mode); } }

When new Child() runs, Parent's constructor calls configure(), which dispatches to Child.configure() because the object is already a Child. At that point, mode has its default value 0, not 5. This is a known problem with calling overridable methods from constructors. The fix is to avoid calling any overrideable method in a constructor, or to use a factory method pattern.

When Inheritance Becomes Maintenance Overhead

Inheritance is not always the best tool. Deep hierarchies can make code difficult to follow, especially when subclasses override many methods and introduce subtle changes in behavior. A subclass that overrides a method to throw UnsupportedOperationException is a signal that the hierarchy may be wrong. Composition is often a better alternative because it allows you to change behavior at runtime without creating new classes.

For example, instead of extending a list class to add logging, you can wrap the list and delegate calls:

public class LoggingList implements List<String> { private final List<String> delegate; public LoggingList(List<String> delegate) { this.delegate = delegate; } @Override public boolean add(String s) { System.out.println("Adding " + s); return delegate.add(s); } // other methods delegate to the wrapped list }

This approach avoids the tight coupling of inheritance and works even if the underlying list is final. The decision between inheritance and composition should be driven by whether you genuinely need polymorphic behavior instead of just code reuse.

Inheritance and Type Checks

The instanceof operator and casts rely on the inheritance relationship. A subclass instance is also an instance of its superclass, which allows safe upcasting. Downcasting requires an explicit cast and an instanceof check to avoid ClassCastException:

Animal a = new Dog("Rex"); if (a instanceof Dog d) { d.fetch(); // safe after pattern matching }

Pattern matching for instanceof (introduced in Java 16) removes the need for a separate cast and is cleaner than the older style. Still, frequent instanceof checks in production code often indicate a missing polymorphic method—if each branch does something different, that logic might belong in the class itself.

From a runtime perspective, each object carries a reference to its class metadata, and instanceof performs a subtype check that walks up the class chain. This is generally fast, but it is not free. In performance-sensitive loops, you may want to avoid repeated instanceof checks and instead structure the code around polymorphism.

Compatibility and Evolution

Inheritance creates a tight contract between a superclass and its subclasses. Changing a superclass method can break subclasses in subtle ways. Adding a new method to a superclass that is not abstract can silently change behavior for subclasses that happen to have a method with the same signature. This is known as the fragile base class problem.

To mitigate this, prefer to make classes and methods final unless you explicitly intend them to be overridden. Document the contract that subclasses must respect. When you add a method to a base class, consider whether existing subclasses would benefit or break from inheriting it. If breaking changes are unavoidable, plan a migration path.

Java's default methods in interfaces allow adding methods without breaking implementers, but they still carry the same risk: an implementer may have a conflicting method that silently wins. Awareness of these issues is more important than memorizing syntax.

Final Code Example That Ties It Together

The following example brings together many concepts: protected fields, constructor chaining, method overriding, and polymorphic dispatch.

public abstract class PressOperator { protected final int pressure; public PressOperator(int pressure) { this.pressure = pressure; } public final void operate() { System.out.println("Applying pressure: " + pressure + " PSI"); apply(); } protected abstract void apply(); } public class HydraulicPress extends PressOperator { public HydraulicPress() { super(2000); } @Override public void apply() { // actual hydraulic mechanism } }

Note that pressure is protected final, so it is visible to subclasses and cannot be changed after construction. The operate() method is final to preserve the operation sequence, while apply() is abstract and left to subclasses. This is a template method pattern: the base class defines the algorithm, and subclasses supply the details. The final modifier on operate() prevents subclasses from altering the safety sequence, which is a deliberate design decision.

This structure keeps the invariant (pressure must be set at construction) and the extension point (the actual pressing behavior) in separate but clear locations. It is a realistic example of using inheritance to share code while retaining control.

java class inheritance: Practical Usage and Code Examples | RYUSLOG DEV