Back to Blog
Java

Java Hierarchical Inheritance

Learn how java hierarchical inheritance organizes classes, how to design parent-child hierarchies, and how to avoid common pitfalls like method overriding misuse.

InheritanceClass HierarchyPolymorphismMethod OverridingConstructors
A diagram showing a parent class at the top with three child classes branching below it, illustrating hierarchical inheritance in Java.

When you model a domain in Java, you often need to express an "is-a" relationship: a Car is a Vehicle, a Dog is an Animal, and a SavingsAccount is a BankAccount. Java supports this through class inheritance. In java hierarchical inheritance, multiple child classes extend the same parent class, forming a tree-like structure where the parent holds shared state and behavior, and each child adds or specializes its own. This article focuses on the design, syntax, and runtime behavior of such hierarchies, and where they commonly break in real codebases.

The Core Pattern: One Parent, Many Children

Hierarchical inheritance is the most frequent inheritance shape in Java. The parent class defines fields and methods that apply to all subclasses; each subclass extends the parent and can override methods or add new ones. The key benefit is code reuse and a consistent contract: code written against the parent type can accept any child instance.

Consider a simple domain: a PaymentMethod parent with CreditCard and PayPal children.

public class PaymentMethod { protected String ownerName; public PaymentMethod(String ownerName) { this.ownerName = ownerName; } public boolean processPayment(double amount) { // Common validation logic if (amount <= 0) { throw new IllegalArgumentException("Amount must be positive"); } return charge(amount); } protected boolean charge(double amount) { throw new UnsupportedOperationException("Subclass must implement charge"); } }

Now two child classes extend it:

public class CreditCard extends PaymentMethod { private String cardNumber; public CreditCard(String ownerName, String cardNumber) { super(ownerName); this.cardNumber = cardNumber; } @Override protected boolean charge(double amount) { // pretend to call a payment gateway System.out.println("Charging " + amount + " to credit card " + cardNumber); return true; } } public class PayPal extends PaymentMethod { private String email; public PayPal(String ownerName, String email) { super(ownerName); this.email = email; } @Override protected boolean charge(double amount) { System.out.println("Charging " + amount + " to PayPal account " + email); return true; } }

Each child inherits ownerName and the processPayment method, but supplies its own implementation of the charge step. This is the essence of hierarchical inheritance: the parent defines the workflow, and the children plug in the details.

How Constructors Behave in a Hierarchy

A common stumbling block is constructor chaining. In Java, every constructor implicitly calls super() as its first statement, unless you explicitly call super(...). This means that when you create a CreditCard object, the PaymentMethod constructor runs first, then the CreditCard constructor.

If the parent does not have a no-arg constructor, you must explicitly call a parameterized super constructor. In the example above, super(ownerName) passes the owner's name up. Forgetting this causes a compile error: "constructor PaymentMethod() is undefined".

Also be careful with instance method calls inside a constructor. If a constructor calls an overridable method, the child's override will run before the child's instance variables are initialized. In the earlier example, if PaymentMethod's constructor had called charge(), the child's charge() would execute with cardNumber still null, because the child constructor hasn't run yet. The fix is to avoid calling overridable methods from constructors.

Overriding Rules: What You Can and Cannot Change

Method overriding is the mechanism that lets hierarchical inheritance achieve polymorphism. The overriding method must have the same name, parameter list, and return type (or a covariant return type). The access level cannot be more restrictive than the parent's. If the parent method is protected, the child cannot make it private; it can make it public. The child also cannot throw broader checked exceptions than the parent.

A common error is trying to override a static method. Static methods are bound at compile time, not runtime, so they cannot be overridden—they can only be hidden. If you declare a static method with the same signature in the child, it is a new method, and the compiler will warn if you use @Override. Similarly, final methods cannot be overridden at all.

Here's an example that fails to compile:

public class Animal { public final void eat() { System.out.println("Eating"); } } public class Dog extends Animal { @Override public void eat() { // error: cannot override final method } }

The final keyword on a method is a deliberate design choice: it locks the behavior so subclasses cannot change it. Use it when the parent's implementation must be invariant across all children.

Type Compatibility and Casting

Because every child is a subtype of the parent, you can assign a child instance to a parent reference:

PaymentMethod method = new CreditCard("Alice", "1234-5678");

This is an upcast, and it is always safe. However, the reverse—a downcast—is not always safe. If you have a PaymentMethod reference, you cannot simply cast it to CreditCard unless the actual object is a CreditCard. A wrong downcast throws ClassCastException at runtime.

To avoid that, use instanceof before casting. But be aware of fragile conditions: instanceof returns true if the object is the same class or a subclass. For example, if you have a third class RewardsCard extends CreditCard, then rewardsCard instanceof CreditCard is true. The instanceof operator does not make a class final, and it does not tell you the exact runtime type. If you need to know the precise class, compare getClass() directly.

The Contract of equals and hashCode in a Hierarchy

When you override equals() in a child class, you must respect the symmetry requirement. Consider a parent Person with an id field and a child Employee that also has a department. If you implement equals() in each class, you might write something like this:

// In Person public boolean equals(Object obj) { if (!(obj instanceof Person)) return false; return this.id == ((Person) obj).id; } // In Employee public boolean equals(Object obj) { if (!(obj instanceof Employee)) return false; return super.equals(obj) && this.department.equals(((Employee) obj).department); }

Now person.equals(employee) can be true (since employee is a Person), but employee.equals(person) is false, breaking symmetry. This is a classic hierarchical pitfall. The common workaround is to use getClass() for equality checks instead of instanceof, or to design your hierarchy so that equality is only meaningful among same-typed instances. Also, whenever you override equals, you must override hashCode so that equal objects have equal hash codes. In a hierarchy, this is tricky because the hash code contract requires that equal objects return the same value; if you include subclass fields in the hash, two equal objects might have different hash codes if the parent's hashCode doesn't consider them.

The safe approach is: if the parent and child cannot be equal to each other (which you enforce with getClass()), then each class's hashCode can include only its own fields, and you must ensure the parent's hashCode and child's hashCode are consistent. A practical rule is to compute the hash only on fields defined in the class itself, and call super.hashCode() in the child so that equal parent state yields the same base component.

Overengineering and When to Avoid Inheritance

Hierarchical inheritance can be overused. If your child classes do not truly share behavior, or if they are only related to satisfy a type check, composition is usually better. For example, a Dog is an Animal, but a Dog that has a Tail should not extend Tail. Favor composition when the relationship is "has-a" rather than "is-a".

Also be wary of deep hierarchies. A chain like A extends B extends C extends D multiplies the complexity: every constructor runs up the chain, and any change in the top class can ripple down. The Java language itself uses single inheritance for classes, so you cannot have a diamond problem, but you can still create classes that are tightly coupled to their ancestors.

When you see a child class that overrides many methods and does not use most of the inherited ones, that signals the hierarchy is wrong. The parent class should represent a contract that all children honor, not a grab-bag of unrelated methods.

Compatibility and the sealed Keyword

Starting with Java 17, you can restrict which classes are allowed to extend a given parent using sealed classes. This is a major design tool for hierarchical inheritance. By default, any class can be extended unless you mark it final. Sealed classes let you declare an explicit, finite set of direct subclasses:

public sealed class Shape permits Circle, Rectangle, Triangle { // ... } public final class Circle extends Shape { } public final class Rectangle extends Shape { } public final class Triangle extends Shape { }

This constrains the hierarchy at compile time. Code that switches on the exact type can be exhaustive without a default branch, and the compiler knows the complete set of subclasses. The permits list must match the actual subclasses in the same module; if a subclass is omitted, compilation fails.

This is useful when you want to model a closed set of variants, like a syntax tree or a mathematical expression. For open domains where you expect third-party extensions, sealed is too restrictive—simply leave the class non-sealed or omit the keyword.

Runtime Cost and Maintainability Tradeoffs

Hierarchical inheritance has minimal direct runtime cost: virtual method dispatch is a single vtable lookup, and constructors add no extra overhead beyond the normal call chain. The real cost is maintainability. A poorly designed hierarchy can become brittle because a change in the parent method affects all descendants. For example, changing the signature of a public method in the parent silently breaks overrides unless they use the same signature.

When you need to add a new child type, you must understand the parent's invariants. If the parent assumes certain fields are initialized in a certain order, adding a new child that violates that assumption can cause subtle bugs. The @Override annotation is crucial: it forces the compiler to check that you are actually overriding, not accidentally overloading. Overloading occurs when the parameter list differs, and it is a frequent source of confusion. If you think you are overriding but the method signature does not match exactly, you will silently create a new method that is never called.

A concrete example of a mistake:

public class Animal { public void speak(String sound) { System.out.println(sound); } } public class Dog extends Animal { // This is an overload, not an override public void speak() { System.out.println("Woof"); } }

Calling speak("bark") on a Dog reference invokes the parent's method, not the no-arg version. The @Override annotation on the child's speak() would produce a compile error, alerting you to the mistake.

In production, the most important rule is to keep the parent class stable. Once you publish a class as the supertype of a hierarchy, changing its behavior can break all clients. Prefer to add new methods in the children rather than modifying the parent's existing method semantics. If you must change the parent, consider deprecating the old method and providing a new default implementation that delegates.

Finally, remember that inheritance is not the only tool. Java's interface types allow multiple inheritance of type, and default methods provide implementation reuse without a class hierarchy. When your objects share behavior but do not have a strict "is-a" relationship, an interface with default methods may be a better fit than a class hierarchy. The choice between the two affects how you handle state: interfaces have no instance fields (except static final constants), so if you need shared state, a class hierarchy is necessary.

The practical takeaway for java hierarchical inheritance is to model genuine specialization, keep the hierarchy shallow, respect the overriding and constructor rules, and design equality carefully. Used that way, it remains a powerful tool for organizing polymorphic behavior in a maintainable manner.

java hierarchical inheritance: Practical Usage and Code Exam | RYUSLOG DEV