Java Polymorphism
Java Polymorphism
Polymorphism means one interface, many implementations. In Java, it is what allows a single method call to execute different code depending on the actual type of the object it is called on — without the caller needing to know which type it is dealing with. A list of Payment objects can contain credit card payments, UPI payments, and wallet payments. One loop, one method call, correct behaviour for each type automatically.
This is not just elegant code — it is a structural property that lets you extend a system with new types without modifying the existing code that uses those types. That extensibility is the practical payoff.
What Is Polymorphism in Java?
Polymorphism is the OOP principle that allows a single method name to resolve to different implementations depending on the context. Java has two forms:
- ›Compile-time polymorphism — resolved at compile time based on method signatures. Achieved through method overloading.
- ›Runtime polymorphism — resolved at runtime based on the actual object type. Achieved through method overriding and dynamic dispatch.
Both forms share the same surface: one method name, multiple behaviours. The difference is when the resolution happens — before the program runs or during execution.
Two Types of Polymorphism in Java
| Aspect | Compile-Time Polymorphism | Runtime Polymorphism |
|---|---|---|
| Also called | Static polymorphism / Early binding | Dynamic polymorphism / Late binding |
| Achieved via | Method overloading | Method overriding |
| Resolved by | Compiler — at compile time | JVM — at runtime |
| Decided based on | Parameter types and count | Actual object type at runtime |
| Requires inheritance | No | Yes |
| Performance | Slightly faster — no lookup needed | Slight overhead — dynamic dispatch |
| Flexibility | Lower — fixed at compile time | Higher — behaviour varies at runtime |
| Example | print(int) vs print(String) | shape.draw() on Circle vs Rectangle |
Compile-Time Polymorphism — Method Overloading
Method overloading is when a class defines multiple methods with the same name but different parameter lists. The compiler chooses which version to call based on the argument types at the call site. This decision is made before the program runs — hence compile-time.
// File: InvoiceFormatter.java
public class InvoiceFormatter {
// Overloaded format() — same name, different parameter types
public String format(double amount) {
return String.format("Rs.%.2f", amount);
}
public String format(double amount, String currency) {
return String.format("%s %.2f", currency, amount);
}
public String format(double amount, String currency, boolean includeSymbol) {
String symbol = includeSymbol ? "₹" : "";
return String.format("%s%s %.2f", symbol, currency, amount);
}
public String format(int quantity, String unit) {
return quantity + " " + unit;
}
}// File: OverloadingDemo.java
public class OverloadingDemo {
public static void main(String[] args) {
InvoiceFormatter formatter = new InvoiceFormatter();
// Compiler picks the correct overload at compile time
System.out.println(formatter.format(1499.0));
System.out.println(formatter.format(1499.0, "INR"));
System.out.println(formatter.format(1499.0, "INR", true));
System.out.println(formatter.format(5, "units"));
}
}Output:
Rs.1499.00
INR 1499.00
₹INR 1499.00
5 units
The compiler examines the argument types at the call site and selects the matching overload. If no exact match exists, it applies widening conversions — an int argument can match a double parameter. Overloading is resolved entirely at compile time — the JVM never needs to look up which version to run.
Overloading Rules
| Rule | Description |
|---|---|
| Different parameter count | print(int) and print(int, int) — valid |
| Different parameter types | print(int) and print(double) — valid |
| Different parameter order | print(int, String) and print(String, int) — valid |
| Return type alone | int get() and double get() — NOT valid |
| Access modifier alone | Cannot overload by changing only visibility |
| Varargs ambiguity | print(int...) and print(int) — compiles but may cause ambiguity |
Runtime Polymorphism — Method Overriding
Method overriding is when a child class provides its own implementation of a method inherited from the parent. The method signature must match exactly. At runtime, the JVM looks at the actual type of the object — not the reference type — and calls the most specific override. This is called dynamic dispatch.
// File: Notification.java
public abstract class Notification {
private final String notificationId;
private final String recipientId;
protected final String message;
public Notification(String notificationId, String recipientId, String message) {
this.notificationId = notificationId;
this.recipientId = recipientId;
this.message = message;
}
public String getNotificationId() { return notificationId; }
public String getRecipientId() { return recipientId; }
// Abstract — each subclass must provide its own delivery mechanism
public abstract void deliver();
// Concrete — shared across all subclasses
public String getSummary() {
return "[" + getClass().getSimpleName() + "] To: " + recipientId
+ " | " + message;
}
}// File: SmsNotification.java
public class SmsNotification extends Notification {
private final String phoneNumber;
public SmsNotification(String id, String recipientId,
String phoneNumber, String message) {
super(id, recipientId, message);
this.phoneNumber = phoneNumber;
}
@Override
public void deliver() {
System.out.println("SMS → " + phoneNumber + ": " + message);
}
}// File: EmailNotification.java
public class EmailNotification extends Notification {
private final String emailAddress;
private final String subject;
public EmailNotification(String id, String recipientId,
String emailAddress, String subject, String body) {
super(id, recipientId, body);
this.emailAddress = emailAddress;
this.subject = subject;
}
@Override
public void deliver() {
System.out.println("Email → " + emailAddress
+ " | Subject: " + subject
+ " | Body: " + message);
}
}// File: PushNotification.java
public class PushNotification extends Notification {
private final String deviceToken;
public PushNotification(String id, String recipientId,
String deviceToken, String message) {
super(id, recipientId, message);
this.deviceToken = deviceToken;
}
@Override
public void deliver() {
System.out.println("Push → Device: " + deviceToken + " | " + message);
}
}// File: RuntimePolymorphismDemo.java
import java.util.List;
public class RuntimePolymorphismDemo {
public static void main(String[] args) {
// All referenced as Notification — actual type determines behaviour
List<Notification> notifications = List.of(
new SmsNotification("N001", "USER-001",
"9876543210", "Your OTP is 847291."),
new EmailNotification("N002", "USER-002",
"priya@example.com", "Order Shipped",
"Your order ORD-4521 is on its way."),
new PushNotification("N003", "USER-003",
"device-token-xyz", "Flash sale starts in 10 minutes!")
);
System.out.println("=== Sending Notifications ===\n");
for (Notification notification : notifications) {
notification.deliver(); // JVM calls the correct override at runtime
System.out.println(" " + notification.getSummary());
System.out.println();
}
}
}Output:
=== Sending Notifications ===
SMS → 9876543210: Your OTP is 847291.
[SmsNotification] To: USER-001 | Your OTP is 847291.
Email → priya@example.com | Subject: Order Shipped | Body: Your order ORD-4521 is on its way.
[EmailNotification] To: USER-002 | Your order ORD-4521 is on its way.
Push → Device: device-token-xyz | Flash sale starts in 10 minutes!
[PushNotification] To: USER-003 | Flash sale starts in 10 minutes!
The loop calls notification.deliver() on a Notification reference. The JVM looks at the actual object type — SmsNotification, EmailNotification, PushNotification — and dispatches to the correct deliver() implementation. The calling code is completely insulated from which type it is working with. Adding a WhatsAppNotification type requires no changes to this loop.
How Dynamic Dispatch Works Internally
When the JVM resolves a virtual method call, it uses the vtable (virtual method table) — a data structure maintained per class that maps method signatures to their implementations.
Reference type declared: Notification Actual object type: SmsNotification Step 1: JVM reads the method name: deliver() Step 2: JVM looks at the actual object's class: SmsNotification Step 3: JVM checks SmsNotification's vtable — finds deliver() override Step 4: JVM executes SmsNotification.deliver() Result: SmsNotification.deliver() runs — NOT Notification.deliver()
This lookup happens at runtime for every virtual method call. It is why runtime polymorphism has a slight overhead compared to compile-time resolution — but this overhead is negligible in practice because modern JVMs inline frequently called virtual methods.
Upcasting and Downcasting
Upcasting is assigning a child type object to a parent type reference. It happens implicitly and is always safe.
Downcasting is reassigning a parent type reference back to a child type. It must be explicit and can fail at runtime with ClassCastException if the actual object is not the expected type.
// File: CastingDemo.java
public class CastingDemo {
public static void main(String[] args) {
// Upcasting — implicit, always safe
Notification notification = new SmsNotification(
"N010", "USER-010", "9999999999", "Test message"
);
// notification.phoneNumber — compile error: Notification reference cannot
// see SmsNotification-specific members
// Downcasting — explicit, must verify first
if (notification instanceof SmsNotification smsNotification) {
// Java 16+ pattern matching instanceof — cleaner than cast then access
System.out.println("Phone: " + smsNotification.getRecipientId());
}
// Unsafe downcast — runtime ClassCastException
try {
EmailNotification email = (EmailNotification) notification;
} catch (ClassCastException ex) {
System.out.println("ClassCastException: cannot cast SmsNotification to EmailNotification");
}
// Safe classical downcast with instanceof check
if (notification instanceof SmsNotification) {
SmsNotification sms = (SmsNotification) notification;
System.out.println("Classic cast succeeded for SmsNotification.");
}
}
}Output:
Phone: USER-010
ClassCastException: cannot cast SmsNotification to EmailNotification
Classic cast succeeded for SmsNotification.
Upcasting vs Downcasting
| Aspect | Upcasting | Downcasting |
|---|---|---|
| Direction | Child → Parent reference | Parent reference → Child type |
| Syntax | Implicit — no cast needed | Explicit — (ChildType) ref |
| Safety | Always safe | Can throw ClassCastException |
| Access | Only parent members visible | Child-specific members accessible |
| When needed | Polymorphic collections, method parameters | Accessing child-specific behaviour |
| Best practice | Common and recommended | Always use instanceof check first |
| Java 16+ pattern | Not applicable | if (obj instanceof Child c) — pattern matching |
Method Overriding vs Method Overloading
| Aspect | Method Overriding | Method Overloading |
|---|---|---|
| Definition | Redefining a parent method in a child class | Defining multiple methods with same name, different parameters |
| Resolved at | Runtime — dynamic dispatch | Compile time — static dispatch |
| Inheritance required | Yes | No |
| Signature | Same name, same parameters | Same name, different parameters |
| Return type | Same or covariant subtype | Can differ |
| Access modifier | Same or wider | Can differ |
| @Override | Recommended — compiler validates | Not applicable |
| Polymorphism type | Runtime | Compile-time |
static methods | Cannot be overridden (hidden instead) | Can be overloaded |
private methods | Cannot be overridden | Can be overloaded |
Polymorphism vs Inheritance
| Aspect | Inheritance | Polymorphism |
|---|---|---|
| What it is | Mechanism — child acquires parent members | Principle — one interface, multiple behaviours |
| Primary purpose | Code reuse | Flexible, extensible behaviour |
| Requires | extends keyword | Inheritance (for runtime) or overloading (for compile-time) |
| Relationship | IS-A — structural | Behavioural — how methods resolve |
| Without inheritance | N/A — inheritance is the mechanism | Compile-time polymorphism works without it |
| Key concept | Parent provides members to child | Method call resolves to correct implementation |
| Enables | Polymorphism (runtime form) | Open-Closed Principle |
Basic Example
public class Main {
static class Instrument {
void play() { System.out.println("Playing an instrument"); }
}
static class Guitar extends Instrument {
@Override
void play() { System.out.println("Strumming the guitar"); }
}
static class Piano extends Instrument {
@Override
void play() { System.out.println("Playing the piano keys"); }
}
public static void main(String[] args) {
Instrument[] instruments = { new Guitar(), new Piano(), new Instrument() };
for (Instrument instrument : instruments) {
instrument.play(); // same call, different behaviour per object
}
}
}Output
Strumming the guitar Playing the piano keys Playing an instrument
- ›Every element in the array is referenced as
Instrument, butGuitarandPianoeach overrideplay()with their own behaviour. - ›The loop makes exactly the same call,
instrument.play(), on every element — there is noif-elseorinstanceofcheck for the type. - ›The JVM looks at the actual object type at runtime — not the declared reference type — and dispatches to the correct override. This is runtime polymorphism.
- ›Adding a new instrument type, like
Violin, only requires writing one new class that overridesplay()— this loop never needs to change.
Best Practices
Program to interfaces and abstract types, not concrete classes
When a method parameter or field type is declared as Payment instead of UpiPayment, the code works correctly with any current or future payment type. The calling code is decoupled from the implementation. Teams following clean architecture consistently write List<Payment> rather than List<UpiPayment> when the collection will hold multiple types.
Use @Override on every overriding method
@Override tells the compiler to verify that the method actually overrides something in the parent. Without it, a typo — delivar() instead of deliver() — silently creates a new method rather than overriding, and the dispatch never reaches the child's code. The bug compiles cleanly and only appears at runtime. Every override in production Java must have @Override.
Use instanceof with pattern matching before downcasting
When a downcast is genuinely necessary, use Java 16+ pattern matching instanceof: if (notification instanceof SmsNotification sms). This eliminates both the explicit cast and the possibility of ClassCastException in a single construct. A naked cast without instanceof is consistently flagged in code review.
Avoid long instanceof chains — they signal a missing override
A series of if (obj instanceof A) ... else if (obj instanceof B) ... is usually a sign that a polymorphic method should have been used instead. Each branch is doing what an override would do — but in the caller rather than the class. Move the behaviour into the classes themselves and call it through a common interface.
Common Mistakes
Mistake 1 — Thinking Static Methods Are Polymorphic
public class Animal {
public static String sound() { return "..."; }
public String describe() { return "I am an Animal"; }
}
public class Dog extends Animal {
public static String sound() { return "Woof"; } // hides Animal.sound()
@Override
public String describe() { return "I am a Dog"; }
}
public class StaticPolymorphismDemo {
public static void main(String[] args) {
Animal ref = new Dog();
System.out.println(ref.sound()); // prints "..." — reference type wins
System.out.println(ref.describe()); // prints "I am a Dog" — runtime type wins
}
}Output:
...
I am a Dog
Static methods use method hiding, not overriding. The reference type (Animal) determines which static method runs — not the actual object type. Only instance methods participate in dynamic dispatch and runtime polymorphism.
Mistake 2 — Overloading and Overriding Confusion
public class Printer {
public void print(Object obj) {
System.out.println("Printing Object: " + obj);
}
}
public class SmartPrinter extends Printer {
// This is OVERLOADING — not overriding — different parameter type
public void print(String text) {
System.out.println("Printing String: " + text);
}
}
public class OverloadConfusionDemo {
public static void main(String[] args) {
Printer printer = new SmartPrinter();
printer.print("Hello"); // calls Printer.print(Object) — NOT SmartPrinter.print(String)
// Because reference type is Printer, compiler picks print(Object)
// SmartPrinter.print(String) is overloading, not overriding
}
}Output:
Printing Object: Hello
SmartPrinter.print(String) does not override Printer.print(Object) — it overloads with a different parameter type. Since the reference type is Printer, the compiler selects print(Object). Adding @Override would have caught this at compile time — the compiler would have reported that no method with print(String) exists in the parent to override.
Mistake 3 — Calling Overridable Methods in Constructors
public class Base {
public Base() {
display(); // runtime dispatch sends this to Child.display()
}
public void display() { System.out.println("Base display"); }
}
public class Child extends Base {
private final String name;
public Child(String name) {
super(); // triggers Base() → calls display() before name is set
this.name = name;
}
@Override
public void display() {
System.out.println("Child display: " + name); // name is null here
}
}public class ConstructorPolymorphismDemo {
public static void main(String[] args) {
Child child = new Child("Alice");
child.display(); // correct output
}
}Output:
Child display: null
Child display: Alice
When Base() calls display(), the runtime type is already Child — so Child.display() runs. But Child's constructor body has not executed yet — name is null. The first output is incorrect. Never call overridable methods from constructors.
Interview Questions
Q1. What is polymorphism in Java and what are its two types?
Polymorphism means one method name can resolve to different implementations depending on the context. Java has compile-time polymorphism — achieved through method overloading, resolved by the compiler based on argument types at the call site — and runtime polymorphism — achieved through method overriding, resolved by the JVM at runtime based on the actual object type using dynamic dispatch. Both allow one interface to represent many behaviours, but at different phases of program execution.
Q2. What is the difference between method overloading and method overriding?
Overloading is defining multiple methods in the same class with the same name but different parameter lists — resolved at compile time, does not require inheritance. Overriding is a child class providing its own implementation of an inherited parent method with the exact same signature — resolved at runtime through dynamic dispatch, requires inheritance. Overloading is compile-time polymorphism; overriding is runtime polymorphism. @Override applies only to overriding. Static methods can be overloaded but not overridden.
Q3. What is dynamic dispatch in Java?
Dynamic dispatch is the mechanism by which the JVM resolves virtual method calls at runtime. When a method is called through a parent-type reference, the JVM looks at the actual runtime type of the object — not the declared reference type — and calls the most specific override found in that type's vtable. This is what makes runtime polymorphism work: the same method call on a Notification reference routes to SmsNotification.deliver(), EmailNotification.deliver(), or PushNotification.deliver() depending on the actual object, without the caller needing to know which type it holds.
Q4. What is upcasting and downcasting in Java?
Upcasting is assigning a child type object to a parent type reference — implicit, always safe, and necessary for polymorphic collections. Downcasting is assigning a parent type reference back to a child type — must be explicit with a cast operator, and can throw ClassCastException at runtime if the actual object is not the expected type. Always use instanceof before downcasting, or use Java 16+ pattern matching: if (ref instanceof ChildType c) which combines the check and cast safely.
Q5. Can a static method be polymorphic in Java?
No. Static methods use method hiding rather than method overriding. When a child class declares a static method with the same signature as a parent static method, it hides it rather than overriding it. The method that is called is determined by the reference type at compile time, not the actual object type at runtime. Dynamic dispatch — the mechanism of runtime polymorphism — does not apply to static methods. Calling a static method through a parent-type reference always invokes the parent's version.
Q6. What is the open-closed principle and how does polymorphism enable it?
The open-closed principle states that software should be open for extension but closed for modification. Polymorphism enables this by allowing new types to be added — new Payment subtypes, new Notification subtypes — without modifying the code that uses the base type. CheckoutService.processPayments() works correctly with a CryptoPayment type that did not exist when it was written. The calling code is closed for modification — it does not need to change. The system is open for extension — new types implement the required interface. This is the direct practical benefit of runtime polymorphism.
FAQs
What is polymorphism in Java in simple terms?
Polymorphism means the same method call can do different things depending on which object it is called on. A deliver() call on a list of Notification objects sends each one through the correct channel — SMS, email, or push — without the calling code knowing which type each notification is. One method name, many behaviours.
What is the difference between compile-time and runtime polymorphism?
Compile-time polymorphism is resolved before the program runs — the compiler picks the correct overloaded method based on argument types. Runtime polymorphism is resolved during execution — the JVM picks the correct overriding method based on the actual object type at that moment. Overloading is compile-time; overriding is runtime.
Does polymorphism require inheritance in Java?
Runtime polymorphism (overriding) requires inheritance — a child class must extend a parent to override its methods. Compile-time polymorphism (overloading) does not require inheritance — multiple methods with the same name and different parameters can exist in the same class independently of any hierarchy.
What is method hiding in Java?
Method hiding occurs when a child class declares a static method with the same signature as a parent static method. Unlike overriding, it does not participate in dynamic dispatch — the method called depends on the reference type, not the object type. Static methods use compile-time binding; instance methods use dynamic binding. Method hiding is not polymorphism in the runtime sense.
What happens when you call an overridden method through a parent reference?
The JVM uses dynamic dispatch — it looks at the actual type of the object the reference points to and calls the most specific override in that class's method table. The declared reference type determines what methods are visible at compile time; the actual object type determines which implementation runs at runtime.
Why is polymorphism important in Java?
Polymorphism enables the open-closed principle — you can add new types without modifying existing code that works with the base type. It reduces conditional branching (if instanceof A ... else if instanceof B) by moving behaviour into the classes themselves. It makes code more readable, testable, and extensible. Every major Java framework — Spring, Hibernate, Jakarta EE — uses polymorphism as a core architectural mechanism.
Summary
Polymorphism is the principle that one method name resolves to different implementations depending on context. Compile-time polymorphism through overloading gives the same method name multiple signatures — the compiler picks the right one. Runtime polymorphism through overriding gives child classes their own behaviour — the JVM picks the right one at runtime through dynamic dispatch.
The practical payoff is extensibility without modification. A loop that processes Payment objects works correctly with any new payment type added later — no changes required to the loop. A notification dispatcher handles any channel added in the future. This is what polymorphism exists to deliver.
For interviews, be ready to explain both types with concrete code examples, demonstrate the difference between method hiding (static) and overriding (instance), show upcasting and downcasting with instanceof, and articulate how polymorphism enables the open-closed principle. These points appear consistently across service-based recall questions and product-based design discussions.
What to Read Next
Practice Programs
Put this topic into practice — each program has full code, output and a step-by-step explanation you can run online.
Multiple classes implementing a shared interface.
Defines the same method name multiple times with different parameter lists.
A base class extended by a subclass with overridden behavior.
Assigns a subclass object to a variable typed as its parent class.