Polymorphism in Java
Understand runtime method dispatch, upcasting, downcasting, and instanceof pattern matching for flexible Java code.
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
Shapework forCircleandSquare - 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 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 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
instanceofcheck (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
instanceofbefore downcasting - Null checks —
instanceofreturns 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
- Slicing — when assigning subclass to superclass, fields specific to subclass are lost
- Type confusion — not checking actual type before downcasting causes ClassCastException
- Public fields in hierarchies — fields don’t participate in polymorphism, only methods do
- Confusing overloading with overriding — overloading is compile-time, overriding is runtime
- 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 =
DerivedtoBase— always safe, implicit - Downcasting =
BasetoDerived— requiresinstanceofcheck 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
instanceofwith automatic variable scoping - Only overridden methods participate in polymorphism — private and static methods are not polymorphic
Interview Questions
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."@Override."instanceof before downcasting?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."@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."if (obj instanceof String) { String s = (String) obj; ... }."Further Reading
- Inheritance — subclassing and the extends keyword
- Abstract Classes — abstract base classes and methods
- Interfaces — defining contracts with interface types
- Encapsulation — data hiding with access modifiers
- Composition Over Inheritance — when to prefer composition
- Oracle: Polymorphism — official documentation on polymorphic behavior
- Effective Java: Item 40 — use hierarchical type names to clarify behavior
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.
Arithmetic Operators in Java
Master Java arithmetic operators: addition, subtraction, multiplication, division, and modulo with integer division gotchas and operator precedence explained.
Array Basics in Java
Learn Java array fundamentals: declaration, initialization, element access, and the length property explained simply.