Compile-time polymorphism is resolved by the compiler before the program runs, using method overloading — same method name, different parameters. Run-time polymorphism is resolved by the JVM while the program runs, using method overriding — a subclass replaces a parent method, and the JVM picks the right version based on the actual object. Both recognise the same class of problems; they differ in when the decision is made and what triggers it.
Polymorphism is one of the four pillars of object-oriented programming, alongside inheritance, encapsulation, and abstraction. The word itself comes from Greek — poly meaning many, morphs meaning forms — and in Java it refers to the ability of a single method name or reference variable to behave differently depending on the context. The key thing to understand is that Java achieves this in two fundamentally different ways, at two different stages of a program’s life.
Compile-time polymorphism is handled by the Java compiler. It looks at how you are calling a method, checks the parameter types, and decides which version to bind — all before a single line of your program executes. Run-time polymorphism is handled by the JVM while your code is actually running. It looks at the real object sitting in heap memory and dynamically dispatches the correct overridden method. These two mechanisms solve different problems, and knowing which one applies in any given situation is what separates a developer who has memorised definitions from one who actually understands Java.
This guide covers both from the ground up — formal definitions, annotated code, dynamic method dispatch internals, upcasting, interfaces, abstract classes, a full comparison table, a realistic example, and the interview questions that come up consistently for Java roles.

What is Polymorphism in Java?
Polymorphism lets one method name or reference variable represent different behaviours depending on the context — either the parameters you pass (compile-time) or the actual object you have created (run-time). It is what allows you to write shape.area() once and have it correctly compute the area of a circle, a rectangle, or a triangle without any if-else chains.
Before going further, one important clarification that trips up many developers coming from C++ backgrounds: Java does not support operator overloading. This was a deliberate design decision by James Gosling to keep the language readable and unambiguous. In Java, compile-time polymorphism means method overloading only — not operator overloading. Any source that says otherwise is incorrect for Java specifically.
Java provides two types of polymorphism:
Compile-Time Polymorphism
- Also called: Static Polymorphism / Early Binding
- Resolved by: Java Compiler
- Mechanism: Method Overloading
- When: Before the program runs
Run-Time Polymorphism
- Also called: Dynamic Polymorphism / Late Binding
- Resolved by: JVM at runtime
- Mechanism: Method Overriding
- When: While the program executes
Compile-Time Polymorphism (Method Overloading)
Compile-time polymorphism happens when you define multiple methods with the same name but different parameter lists inside the same class. The compiler looks at the method call, inspects the arguments, and decides exactly which version to invoke — locking that decision into the bytecode before the program ever runs. This is called static binding because the binding is fixed at compile time.
The rules are straightforward. Methods must share the same name and must differ in the number, type, or order of their parameters. Changing only the return type is not enough — the compiler will throw an error because it cannot distinguish the calls. Inheritance is not needed; overloading works within a single class.
When the compiler encounters an overloaded call, it tries to resolve it in three passes: first an exact type match, then widening primitive conversion (for example int promoted to long), and finally autoboxing and varargs. If no match is found after all three, you get a compile-time error — which is actually one of the main advantages over run-time resolution.
Example: Method Overloading
public class Calculator {
// Two int parameters
public int add(int a, int b) {
return a + b;
}
// Three int parameters
public int add(int a, int b, int c) {
return a + b + c;
}
// Two double parameters
public double add(double a, double b) {
return a + b;
}
// String concatenation -- same name, completely different type
public String add(String a, String b) {
return a + b;
}
public static void main(String[] args) {
Calculator calc = new Calculator();
System.out.println(calc.add(2, 3)); // 5 -- calls add(int, int)
System.out.println(calc.add(1, 2, 3)); // 6 -- calls add(int, int, int)
System.out.println(calc.add(2.5, 3.5)); // 6.0 -- calls add(double, double)
System.out.println(calc.add("Hello", " World")); // Hello World -- calls add(String, String)
}
}
// The compiler binds each call above at compile time.
// By the time this runs, there is no decision left to make -- it is all in the bytecode.Advantages
- Faster execution — method binding is done before runtime, zero dispatch overhead
- Early error detection — wrong calls are caught by the compiler, not discovered in production
- Cleaner API — one logical name covers related operations regardless of input type
- No inheritance required — works entirely within a single class
Disadvantages
- Less flexible — behaviour is fixed at compile time and cannot adapt to object types at runtime
- No cross-class polymorphism — cannot produce different behaviour across an inheritance hierarchy
- Return type alone is not enough — a common source of confusion for beginners
Run-Time Polymorphism (Method Overriding)
Run-time polymorphism is a different mechanism entirely. Here, a subclass provides its own implementation of a method already defined in its superclass — same name, same parameter list, same return type (or a covariant subtype). The compiler sees the superclass reference and confirms the method exists, but it does not lock in which version to call. That decision is deferred to the JVM, which inspects the actual object in memory at the moment of the call and dispatches accordingly. This is dynamic binding.
A few rules to keep in mind. The overriding method must have the same signature as the superclass method. The access modifier can be the same or less restrictive — you can widen visibility but not narrow it. Static, final, and private methods cannot be overridden. Inheritance is mandatory — without a parent-child relationship there is nothing to override.
Always include the @Override annotation. It costs nothing and saves you from a subtle bug: if you accidentally type Sound() instead of sound(), without @Override Java silently creates a brand new method instead of overriding — your runtime polymorphism breaks with no error. With @Override, the compiler immediately tells you the method does not override anything.
Example: Method Overriding with @Override
class Animal {
public void sound() {
System.out.println("Animal makes a sound");
}
}
class Dog extends Animal {
@Override
public void sound() {
System.out.println("Dog barks");
}
}
class Cat extends Animal {
@Override
public void sound() {
System.out.println("Cat meows");
}
}
class Bird extends Animal {
@Override
public void sound() {
System.out.println("Bird chirps");
}
}
public class Main {
public static void main(String[] args) {
Animal a = new Dog(); // upcasting -- Dog object, Animal reference
a.sound(); // JVM checks actual object: Dog -- calls Dog.sound()
// Output: Dog barks
a = new Cat();
a.sound(); // JVM checks actual object: Cat -- calls Cat.sound()
// Output: Cat meows
a = new Bird();
a.sound(); // JVM checks actual object: Bird -- calls Bird.sound()
// Output: Bird chirps
}
// At compile time, all three calls to a.sound() look identical to the compiler.
// The compiler only knows the reference is Animal.
// At runtime, the JVM looks at the actual object and picks the right method.
}Advantages
- Highly flexible — behaviour changes based on the actual object, not the reference type
- Extensible — add new subclasses without touching existing calling code (Open/Closed Principle)
- Cleaner architecture — write code against abstractions, not concrete implementations
- Foundation of design patterns — Strategy, Factory, Observer all rely on this
Disadvantages
- Small performance overhead — vtable lookup at each call, though the JIT compiler largely eliminates this in practice
- Harder to trace — which method actually runs is not always obvious by reading the code
- Requires inheritance — couples subclass to superclass structure
- Incorrect downcasting throws ClassCastException at runtime
Upcasting and Why It Matters
Upcasting is what makes run-time polymorphism possible. It means storing a subclass object in a superclass reference variable — Animal a = new Dog(). Java does this implicitly; no cast operator is needed. The reference variable type (Animal) determines what methods you are allowed to call — only methods defined in Animal are accessible through that reference. But the actual object type (Dog) determines which version of an overridden method actually runs when the JVM executes it.
This distinction — reference type vs object type — is the single most important concept to understand about run-time polymorphism. The compiler works with the reference type. The JVM works with the object type. They operate on the same line of code but at different times.
Animal a = new Dog(); // implicit upcast -- perfectly safe, always valid
// What you can call is determined by the reference type (Animal):
a.sound(); // OK -- sound() is defined in Animal
// a.fetch(); // compile error -- fetch() only exists in Dog, not Animal
// Going the other way (downcasting) is explicit and can fail:
Dog d = (Dog) a; // explicit downcast -- safe here because a actually holds a Dog
// Dog d2 = (Dog) new Cat(); // compiles, but throws ClassCastException at runtime
// Always check before downcasting:
if (a instanceof Dog) {
Dog d3 = (Dog) a; // safe -- instanceof confirmed the type first
}Dynamic Method Dispatch
Dynamic method dispatch is the internal mechanism the JVM uses to implement run-time polymorphism. When an overridden method is called through a superclass reference, the JVM does not use the reference type to pick the method — it looks at the actual object stored in heap memory and uses that object’s vtable (virtual method table) to find the correct implementation.
Every Java object carries a pointer to its class’s vtable — a lookup table that maps method names to their actual implementations for that particular class. When the JVM calls a.sound() and a holds a Dog, it reads the Dog vtable, finds sound -> Dog.sound(), and calls it. This vtable lookup is an O(1) operation — fast, but marginally slower than the zero-cost static binding of compile-time polymorphism. In practice, the JVM’s JIT compiler inlines and optimises monomorphic call sites so the overhead is negligible in most real applications.

// Tracing what happens step by step
Animal a = new Dog(); // Step 1: Dog object created in heap, a holds a reference to it
a.sound();
// Step 2 -- Compile time: compiler checks Animal has a sound() method. Compilation passes.
// Compiler does NOT bind to Dog.sound() here.
// Step 3 -- Runtime: JVM follows the reference 'a' to the Dog object in heap.
// JVM reads Dog's vtable: { sound -> Dog.sound() }
// JVM calls Dog.sound()
// Output: Dog barks
a = new Cat();
a.sound();
// JVM reads Cat's vtable: { sound -> Cat.sound() }
// Output: Cat meows
// The method name in the source code is always the same: a.sound()
// What actually runs changes based on the object -- that is dynamic dispatch.Run-Time Polymorphism via Interfaces
In real Java development, you will use interfaces for run-time polymorphism more often than class inheritance. An interface defines a contract — a set of method signatures that any implementing class must honour. At runtime, the JVM dispatches to the correct implementation based on the actual object, exactly as it does with class overriding.
The practical advantage is that a class can implement multiple interfaces, which Java does not allow with multiple class inheritance. Writing your code against interface types rather than concrete class types also makes it far easier to swap implementations without changing the calling code — which is the foundation of dependency injection.
interface Shape {
double area();
double perimeter();
}
class Circle implements Shape {
private double radius;
Circle(double radius) {
this.radius = radius;
}
@Override
public double area() {
return Math.PI * radius * radius;
}
@Override
public double perimeter() {
return 2 * Math.PI * radius;
}
}
class Rectangle implements Shape {
private double width, height;
Rectangle(double w, double h) {
this.width = w;
this.height = h;
}
@Override
public double area() {
return width * height;
}
@Override
public double perimeter() {
return 2 * (width + height);
}
}
public class Main {
// This method works with any Shape -- current or future implementations
public static void printShapeInfo(Shape shape) {
System.out.println("Area: " + shape.area());
System.out.println("Perimeter: " + shape.perimeter());
}
public static void main(String[] args) {
Shape s1 = new Circle(5.0);
Shape s2 = new Rectangle(4.0, 6.0);
printShapeInfo(s1); // JVM dispatches to Circle.area() and Circle.perimeter()
printShapeInfo(s2); // JVM dispatches to Rectangle.area() and Rectangle.perimeter()
// Adding a Triangle class later requires zero changes to printShapeInfo()
}
}A common best practice is to use the interface type as the reference variable — Shape s = new Circle() rather than Circle s = new Circle(). This keeps your calling code decoupled from the specific implementation and makes future changes, including dependency injection and testing with mock objects, significantly easier.
Run-Time Polymorphism via Abstract Classes
Abstract classes sit between a regular class and an interface. They can have concrete methods with shared implementations that subclasses inherit directly, and abstract methods with no body that every subclass must override. That combination makes them well suited for situations where you have common behaviour to share but also need to enforce that each subclass provides specific implementations of certain methods.
abstract class Vehicle {
private String brand;
Vehicle(String brand) {
this.brand = brand;
}
// Concrete -- shared by all subclasses, no need to override
public String getBrand() {
return brand;
}
// Abstract -- every subclass must provide its own version
public abstract void fuelType();
public abstract int maxSpeed();
}
class ElectricCar extends Vehicle {
ElectricCar(String brand) {
super(brand);
}
@Override
public void fuelType() {
System.out.println(getBrand() + " runs on battery (electric)");
}
@Override
public int maxSpeed() {
return 250;
}
}
class PetrolBike extends Vehicle {
PetrolBike(String brand) {
super(brand);
}
@Override
public void fuelType() {
System.out.println(getBrand() + " runs on petrol (combustion engine)");
}
@Override
public int maxSpeed() {
return 180;
}
}
public class Main {
public static void main(String[] args) {
Vehicle v1 = new ElectricCar("Tesla");
Vehicle v2 = new PetrolBike("Yamaha");
v1.fuelType(); // Tesla runs on battery (electric)
v2.fuelType(); // Yamaha runs on petrol (combustion engine)
System.out.println(v1.maxSpeed()); // 250
System.out.println(v2.maxSpeed()); // 180
// Cannot instantiate an abstract class directly:
// Vehicle v3 = new Vehicle("X"); -- compile error
}
}The table below shows when to choose an abstract class versus an interface:
| Feature | Abstract Class | Interface |
|---|---|---|
| Concrete methods | Yes | Yes, via default methods (Java 8+) |
| Constructors | Yes | No |
| Instance variables | Yes | No — only static final constants |
| Multiple inheritance | One class only | A class can implement many interfaces |
| Best used when | Subclasses share common state or behaviour alongside required overrides | Defining a pure contract with no shared state |
Compile-Time vs Run-Time Polymorphism: Key Differences
| Aspect | Compile-Time Polymorphism | Run-Time Polymorphism |
|---|---|---|
| Also known as | Static polymorphism, Early binding, Static binding | Dynamic polymorphism, Late binding, Dynamic binding |
| Resolved by | Java compiler — before the program runs | JVM — while the program is executing |
| Mechanism | Method overloading — same name, different parameters | Method overriding — same name, same parameters in subclass |
| Inheritance required? | No — happens within a single class | Yes — requires extends or implements |
| Binding type | Static — method address fixed in bytecode at compile time | Dynamic — method address resolved via vtable at runtime |
| Decision based on | Method signature (parameter types and count) | Actual object type in heap memory, not the reference type |
| @Override annotation | Not applicable | Should always be used to prevent accidental overloading |
| Performance | Faster — zero runtime overhead, already resolved | Marginally slower — vtable lookup at each call |
| Flexibility | Less flexible — behaviour fixed at compile time | Highly flexible — behaviour adapts to the actual object |
| Static methods | Can be overloaded | Cannot be overridden — method hiding occurs instead |
| Private methods | Can be overloaded | Cannot be overridden — not inherited by subclasses |
| Error detection | Compile-time — wrong calls caught before running | Runtime — ClassCastException possible with incorrect downcasting |
| OOP principle supported | Clean API design — one method name for logically related operations | Open/Closed Principle — extend behaviour without modifying existing code |
| Primary use case | Utility methods handling different input types — Math operations, builders | Frameworks, plugins, strategy pattern, dependency injection |
| Java keywords involved | No special keyword needed | extends, implements, @Override, super |

Real-World Example: Payment System
The following example deliberately uses both types of polymorphism in one application to show how they complement each other in practice. The overloaded processPayment methods handle different input combinations at compile time. The overriding pay() method across payment gateway subclasses handles different runtime behaviour based on which gateway object is actually used.
// ------------------------------------------------------------
// COMPILE-TIME POLYMORPHISM -- processPayment overloading
// ------------------------------------------------------------
class PaymentProcessor {
public void processPayment(double amount) {
System.out.println("Processing payment of Rs. " + amount);
}
public void processPayment(double amount, String currency) {
System.out.println("Processing " + currency + " payment of " + amount);
}
public void processPayment(double amount, String currency, int emiMonths) {
System.out.println("Processing " + currency + " " + amount
+ " split into " + emiMonths + " EMI installments");
}
}
// The compiler chooses which processPayment to invoke based on the arguments.
// ------------------------------------------------------------
// RUN-TIME POLYMORPHISM -- payment gateway overriding
// ------------------------------------------------------------
abstract class PaymentGateway {
protected String gatewayName;
PaymentGateway(String name) {
this.gatewayName = name;
}
public abstract boolean pay(double amount);
public void printReceipt(double amount) {
System.out.println("Receipt issued by " + gatewayName + " for Rs. " + amount);
}
}
class RazorpayGateway extends PaymentGateway {
RazorpayGateway() {
super("Razorpay");
}
@Override
public boolean pay(double amount) {
System.out.println("Razorpay: charging card for Rs. " + amount);
return true;
}
}
class UPIGateway extends PaymentGateway {
UPIGateway() {
super("UPI");
}
@Override
public boolean pay(double amount) {
System.out.println("UPI: sending Rs. " + amount + " via QR code");
return true;
}
}
class WalletGateway extends PaymentGateway {
private double balance;
WalletGateway(double balance) {
super("Wallet");
this.balance = balance;
}
@Override
public boolean pay(double amount) {
if (balance >= amount) {
balance -= amount;
System.out.println("Wallet: deducted Rs. " + amount
+ ". Remaining balance: Rs. " + balance);
return true;
}
System.out.println("Wallet: insufficient balance");
return false;
}
}
public class Main {
// Works for any PaymentGateway -- present or future
public static void checkout(PaymentGateway gateway, double amount) {
boolean success = gateway.pay(amount); // runtime dispatch
if (success) {
gateway.printReceipt(amount);
}
}
public static void main(String[] args) {
// Compile-time polymorphism in action
PaymentProcessor processor = new PaymentProcessor();
processor.processPayment(999.0);
processor.processPayment(1499.0, "INR");
processor.processPayment(5000.0, "INR", 6);
System.out.println("---");
// Run-time polymorphism in action -- same checkout() method, different behaviour
checkout(new RazorpayGateway(), 999.0);
checkout(new UPIGateway(), 499.0);
checkout(new WalletGateway(1000.0), 750.0);
// Adding a NetBankingGateway class later requires zero changes to checkout()
}
}Java Interview Questions on Polymorphism
These questions come up regularly in Java interviews at both service companies (TCS, Infosys, Wipro, Cognizant) and product companies. They test not just whether you know the definition but whether you understand the underlying mechanism.
No. Static methods belong to the class, not to any instance, and are resolved at compile time using the reference type — not the actual object. If a subclass defines a static method with the same signature, it is called method hiding, not overriding. No dynamic dispatch occurs.
class Parent {
static void show() { System.out.println("Parent"); }
}
class Child extends Parent {
static void show() { System.out.println("Child"); }
}
Parent p = new Child();
p.show(); // prints "Parent" -- static binding on reference type, not the objectSince Java 5, an overriding method in a subclass can return a subtype of the original return type rather than exactly the same type. This is called a covariant return type. It is still valid overriding because the return type is narrowed, not widened — which keeps the contract safe.
class Animal { Animal create() { return new Animal(); } }
class Dog extends Animal {
@Override
Dog create() { return new Dog(); } // Dog is a subtype of Animal -- valid
}final when behaviour must stay consistent across all subclasses, such as security-sensitive logic or a method whose contract must not be broken by inheritance.This is one of the most common Java pitfalls. When the superclass constructor calls an overridden method, the subclass version runs — even though the subclass object is not yet fully initialised. The subclass fields have not been set yet, so they hold their default values.
class Parent {
Parent() { show(); } // calls the overridden version during construction
void show() { System.out.println("Parent"); }
}
class Child extends Parent {
int x = 10;
@Override
void show() { System.out.println("Child: x = " + x); }
}
new Child();
// Output: "Child: x = 0" -- x has not been initialised to 10 yetThe rule to follow: never call overridable methods from a constructor.
Frequently Asked Questions
+ operator works with both numbers and Strings is a built-in language feature, not user-defined operator overloading.@Override annotation when overriding so the compiler can verify you are actually overriding and not accidentally creating a new method due to a typo.Wrapping Up
Compile-time and run-time polymorphism are not competing approaches — they solve different problems and you will use both regularly. Method overloading gives you a clean, convenient API where one method name handles multiple input types, with the compiler guaranteeing correctness. Method overriding gives you flexible, extensible architectures where adding new behaviour means creating a new class rather than modifying existing ones.
Understanding dynamic method dispatch at the vtable level — not just the surface behaviour — is what makes debugging polymorphism issues much easier. When you see unexpected method calls in a codebase using inheritance, the question to ask is always: what is the actual object type here, and what does its vtable say? That mental model scales to every OOP design problem you will encounter in Java.
- Abstraction vs Encapsulation in Java — the other two pillars of OOP alongside polymorphism and inheritance
- Java Exceptions: Checked vs Unchecked — essential for writing robust methods
- HashMap vs HashSet in Java
- List vs ArrayList in Java — interface vs implementation in the Collections API
- String vs StringBuilder vs StringBuffer in Java