By WebNest Studio Engineering Team
Java - Core Tutorial
Polymorphism in Java: An Overview
Polymorphism comes from Greek words meaning "many forms." In Java, it describes the ability of a single method name, or a single reference type, to behave differently depending on the context in which it is used. It is one of the four pillars of object-oriented programming, alongside encapsulation, inheritance, and abstraction.
Java supports two distinct kinds of polymorphism: compile-time (static) polymorphism, achieved through method overloading, and runtime (dynamic) polymorphism, achieved through method overriding. This lesson is a short orientation to both — the deeper mechanics of overriding and dynamic dispatch are covered in dedicated lessons elsewhere in this course.
Why it matters
Polymorphism is what lets you write code against a general type and have it work for every specific type, including ones written later. A method that takes a Notification does not need a new branch each time someone adds a new kind of notification. Without it, programs grow long chains of if and instanceof checks that must be edited for every new case.
Watch: Java Polymorphism Masterclass: Method Overloading, Overriding and Dynamic Dispatch
From "one interface, many forms" to how the JVM picks a method at runtime: overloading rules, overriding and dynamic method dispatch, upcasting and downcasting with ClassCastException, why static, private and final methods are not overridden, covariant return types, interfaces and abstract classes, a payment-system example, and tricky interview output questions.
Compile-Time (Static) Polymorphism — Method Overloading
Overloading lets a class define multiple methods with the same name but different parameter lists (different number, type, or order of parameters). The compiler decides which version to call by matching the arguments at compile time — hence "static" or "compile-time" polymorphism. Overloaded methods can differ in return type too, but return type alone is never enough to distinguish them.
Runtime (Dynamic) Polymorphism — Method Overriding
Overriding lets a subclass provide its own implementation of a method already defined in its superclass, using the exact same signature. Which version actually runs is decided at runtime, based on the real (runtime) type of the object being referenced — not the type of the reference variable. This is what allows a single line of code like animal.makeSound() to produce different behavior depending on whether "animal" actually refers to a Dog or a Cat object.
Why the Distinction Matters
Compile-time polymorphism is about convenience and readability — one intuitive method name covering several related use cases. Runtime polymorphism is far more powerful architecturally: it is the mechanism behind interfaces, abstract classes, and extensible frameworks, because it lets code work with a general supertype while automatically running the correct specific behavior for whatever object is actually supplied at runtime.
How the JVM Picks the Method: Dynamic Method Dispatch
A call such as animal.sound() is handled in two steps. At compile time, the compiler looks only at the declared type of the variable (Animal) and checks that this type has a sound() method; if it does not, the code does not compile, whatever the object will be. At runtime, the JVM looks at the class of the actual object and runs the most specific version of sound() it finds, starting at that class and walking up through its parents. This runtime lookup is called dynamic method dispatch. It is why the declared type decides which methods you may call, while the object decides which code actually runs.
Upcasting and Downcasting
Storing a Dog in an Animal variable is an upcast. It is always safe, so Java does it automatically, and it is what makes polymorphism possible: a list of Animal can hold dogs, cats and any future subclass. Going the other way, from Animal back to Dog to call a dog-only method such as fetch(), is a downcast. It needs an explicit cast and can fail: if the object is really a Cat, the JVM throws ClassCastException. Check first with instanceof; since Java 16 you can check and cast in one step with if (a instanceof Dog d) { d.fetch(); }. Frequent downcasting is often a sign that the behaviour belongs in an overridden method instead.
What Is Not Polymorphic
Only instance methods that are inherited and overridable take part in runtime dispatch. Everything else is decided by the compiler from the declared type:
staticmethods belong to the class, not the object. A subclass may declare a static method with the same signature, but that hides the parent's method rather than overriding it, and@Overrideon it is a compile error.privatemethods are not inherited, so a subclass method with the same name is simply a new, unrelated method.finalmethods cannot be overridden at all; the compiler reports "overridden method is final".- Fields are never overridden. A subclass field with the same name hides the parent's, and which one you read depends on the variable's declared type.
- Overload selection also uses declared types: with
print(Animal)andprint(Dog), callingprint(a)on anAnimalvariable picksprint(Animal)even whenaholds aDog.
Polymorphism with Interfaces and Abstract Classes
In real projects the parent type is usually an interface or an abstract class rather than a concrete class. An interface such as PaymentMethod states what every payment can do (pay(amount)) without saying how. Card, UPI and wallet payments each implement it differently, and the checkout code works with PaymentMethod only, so adding a new payment type needs no change there. An abstract class sits in between: it can implement the steps that are the same for every subclass and leave one step abstract, as OnlinePayment does below with transfer(). Overriding methods may also narrow the return type, for example a copy() that returns Shape in the parent and Circle in the child. This is called a covariant return type and is covered in the Method Overriding lesson.
Examples
Overloading (compile-time) side by side with overriding (runtime)
// --- Compile-time polymorphism: overloading ---
class Calculator {
int add(int a, int b) { return a + b; }
double add(double a, double b) { return a + b; }
}
// --- Runtime polymorphism: overriding ---
class Animal {
void makeSound() { System.out.println("Some generic animal sound"); }
}
class Dog extends Animal {
@Override
void makeSound() { System.out.println("Woof!"); }
}
public class PolymorphismOverviewDemo {
public static void main(String[] args) {
Calculator calc = new Calculator();
System.out.println(calc.add(2, 3)); // resolved at compile time -> int version
System.out.println(calc.add(2.5, 3.5)); // resolved at compile time -> double version
Animal a = new Dog(); // reference type Animal, actual object Dog
a.makeSound(); // resolved at RUNTIME based on actual object -> Woof!
}
}
5
6.0
Woof!
A payment system: one loop, three behaviours, through an interface and an abstract class
import java.math.BigDecimal;
import java.util.List;
public class PaymentDemo {
public static void main(String[] args) {
List<PaymentMethod> methods = List.of(
new CardPayment("4111111111111111"),
new UpiPayment("asha@okbank"),
new WalletPayment(new BigDecimal("300.00")));
BigDecimal amount = new BigDecimal("499.00");
for (PaymentMethod method : methods) { // one loop, three behaviours
System.out.println(method.name() + ": " + method.pay(amount));
}
}
}
interface PaymentMethod {
String pay(BigDecimal amount);
default String name() { // shared default, can be overridden
return getClass().getSimpleName();
}
}
abstract class OnlinePayment implements PaymentMethod {
// Every online payment is formatted the same way; only the transfer differs
@Override
public final String pay(BigDecimal amount) {
return "Rs." + amount + " " + transfer(amount);
}
protected abstract String transfer(BigDecimal amount);
}
class CardPayment extends OnlinePayment {
private final String cardNumber;
CardPayment(String cardNumber) { this.cardNumber = cardNumber; }
@Override
protected String transfer(BigDecimal amount) {
return "charged to card ending " + cardNumber.substring(cardNumber.length() - 4);
}
}
class UpiPayment extends OnlinePayment {
private final String upiId;
UpiPayment(String upiId) { this.upiId = upiId; }
@Override
protected String transfer(BigDecimal amount) {
return "requested from " + upiId;
}
@Override
public String name() { return "UPI"; }
}
class WalletPayment implements PaymentMethod {
private BigDecimal balance;
WalletPayment(BigDecimal balance) { this.balance = balance; }
@Override
public String pay(BigDecimal amount) {
if (balance.compareTo(amount) < 0) {
return "declined, wallet balance is Rs." + balance;
}
balance = balance.subtract(amount);
return "paid from wallet";
}
}
CardPayment: Rs.499.00 charged to card ending 1111
UPI: Rs.499.00 requested from asha@okbank
WalletPayment: declined, wallet balance is Rs.300.00
Interview-style output questions: what is dispatched at runtime and what is not
public class TrickyOutput {
static void print(Animal a) { System.out.println("print(Animal)"); }
static void print(Dog d) { System.out.println("print(Dog)"); }
public static void main(String[] args) {
Animal a = new Dog(); // upcast: automatic
a.sound(); // Q1
print(a); // Q2
System.out.println(a.legs); // Q3
a.describe(); // Q4
if (a instanceof Dog d) { // Q5: check and downcast in one step (Java 16+)
d.fetch();
}
Animal cat = new Cat();
try {
Dog wrong = (Dog) cat; // Q6
wrong.fetch();
} catch (ClassCastException e) {
System.out.println("ClassCastException: " + e.getMessage());
}
}
}
class Animal {
int legs = 4;
void sound() { System.out.println("Animal sound"); }
static void info() { System.out.println("Animal.info"); }
void describe() { info(); }
}
class Dog extends Animal {
int legs = 3; // hides Animal.legs (a three-legged dog)
@Override void sound() { System.out.println("Woof"); }
static void info() { System.out.println("Dog.info"); } // hides, does not override
void fetch() { System.out.println("Dog fetches"); }
}
class Cat extends Animal {
@Override void sound() { System.out.println("Meow"); }
}
Woof
print(Animal)
4
Animal.info
Dog fetches
ClassCastException: class Cat cannot be cast to class Dog (Cat and Dog are in unnamed module of loader ...)
Q1 Woof: sound() is an overridden instance method, so the Dog object decides.
Q2 print(Animal): overloads are chosen at compile time from the declared type Animal.
Q3 4: fields are not overridden; a.legs reads the field declared in Animal.
Q4 Animal.info: static methods are hidden, not overridden, so describe() calls Animal's.
Q5 the pattern match succeeds because the object really is a Dog.
Q6 the object is a Cat, so the cast fails at runtime, not at compile time.
Common Mistakes
- Using "overloading" and "overriding" interchangeably — they are resolved at different times (compile-time vs runtime) and follow completely different rules.
- Believing overload resolution can happen at runtime — the compiler picks the overloaded method based purely on the declared/static types of the arguments.
- Forgetting that overriding requires an identical method signature, while overloading requires a different one — an "override" with a slightly different parameter list actually just creates an accidental overload.
- Assuming polymorphism only applies to classes — interfaces and abstract classes rely on runtime polymorphism just as heavily, if not more.
- Downcasting without an instanceof check, so a wrong object type crashes with ClassCastException at runtime.
- Expecting a static method or a field in the subclass to override the parent's; both are only hidden and are chosen by the declared type.
- Writing if/else chains on instanceof to pick behaviour, instead of putting the behaviour in an overridden method.
Exercise
Create two kinds of notification that share a parent type. Each overrides send() to return its own text. Store one of each in an array of the parent type and print the result of send() for every element.
Expected output: Email sent then SMS sent
class Notification {
String send() {
return "Notification sent";
}
}
// TODO: class EmailNotification extends Notification, returning "Email sent"
// TODO: class SmsNotification extends Notification, returning "SMS sent"
public class NotifyDemo {
public static void main(String[] args) {
Notification[] queue = { new EmailNotification(), new SmsNotification() };
// TODO: loop over the queue and print send() for each element
}
}
Show hints
- Mark each overriding method with
@Overrideso the compiler checks the signature. - The loop variable has type
Notification, but the method that runs is chosen by the actual object.
Show solution
class Notification {
String send() {
return "Notification sent";
}
}
class EmailNotification extends Notification {
@Override
String send() {
return "Email sent";
}
}
class SmsNotification extends Notification {
@Override
String send() {
return "SMS sent";
}
}
public class NotifyDemo {
public static void main(String[] args) {
Notification[] queue = { new EmailNotification(), new SmsNotification() };
for (Notification notification : queue) {
System.out.println(notification.send());
}
}
}
Quick quiz
1. When is a call to an overloaded method resolved?
- A. It is never resolved
- B. At runtime, from the object type
- C. When the class is loaded
- D. At compile time, from the argument types
Show answer
D. Overloading is compile-time polymorphism: the compiler picks the method from the declared types of the arguments.
2. When is a call to an overridden instance method resolved?
- A. At runtime, from the actual object type
- B. At compile time, from the reference type
- C. When the source file is saved
- D. By the garbage collector
Show answer
A. Overriding is runtime polymorphism: the JVM looks at the real class of the object and runs its version.
3. Dog overrides sound() from Animal. What does Animal a = new Dog(); a.sound(); run?
- A. Animal's sound()
- B. It does not compile
- C. Both, Animal first
- D. Dog's sound()
Show answer
D. The reference type is Animal, but the object is a Dog, so Dog's overriding method runs.
Interview questions
What is the difference between compile-time and runtime polymorphism?
Show answer
Compile-time polymorphism is method overloading: several methods share a name, and the compiler chooses one from the number and types of the arguments. Runtime polymorphism is method overriding: a subclass replaces an inherited method, and the JVM chooses the version to run from the actual type of the object when the call happens.
Do fields and static methods take part in runtime polymorphism?
Show answer
No. Only instance methods are dispatched on the actual object. Fields and static methods are resolved from the declared type of the reference at compile time. A subclass that declares a field or static method with the same name hides the parent's version rather than overriding it.
Key Points to Remember
- Polymorphism means one name or reference type behaving in multiple forms depending on context.
- Compile-time (static) polymorphism = method overloading, resolved by the compiler using argument types.
- Runtime (dynamic) polymorphism = method overriding, resolved using the actual object type at runtime.
- Runtime polymorphism is the foundation of flexible, extensible designs built on interfaces and abstract classes.
- The declared type decides which methods may be called; the actual object decides which overriding version runs (dynamic method dispatch).
- Upcasting is automatic and safe; downcasting needs a cast and an instanceof check to avoid ClassCastException.
- static, private and final methods and all fields are resolved at compile time and are not polymorphic.
Go deeper
Practice in the playground
Write and run your own code for this topic, or start from the exercise or an example. Runs plain Java 17. Frameworks such as Spring, and databases, are not available here. Your code for each topic is kept in this browser.
Run the examples
Change an input, predict the result, then compare it with the output. Explain why the result changes.
Written by
WebNest Studio Engineering Team
Practical lessons from developers working with Java, Python, React, databases and production systems.
Led by Vansh Duggal, Founder, WebNest Studio · Full-stack & software engineering