Polymorphism

A complete guide to polymorphism in Java: compile-time polymorphism through method overloading, runtime polymorphism through method overriding, upcasting and downcasting, dynamic method dispatch, safe casting with instanceof, Java 16 pattern matching, and polymorphism applied to arrays and collections.

What is Polymorphism?

The word polymorphism comes from Greek and means "many forms." In Java, it refers to the ability of a single method name or reference variable to represent different underlying implementations depending on context. It is the feature that lets you write code against a general type and have the correct specific behaviour execute automatically without needing to know which concrete type is involved at the call site.

Java supports two distinct flavours of polymorphism. The first is resolved at compile time and is called compile-time polymorphism. The second is resolved at runtime by the JVM and is called runtime polymorphism. Both are important, but runtime polymorphism is what makes large object-oriented architectures genuinely flexible and extensible.

Two types of polymorphism in Java

Each type is resolved at a different stage of the program's lifecycle.

  • Compile-time (static):Achieved through method overloading. The compiler looks at the method name and argument types and decides which overloaded version to call before the program ever runs.
  • Runtime (dynamic):Achieved through method overriding and inheritance. The JVM looks at the actual type of the object in memory at the moment the method is called and dispatches to the correct implementation.

Compile-time Polymorphism: Method Overloading

Method overloading lets you define multiple methods in the same class that share the same name but differ in their parameter lists. The difference can be in the number of parameters, their types, or their order. The compiler uses the arguments at each call site to decide which version to bind, and this decision is final by the time the bytecode is generated. No runtime check is needed.

What makes a valid overload

Overloading rules are enforced strictly at compile time.

  • Different parameter count:add(int a) and add(int a, int b) are valid overloads.
  • Different parameter types:add(int a, int b) and add(double a, double b) are valid overloads.
  • Different parameter order:process(int n, String s) and process(String s, int n) are valid overloads.
  • Return type alone is not enough:int compute(int n) and double compute(int n) are NOT valid overloads. The compiler cannot distinguish them by return type, so this is a compile error.
  • Access modifier alone is not enough:Changing public to private on a method with the same signature does not create an overload.

Method Overloading

Java

A Calculator class with overloaded add() and describe() methods covering different argument scenarios.

Overloading is also how Java handles type promotion during method resolution. If you call add(3, 4) and there is an add(double, double) but no add(int, int), the compiler will widen the int arguments to double and use the double version. This widening is automatic and follows a fixed hierarchy: byte, short, int, long, float, double.

Runtime Polymorphism: Method Overriding

Runtime polymorphism occurs when a subclass provides its own implementation of a method defined in its parent class, and that method is called through a parent-type reference. The JVM defers the decision of which version to execute until the actual moment of the call, when it can inspect the type of the object in memory. This is the behaviour that makes the same line of code produce different results depending on which object it runs against.

Runtime Polymorphism

Java

The same draw() call on a Shape reference produces different output depending on the actual object.

Why runtime polymorphism matters in design

The real power shows up when you write code that does not need to know what specific type it is working with.

  • You can write a method that accepts a Shape parameter and calls draw() on it. That method works correctly for every existing subclass and for every new subclass someone adds in the future, without any modification.
  • This is the Open/Closed Principle in action: your code is open for extension (new subclasses) and closed for modification (you do not change the existing method).
  • Entire frameworks and libraries like the Java Collections API, Spring, and JavaFX are built on this principle.

Upcasting and Downcasting

When you work with an inheritance hierarchy, you frequently need to move a reference between the parent type and a subtype. Moving up the hierarchy (treating a subtype as its parent type) is called upcasting. Moving down (recovering the specific subtype from a parent reference) is called downcasting.

Upcasting vs. Downcasting

The direction of the cast determines both its safety and whether you need to write it explicitly.

  • Upcasting (implicit, always safe):Animal a = new Dog();, you assign a Dog to an Animal variable. No cast syntax is needed. Since every Dog IS-A Animal, the compiler guarantees this is safe. You lose access to Dog-specific methods through the a reference, but runtime polymorphism still dispatches to Dog's overridden methods.
  • Downcasting (explicit, may fail):Dog d = (Dog) a;, you tell the compiler "trust me, this Animal reference actually points to a Dog." The compiler accepts it, but if at runtime the object is not a Dog, a ClassCastException is thrown. Always guard a downcast with instanceof.

Upcasting and Downcasting

Java

Showing an implicit upcast, an explicit safe downcast, and a failed downcast caught at runtime.

Dynamic Method Dispatch

Dynamic method dispatch is the JVM mechanism that makes runtime polymorphism work. When you call an overridden method through a parent-type reference, the JVM does not look at the declared type of the reference variable. Instead, it inspects the actual type of the object stored in memory at that moment and calls the overridden version belonging to that type. This resolution happens at runtime, not at compile time.

The mechanism is implemented using a virtual method table (vtable). Each class has a vtable that maps method signatures to the concrete method implementation for that class. When a method call is dispatched, the JVM looks up the vtable of the actual object type, not the declared reference type. This lookup is what makes the correct override run even when the code is written against a parent type.

What dynamic dispatch does NOT apply to

Dynamic dispatch only works for instance methods that are overridable. Three things are outside its scope.

  • Static methods:Static methods are bound at compile time based on the reference type, not the object type. If a subclass defines a static method with the same signature as a parent static method, this is hiding, not overriding. The reference type determines which static method runs.
  • Private methods:Private methods cannot be overridden because they are not visible to subclasses. A subclass method with the same name is a completely separate method.
  • Instance fields:Fields are resolved by the reference type, not the object type. If a parent and child both declare a field with the same name, which one you see depends on the type of the variable, not the type of the object.

Dynamic Method Dispatch

Java

Demonstrating that dispatch is based on the actual object type, not the reference type, and contrasting with static method hiding.

instanceof Check before Casting

Whenever you perform a downcast, you should first verify the actual type of the object using instanceof. Skipping this check is a common source of ClassCastException errors at runtime. The check costs almost nothing in performance terms, and makes your intent clear to anyone reading the code.

The traditional instanceof guard pattern

Before Java 16, every safe downcast required two lines: a check and a cast.

  • Check: if (obj instanceof TargetType), returns true if the object is of that type or a subtype, and false (never throws) for null.
  • Cast: TargetType t = (TargetType) obj;, performed inside the if block where the check has already confirmed the type.
  • This pattern is correct and readable but slightly redundant: you name the type twice and the variable three times across the check and cast.

instanceof Guard before Downcast

Java

Processing a mixed list of animals safely by checking type before accessing subtype-specific behaviour.

Pattern Matching for instanceof (Java 16+)

Java 16 made pattern matching for instanceof a standard feature. The enhancement is straightforward. Instead of writing the check and the cast as two separate steps, you write them together in the instanceof expression. If the check passes, a new variable of the target type is automatically bound within the scope where the check succeeds. You get a typed, named binding without writing a cast.

Pattern matching syntax and scoping

The bound variable is only in scope where the compiler can prove the check has passed.

  • Syntax:if (obj instanceof Dog d) { d.fetch(); }, the variable d of type Dog is available inside the if block.
  • Negation scoping:if (!(obj instanceof Dog d)) { return; }, after the early return, d is in scope in the code that follows, because the only way to reach it is if obj was actually a Dog.
  • No null problem:The pattern match still returns false for null, so there is no change to existing null safety behaviour.
  • Combining conditions:if (obj instanceof Dog d && d.name.startsWith("R")), you can use d in the same condition expression after the pattern variable is bound.

Pattern Matching for instanceof (Java 16+)

Java

Rewriting the traditional guard pattern using the compact pattern matching syntax.

Polymorphism with Arrays and Collections

One of the most powerful applications of polymorphism is storing different subtype objects together in a single array or collection typed at the parent level. You can iterate over the collection and call methods on each element without knowing or caring which specific subtype each element is. The JVM handles the dispatch to the correct implementation automatically at each step.

This pattern is fundamental to writing extensible code. When a new subtype is added to the hierarchy, the loop that processes the collection works correctly for it immediately, without any changes to the loop itself.

Polymorphism with Arrays

Java

Processing a mixed array of Employee subtypes without any type checks in the iteration logic.

Polymorphism with Collections

Java

Using an ArrayList typed at the parent level to store and process mixed subtypes.

Writing polymorphic utility methods

Designing methods to accept parent types or interfaces is what makes polymorphism genuinely useful at scale.

  • A method that accepts Animal can receive any Animal subclass: Dog, Cat, Bird, or any future subclass. No method change required.
  • A method that accepts List works for any List containing Drawables: ArrayList, LinkedList, or any custom List implementation.
  • Keeping method parameters as abstract (parent types or interfaces) rather than concrete subtypes is the key difference between tightly and loosely coupled code.
  • In the Java standard library, Collections.sort(List) accepts any List, and Arrays.sort(Object[]) accepts any object array because they rely on the Comparable or Comparator interfaces rather than specific types.

Quiz - Test Your Knowledge

Ten questions covering compile-time and runtime polymorphism, overloading, overriding, upcasting and downcasting, dynamic method dispatch, instanceof checks, pattern matching, and polymorphic collections. Read each option carefully before selecting your answer.

Knowledge Check

1. What distinguishes compile-time polymorphism from runtime polymorphism in Java?

2. Which of the following correctly demonstrates method overloading?

3. What is upcasting in Java?

4. What is Dynamic Method Dispatch?

5. You have Animal a = new Dog(); where Dog extends Animal and overrides speak(). What happens when you call a.speak()?

6. What exception is thrown if you perform an invalid downcast?

7. What does the instanceof pattern matching syntax introduced in Java 16 eliminate?

8. Why can you store a Dog object inside a List<Animal>?

9. Which statement about downcasting is correct?

10. What is the primary advantage of writing methods that accept a parent type parameter instead of a specific subtype?