Polymorphism in Java

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

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

Polymorphism lets code call shared operations through a base type while Java dispatches overridden methods on the actual object. This guide compares overloading and overriding, explains safe upcasts and checked downcasts, and shows how pattern matching simplifies type checks. Use the examples to avoid ClassCastException and choose interfaces when multiple implementations need to share a contract.

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 compile-time and runtime polymorphism. Overload selection and generic type checks happen at compile time. For an overridden instance method, the JVM selects the implementation using the actual object’s class, even when the reference has a superclass or interface type. This runtime dispatch is what most people mean by polymorphism in Java.

This guide covers upcasting and downcasting, runtime method dispatch, pattern matching for instanceof (Java 16+), and common errors such as ClassCastException and accidentally overloading instead of overriding.

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 by object class]
    D -->|Interface| F[different implementations same contract]

Failure Scenarios

1. Base-Type References Hide Subclass-Only Members

Java does not slice objects when you assign a subclass instance to a superclass reference. Base b = new Derived() still refers to a complete Derived object. The reference type controls which members are available at compile time, so derivedField cannot be accessed through b without a checked downcast. Overridden methods still dispatch to the implementation on the actual object.

This distinction matters when designing an API: a base type should expose the operations callers need across all implementations. If callers repeatedly cast to reach subclass-only behavior, consider whether that behavior belongs in the shared interface or in a separate capability interface.

public class Base {
    public int baseField = 1;
}

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

// Upcasting keeps the complete Derived object
Base b = new Derived();  // Allowed: the reference type is Base
System.out.println(b.baseField);  // Works: 1
System.out.println(b.derivedField);  // COMPILE ERROR: Base does not declare derivedField

// Use a checked downcast when subtype-specific access is necessary
if (b instanceof Derived d) {
    System.out.println(d.derivedField);  // Works: 2
}

The object remains a Derived when passed to a method or stored in a List<Base>; only the static type of that reference is Base. Prefer methods on the shared type for common behavior, and use a checked downcast only when subtype-specific access is genuinely required.

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 Type Safe Requires Use When
Upcasting (Derived → Base) Always safe Nothing Storing different types in same collection
Downcasting (Base → Derived) Unsafe instanceof check Accessing subclass-specific methods
Pattern matching (instanceof) Safe Pattern variable scoped Java 16+ cleaner syntax
record patterns Safe Pattern match Java 21+ 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()

Production Failure Scenarios

Polymorphic dispatch can fail quietly when a base implementation looks like a valid result. A common example is routing events through handlers: an unrecognized subtype reaches a fallback handler, which returns an empty response. The request appears successful, but the event was never processed.

interface EventHandler {
    boolean supports(Event event);
    void handle(Event event);
}

final class PaymentHandler implements EventHandler {
    public boolean supports(Event event) { return event instanceof PaymentEvent; }
    public void handle(Event event) { charge((PaymentEvent) event); }
}

final class FallbackHandler implements EventHandler {
    public boolean supports(Event event) { return true; }
    public void handle(Event event) {
        // Returning normally makes an unsupported event look processed.
        log.warn("No handler for {}", event.getClass().getName());
    }
}

EventHandler selected = handlers.stream()
    .filter(handler -> handler.supports(event))
    .findFirst()
    .orElse(fallbackHandler);
selected.handle(event);

The symptom is a growing queue or missing side effects with no failed request, because the fallback absorbs an event that should have been rejected or retried. Make fallback behavior explicit: record a metric, move the event to a dead-letter queue, and return a failure that the caller can retry. Test dispatch with every registered event subtype, including one that has no handler.

Security and Compliance Notes

Polymorphic policy objects are useful for authorization, but a permissive default turns an unknown tenant or new policy subtype into an access grant. This can happen after a configuration rollout when the application knows about a policy implementation that the policy registry does not yet contain.

interface AccessPolicy {
    boolean allows(User user, Resource resource);
}

final class DenyByDefaultPolicy implements AccessPolicy {
    public boolean allows(User user, Resource resource) {
        return false;
    }
}

AccessPolicy policy = policies.getOrDefault(
    tenantId,
    new DenyByDefaultPolicy()
);

if (!policy.allows(user, resource)) {
    throw new ForbiddenException("Access denied");
}

If policy selection fails, the request is denied and the missing tenant mapping can be logged for investigation. Keep policy selection separate from policy execution, and include the selected policy identifier and version in authorization audit records. Avoid logging credentials or sensitive resource data. Tests should cover unknown tenants, missing policy registrations, and each policy implementation’s allow and deny cases.

Common Pitfalls and Anti-Patterns

One dispatch bug comes from overloading a method in a subclass instead of overriding it. The code compiles, but the call through a base reference keeps using the base method because overload resolution uses the reference’s compile-time type.

class Formatter {
    String format(Object value) { return "generic"; }
}

class JsonFormatter extends Formatter {
    // This overload does not override format(Object).
    String format(String value) { return "json:" + value; }
}

Formatter formatter = new JsonFormatter();
String result = formatter.format("payload"); // "generic"

If the subtype is meant to replace base behavior, keep the same signature and add @Override; the compiler will catch accidental signature drift. Another risky pattern is catching every exception from a selected implementation and silently switching to a permissive fallback. That hides broken dispatch and can bypass policy decisions. Limit fallback to errors that are safe to recover from, make the fallback’s behavior explicit, and let authorization or data-integrity failures stop the request.

Observability Checklist

  • Methods intended for overriding are not private, static, or 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 checks — instanceof 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. Confusing a base reference with a sliced object — Java retains the full object, but subclass-only members are not visible through the base type
  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

Quick Recap Checklist

  • Distinguish overloading, which is resolved at compile time, from overriding, which uses runtime dispatch.
  • Use a base-class or interface reference when callers need shared behavior across implementations.
  • Before downcasting, check the object’s type with instanceof or pattern matching.
  • Remember that static, private, and final methods do not participate in ordinary overriding.

Interview Questions

1. 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.
2. How does Java choose an overridden method at runtime?
The reference type determines which instance methods can be called at compile time. For an overridable instance method, runtime dispatch selects the implementation for the object's actual class. The Java language specifies this behavior without requiring a particular internal lookup structure.
3. 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.
4. 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.
5. 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.
6. 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.
7. 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.
8. 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.
9. 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.
10. 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.
11. 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.
12. 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.
13. 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().
14. 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; ... }.
15. 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.
16. 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.
17. How do overloading and overriding differ?
Overloading gives methods the same name with different parameter lists; the compiler chooses which one to call. Overriding replaces an inherited instance method, and runtime dispatch selects the implementation based on the object's actual class.
18. If Animal animal = new Dog(), which methods can you call, and which implementation runs?
The reference type, Animal, determines which methods are available at compile time. If Dog overrides one of those methods, the Dog implementation runs at runtime.
19. When is a downcast safe in Java?
Check the object's type with instanceof before casting, or use pattern matching such as if (animal instanceof Dog dog). The cast is valid only when the object really is a Dog (or one of its subclasses); otherwise Java throws ClassCastException.
20. Do static methods participate in runtime polymorphism?
No. Static methods belong to the class, so a same-signature method in a subclass hides the parent method. Overridden instance methods use runtime dispatch; private methods cannot be overridden, and final methods cannot be overridden.

Further Reading

Conclusion

Polymorphism lets Java code use a parent or interface type while runtime dispatch calls the implementation on the actual object. Upcasting preserves the full object; downcasting needs a type check to avoid ClassCastException. Pattern matching for instanceof (Java 16+) combines a type check and binding into a clear expression. Only eligible overridden instance methods use runtime dispatch; static, private, and final methods do not.

Use abstractions when several implementations share behavior, and keep subtype-specific access behind a checked cast or a narrower interface. This is why interfaces make polymorphic code useful and why composition over inheritance can reduce the cost of changing a hierarchy.

Summary

Polymorphism lets Java code call shared behavior through a base type while the object’s class supplies the overridden implementation. Upcasts are safe; downcasts need a type check. Use interfaces and overrides for interchangeable behavior, and reach for casts only when subtype-specific access is truly needed.

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