Polymorphism in Java

Understand runtime method dispatch, upcasting, downcasting, and instanceof pattern matching for flexible Java code.

published: reading time: 18 min read author: Geek Workbench
Quick Summary

Understand runtime method dispatch, upcasting, downcasting, and instanceof pattern matching for flexible Java code.

Polymorphism in Java

Polymorphism — “many forms” — is the ability of objects to be treated as instances of their parent type while executing the correct subclass method. It is the mechanism that makes interfaces usable and inheritance powerful.

Introduction

Polymorphism is what makes object-oriented programming scalable. Without polymorphism, every function that operates on different shapes would need explicit type checks and casts: if (shape instanceof Circle) cast and call circle.area(). With polymorphism, you write shape.area() and the correct implementation is chosen at runtime based on the actual object type. This is the difference between code that scales with new types and code that requires modification every time a new type is added.

Java supports two forms of polymorphism at the language level. Compile-time polymorphism — method overloading and generics — resolves at compile time based on the declared types. Runtime polymorphism — method overriding — resolves at runtime via virtual method dispatch, where the JVM looks up the actual object’s type in its class’s method table (vtable) and calls the appropriate implementation. The runtime form is what most people mean when they say “polymorphism” in Java.

The practical impact of polymorphism reaches every Java codebase. Frameworks rely on it for dependency injection, callbacks, and extension points. Collections hold Object or parameterized types and call overridden methods on elements. REST controllers call service methods that return interface types, with the actual implementation chosen at runtime. If you write Java long enough, you are writing polymorphic code whether you realize it or not.

This guide covers the mechanics of upcasting and downcasting, how virtual method dispatch works via the vtable, pattern matching for instanceof (Java 16+), and the failure scenarios that cause ClassCastException, slicing, and infinite recursion in polymorphic code.

When to Use

Use polymorphism when:

  • Writing code that operates on base types — functions that accept Shape work for Circle and Square
  • Adding new subtypes without modifying existing code — Open/Closed Principle
  • Implementing services that delegate to different implementations — strategy pattern
  • Building extensible frameworks — hooks and callbacks that subclasses customize
public class Shape {
    public double area() {
        return 0;
    }
}

public class Circle extends Shape {
    private final double radius;

    public Circle(double radius) {
        this.radius = radius;
    }

    @Override
    public double area() {
        return Math.PI * radius * radius;
    }
}

public class Square extends Shape {
    private final double side;

    public Square(double side) {
        this.side = side;
    }

    @Override
    public double area() {
        return side * side;
    }
}

// Polymorphic usage
public double totalArea(List<Shape> shapes) {
    double total = 0;
    for (Shape shape : shapes) {
        total += shape.area();  // Calls correct method for each type
    }
    return total;
}

List<Shape> shapes = List.of(new Circle(5), new Square(4));
System.out.println(totalArea(shapes));  // Works with both Circle and Square

When Not to Use

Avoid polymorphism when:

  • Simple conditional logic — don’t create classes just to avoid if/else
  • Unrelated types — don’t force common interface where none exists
  • Performance-critical tight code — virtual dispatch has small overhead (usually negligible)
  • Static behavior — methods that should never be overridden don’t need polymorphism

Compile-Time Polymorphism — Mermaid Diagram

flowchart TD
    A[Compile-Time Polymorphism] -->|Method Overloading| B[same name, different params]
    A -->|Generics| C[type parameters]

Runtime Polymorphism — Mermaid Diagram

flowchart TD
    D[Runtime Polymorphism] -->|Method Overriding| E[dynamic dispatch via vtable]
    D -->|Interface| F[different implementations same contract]

Failure Scenarios

1. Slicing — Losing Subclass Data

Slicing happens when you assign a subclass instance to a superclass variable by value. Java copies only the superclass portion of the object, discarding any fields that exist only in the subclass. This is not a runtime error. It silently loses data, and you only find out when the compiler refuses to let you access the discarded members.

When you write Base b = new Derived(), the right-hand side is a Derived object, but the left-hand side is typed as Base. Java copies only the Base part. The derivedField and any subclass-specific state stop being accessible through the Base reference, even though the object in memory is still a complete Derived.

The upcast itself is legal — a Derived object is-a Base — so the compiler does not warn you. The data loss stays invisible until you try to access subclass members and the compiler tells you they do not exist.

public class Base {
    public int baseField = 1;
}

public class Derived extends Base {
    public int derivedField = 2;
}

// Slicing — object is copied as Base, losing Derived parts
Base b = new Derived();  // Allowed: upcasting
System.out.println(b.baseField);    // Works: 1
System.out.println(b.derivedField);  // COMPILE ERROR: Base doesn't have derivedField

// Fix: if you need derived behavior, use the derived type
Derived d = new Derived();  // Or downcast carefully

Slicing most commonly surfaces when passing subclass objects to methods that accept superclass types by value, or when storing them in collections typed as the superclass. Use interfaces or preserve the full reference type when you need access to subclass-specific members.

2. Incorrect Downcasting

Downcasting becomes necessary when you need to use members that exist only on the subclass. The risk is that the superclass reference might point to a completely different subclass object, which causes a ClassCastException at runtime. Upcasting is safe by design; downcasting requires you to verify the type first.

The compiler cannot check the actual object type at compile time, so it allows any downcast between related types. Only at runtime does the JVM compare the object’s true class against the target type. If they do not match, you get a ClassCastException. In practice, the Dog-and-Cat mistake is obvious, but real hierarchies are deeper and the mismatched type is not always so apparent.

Guard every downcast with an instanceof check, or use pattern matching in Java 16 and later. The instanceof operator returns false when the object is not an instance of the target type, giving you a safe place to branch.

public class Animal { }
public class Dog extends Animal { }
public class Cat extends Animal { }

Animal animal = new Dog();

// Wrong downcast — animal is a Dog, not a Cat
Cat cat = (Cat) animal;  // Compiles, but throws ClassCastException at runtime

// Safe approach: check before casting
if (animal instanceof Cat) {
    Cat safeCat = (Cat) animal;  // Only executes if animal is actually a Cat
}

// Even safer: pattern matching (Java 16+)
if (animal instanceof Cat c) {
    System.out.println("Meow: " + c);  // c is scoped and typed here
}

3. Calling Non-Polymorphic Methods on References

When you hold a subclass object through a superclass reference, the compiler only sees the superclass surface area. It resolves method calls against the reference type, not the object behind it, and only for methods that exist on the superclass. Methods defined only on the subclass are invisible through that reference, producing a compile error no matter what the object actually is.

Here is the key difference: overridden methods are polymorphic, so p.display() calls Child.display() even with a Parent reference. But childOnlyMethod() exists only on Child, so the compiler refuses to see it through Parent. The @Override annotation on display() is what marks it as participating in virtual dispatch. Without that annotation, a same-named method in the subclass would be a separate method, not an override.

The compile error is Java protecting you from a bad call. You cannot invoke a method the compiler cannot verify exists on the reference type. To call childOnlyMethod(), you must cast to Child, which restores access at the cost of reintroducing the ClassCastException risk.

public class Parent {
    public void display() {
        System.out.println("Parent.display()");
    }
}

public class Child extends Parent {
    public void display() {
        System.out.println("Child.display()");
    }

    public void childOnlyMethod() {
        System.out.println("Only Child has this");
    }
}

Parent p = new Child();
p.display();      // Calls Child.display() — polymorphic
p.childOnlyMethod(); // COMPILE ERROR — Parent reference doesn't know this method

// Fix: downcast to access child-specific methods
((Child) p).childOnlyMethod();  // Works, but be sure of actual type

A cleaner design avoids this pattern by exposing only the interface the superclass declares. If Child needs to offer behavior Parent does not, consider an interface that both implement, or a method on Parent that Child overrides with its specific behavior.

Trade-off Table

Casting TypeSafeRequiresUse When
Upcasting (DerivedBase)Always safeNothingStoring different types in same collection
Downcasting (BaseDerived)Unsafeinstanceof checkAccessing subclass-specific methods
Pattern matching (instanceof)SafePattern variable scopedJava 16+ cleaner syntax
record patternsSafePattern matchJava 16+ deconstruction

Code Snippets

Safe Downcasting with instanceof (Pre-Java 16)

public void processShape(Shape shape) {
    if (shape instanceof Circle) {
        Circle circle = (Circle) shape;  // Safe: checked above
        System.out.println("Circle radius: " + circle.getRadius());
    } else if (shape instanceof Square) {
        Square square = (Square) shape;
        System.out.println("Square side: " + square.getSide());
    }
}

Pattern Matching for instanceof (Java 16+)

public void processShape(Shape shape) {
    // Pattern variable 'circle' is scoped and typed within the block
    if (shape instanceof Circle circle) {
        System.out.println("Circle radius: " + circle.getRadius());
    } else if (shape instanceof Square square) {
        System.out.println("Square side: " + square.getSide());
    }
}

// Can use in switch too (Java 21+)
public String describe(Shape shape) {
    return switch (shape) {
        case Circle c -> "Circle with radius " + c.getRadius();
        case Square s -> "Square with side " + s.getSide();
        case null, default -> "Unknown shape";
    };
}

Virtual Method Dispatch

public class Greeter {
    public void greet() {
        System.out.println("Hello!");
    }
}

public class SpanishGreeter extends Greeter {
    @Override
    public void greet() {
        System.out.println("Hola!");
    }
}

public class FrenchGreeter extends Greeter {
    @Override
    public void greet() {
        System.out.println("Bonjour!");
    }
}

// The correct method is chosen at runtime based on actual object type
Greeter g1 = new SpanishGreeter();
Greeter g2 = new FrenchGreeter();
g1.greet();  // Prints "Hola!" — SpanishGreeter.greet()
g2.greet();  // Prints "Bonjour!" — FrenchGreeter.greet()

Observability Checklist

  • Methods intended for overriding marked virtual (non-final)
  • Downcasting always preceded by instanceof check (or pattern matching)
  • Avoid calling subclass-specific methods through base type references
  • Prefer interfaces over concrete types for method parameters
  • Test polymorphic behavior with different subtypes

Security Notes

  • Don’t trust type at face value — use instanceof before downcasting
  • Null checksinstanceof returns false for null, but verify null isn’t expected
  • Reflection bypass — can bypass normal polymorphism; avoid reflection for type-safe operations
  • Malicious subclasses — overridden methods can behave unexpectedly; seal classes if needed
public class SecureProcessor {
    // Use sealed classes to limit inheritance (Java 17+)
    public sealed class Processor permits CreditProcessor, DebitProcessor {
        public abstract void process(Transaction t);
    }

    public final class CreditProcessor extends Processor { }
    public final class DebitProcessor extends Processor { }

    public void handle(Processor p, Transaction t) {
        // Compiler ensures only known subtypes can exist
        // No unexpected subclasses possible
    }
}

Pitfalls

  1. Slicing — when assigning subclass to superclass, fields specific to subclass are lost
  2. Type confusion — not checking actual type before downcasting causes ClassCastException
  3. Public fields in hierarchies — fields don’t participate in polymorphism, only methods do
  4. Confusing overloading with overriding — overloading is compile-time, overriding is runtime
  5. Forgetting that private methods are not overridden — they’re just hidden, not polymorphic
public class Base {
    private void hidden() { System.out.println("Base.hidden"); }
    public void call() { hidden(); }  // Calls Base.hidden(), not overridable
}

public class Derived extends Base {
    public void hidden() { System.out.println("Derived.hidden"); }  // Not overriding — different method
}

Base b = new Derived();
b.call();  // Prints "Base.hidden" — private methods are not virtual in Java

Quick Recap

  • Upcasting = Derived to Base — always safe, implicit
  • Downcasting = Base to Derived — requires instanceof check to avoid ClassCastException
  • Virtual dispatch = method implementation chosen at runtime based on actual object type, not reference type
  • Pattern matching = Java 16+ cleaner syntax for instanceof with automatic variable scoping
  • Only overridden methods participate in polymorphism — private and static methods are not polymorphic

Interview Questions

1. What is the difference between upcasting and downcasting?
Upcasting is converting a subclass reference to a parent class reference (e.g., Dog to Animal). This is always safe and done implicitly. Downcasting is converting a parent reference back to a subclass reference (e.g., Animal to Dog). This requires an instanceof check to avoid ClassCastException at runtime."

2. How does runtime polymorphism work in Java?
Java uses a virtual method table (vtable) for each class. When an object is created, it carries a reference to its class's vtable. When a method is called on an object reference, Java looks up the actual object's type at runtime and calls the overridden method from its vtable, not the reference type's vtable."

3. What is method overloading vs method overriding?
Overloading is compile-time polymorphism — multiple methods with the same name but different parameter lists in the same class. Overriding is runtime polymorphism — a subclass provides a specific implementation of a method already defined in its parent class, marked with @Override."

4. Why should you always check instanceof before downcasting?
Because a parent reference may point to an object of a different subclass type. Without checking, downcasting throws ClassCastException at runtime, which is a runtime error. Using instanceof (or pattern matching in modern Java) ensures you only cast when the actual object type supports it."

5. Can static methods be overridden?
No. Static methods belong to the class, not the instance, and are not polymorphic. If a subclass defines a static method with the same signature, it 'hides' the parent's method, it doesn't override it. Calling through a parent reference gets the parent's method; calling through a child reference gets the child's method."

6. What is the vtable and how does it enable runtime polymorphism?
Virtual method table is a lookup table each class maintains for overridden methods. Every object carries a pointer to its class vtable at a fixed memory offset. When method is called, JVM looks up actual object type's vtable to find correct method address."

7. Can polymorphism be achieved without inheritance?
Yes — through interfaces, which provide contract without requiring class hierarchy. Composition with interface-typed collaborators also achieves polymorphic behavior. Strategy pattern uses interface references that can point to different implementations."

8. What is the difference between early binding and late binding?
Early binding (static binding) happens at compile time — method resolution is static. Late binding (dynamic binding) happens at runtime — method resolved based on actual object type. Overloaded methods use early binding; overridden methods use late binding."

9. How does Java handle method dispatch for overloaded methods?
Overload resolution happens at compile time based on argument types. Compiler determines which overloaded version to call based on declared argument types. Runtime polymorphism does not affect overloaded method selection."

10. What happens when a superclass method is declared final — can subclasses still behave polymorphically?
Final methods cannot be overridden — they use static binding (early binding). Subclass instances can still be stored in superclass variables (polymorphic storage). But the final method call resolves at compile time to the parent version."

11. What is the relationship between polymorphism and the Open/Closed Principle?
Open/Closed Principle: classes should be open for extension, closed for modification. Polymorphism enables extension by allowing new subclasses to be added without changing existing code. Code operates on abstractions (parent type) — new subtypes work without code changes."

12. What is the purpose of the @Override annotation and how does it relate to polymorphism?
@Override tells compiler to verify the method actually overrides a parent method. Catches typos in method names at compile time before runtime. Without @Override, a misspelled method would be a new method, not an override — breaking polymorphic behavior."

13. Can an interface reference hold implementations that were created before the interface existed?
Yes — any class implementing the interface can be assigned to interface reference regardless of when it was created. This is the essence of polymorphism — code depends on contract, not implementation date. Retroactive interface implementation is common when adding interfaces to existing classes."

14. What is the difference between polymorphism and inheritance hierarchy depth?
Polymorphism works at any hierarchy depth — object can be stored as any ancestor type. Deep hierarchies can complicate understanding but don't affect polymorphic mechanism. Virtual dispatch walks the chain at runtime to find the overridden method."

15. Why should you prefer interfaces over concrete types for method parameters?
Interface-typed parameters accept any implementation — more flexible. New implementations can be added later without changing the method signature. Example: List vs ArrayList — method accepting List can work with LinkedList, ArrayList, etc."

16. What is polymorphic assignment and what are its safety rules?
Polymorphic assignment: storing subclass instance in superclass variable (upcasting). Always safe and implicit — no instanceof check needed. Opposite (downcasting) requires instanceof check to avoid ClassCastException."

17. What is the diamond problem in multiple inheritance and how does Java handle it with polymorphism?
Java doesn't support multiple class inheritance, avoiding diamond problem for state. Interface default methods can cause diamond problem if two interfaces have same default method. Class must explicitly resolve which default to use via InterfaceName.super.methodName()."

18. How does pattern matching for instanceof improve upon the classic instanceof check?
Pattern matching combines check and cast into one expression. Pattern variable is scoped within the if block — no accidental use outside. Cleaner than: if (obj instanceof String) { String s = (String) obj; ... }."

19. What is the relationship between encapsulation, inheritance, and polymorphism as OOP pillars?
Encapsulation protects internal state — prerequisite for safe inheritance. Inheritance enables code reuse and 'is-a' relationships. Polymorphism makes inheritance useful — code operates on abstractions, not concrete types. All three work together — polymorphism builds on encapsulation and inheritance."

20. Can private methods participate in polymorphic dispatch?
No — private methods are not visible to subclasses, so cannot be overridden. If subclass defines same-named private method, it's a new method, not an override. Private methods are resolved at compile time via static binding."

Further Reading

Conclusion

Polymorphism allows objects to be treated as instances of their parent type while executing the correct subclass method at runtime. Upcasting (Derived to Base) is always safe and implicit; downcasting (Base to Derived) requires an instanceof check to avoid ClassCastException. Virtual method dispatch uses the vtable mechanism to select the correct implementation based on the actual object’s type, not the reference type.

Compile-time polymorphism includes method overloading (same name, different parameters) and generics. Runtime polymorphism centers on method overriding, where subclasses provide specific implementations of parent methods. Pattern matching for instanceof (Java 16+) simplifies type checking with automatic variable scoping. Only overridden instance methods participate in polymorphism — private, static, and final methods do not.

Polymorphism is the mechanism that makes interfaces useful and inheritance powerful, allowing code to operate on abstractions rather than concrete types.

Category

Related Posts

Abstract Classes in Java

Learn about partially implemented classes that define contracts for subclasses using abstract methods and concrete implementations.

#java-abstract-classes #java #java-fundamentals

Arithmetic Operators in Java

Master Java arithmetic operators: addition, subtraction, multiplication, division, and modulo with integer division gotchas and operator precedence explained.

#java-arithmetic-operators #java #java-fundamentals

Array Basics in Java

Learn Java array fundamentals: declaration, initialization, element access, and the length property explained simply.

#java-array-basics #java #java-fundamentals