Polymorphism in Java
Understand runtime method dispatch, upcasting, downcasting, and instanceof pattern matching for flexible Java code, with practical examples.
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
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 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, orfinal - 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
- Confusing a base reference with a sliced object — Java retains the full object, but subclass-only members are not visible through the base type
- 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
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
instanceofor pattern matching. - Remember that static, private, and final methods do not participate in ordinary overriding.
Interview Questions
@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; ... }.Animal animal = new Dog(), which methods can you call, and which implementation runs?Animal, determines which methods are available at compile time. If Dog overrides one of those methods, the Dog implementation runs at runtime.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.final methods cannot be overridden.Further Reading
- Inheritance — subclassing and the
extendskeyword - 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 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.
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.