The short answer

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.

Diagram comparing compile-time polymorphism resolved by the Java compiler using method overloading versus run-time polymorphism resolved by the JVM using method overriding across an inheritance hierarchy.
Comparison of Java compile-time polymorphism (method overloading) and run-time polymorphism (method overriding), showing how the compiler and JVM resolve method calls.

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.

Flowchart showing how the Java JVM performs dynamic method dispatch at runtime by checking the actual object type in heap memory to decide which overridden method to call.
Java dynamic method dispatch process showing how an Animal reference pointing to a Dog object results in the JVM calling the Dog implementation at runtime.
// 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:

FeatureAbstract ClassInterface
Concrete methodsYesYes, via default methods (Java 8+)
ConstructorsYesNo
Instance variablesYesNo — only static final constants
Multiple inheritanceOne class onlyA class can implement many interfaces
Best used whenSubclasses share common state or behaviour alongside required overridesDefining a pure contract with no shared state

Compile-Time vs Run-Time Polymorphism: Key Differences

AspectCompile-Time PolymorphismRun-Time Polymorphism
Also known asStatic polymorphism, Early binding, Static bindingDynamic polymorphism, Late binding, Dynamic binding
Resolved byJava compiler — before the program runsJVM — while the program is executing
MechanismMethod overloading — same name, different parametersMethod overriding — same name, same parameters in subclass
Inheritance required?No — happens within a single classYes — requires extends or implements
Binding typeStatic — method address fixed in bytecode at compile timeDynamic — method address resolved via vtable at runtime
Decision based onMethod signature (parameter types and count)Actual object type in heap memory, not the reference type
@Override annotationNot applicableShould always be used to prevent accidental overloading
PerformanceFaster — zero runtime overhead, already resolvedMarginally slower — vtable lookup at each call
FlexibilityLess flexible — behaviour fixed at compile timeHighly flexible — behaviour adapts to the actual object
Static methodsCan be overloadedCannot be overridden — method hiding occurs instead
Private methodsCan be overloadedCannot be overridden — not inherited by subclasses
Error detectionCompile-time — wrong calls caught before runningRuntime — ClassCastException possible with incorrect downcasting
OOP principle supportedClean API design — one method name for logically related operationsOpen/Closed Principle — extend behaviour without modifying existing code
Primary use caseUtility methods handling different input types — Math operations, buildersFrameworks, plugins, strategy pattern, dependency injection
Java keywords involvedNo special keyword neededextends, implements, @Override, super

Infographic comparing compile-time polymorphism with method overloading and static binding against run-time polymorphism with method overriding and dynamic binding in Java.
Side-by-side comparison of Java compile-time and run-time polymorphism, highlighting binding type, method behavior, inheritance requirements, and execution characteristics.

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 object

Overloading is the same method name with different parameters within the same class, resolved at compile time (static binding), no inheritance required. Overriding is the same method name with the same parameters in a subclass, resolved at runtime (dynamic binding), inheritance required. The simplest way to remember: if the signature differs, it is overloading; if the signature is identical across a parent-child pair, it is overriding.

No. Constructors are not inherited by subclasses, so there is nothing to override. Each class defines its own constructors. Within a single class, constructors can be overloaded (compile-time polymorphism), but they cannot participate in run-time polymorphism through dynamic dispatch.

Since 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
}

Private methods are not inherited — the subclass cannot see them, so it has nothing to override. If a subclass writes a method with the same name, it is simply a new method with no connection to the parent. Final methods are explicitly marked to prevent subclasses from changing their behaviour — the compiler enforces this and rejects any attempt to override. Use 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 yet

The rule to follow: never call overridable methods from a constructor.

Frequently Asked Questions

Compile-time polymorphism in Java is achieved through method overloading — defining multiple methods with the same name but different parameter lists in the same class. The Java compiler determines which method to call at compile time based on the method signature. This is also called static binding or early binding. It is faster than run-time polymorphism because the decision is made before the program runs. Important: Java does not support operator overloading, so compile-time polymorphism in Java means method overloading only.

Run-time polymorphism in Java is achieved through method overriding, where a subclass provides its own implementation of a method defined in its superclass. The JVM determines which version to call at runtime based on the actual object type in heap memory, not the reference variable type. This process is called dynamic method dispatch and uses the object’s vtable. Inheritance via extends or implements is required.

No. Java does not support operator overloading. This was a deliberate design decision to keep the language simpler and more readable than C++. In Java, compile-time polymorphism is achieved only through method overloading. The fact that the + operator works with both numbers and Strings is a built-in language feature, not user-defined operator overloading.

Method overloading happens within the same class — same method name, different parameters, resolved at compile time, no inheritance needed. Method overriding happens between a parent and child class — same method name, same parameters, resolved at runtime, inheritance required. Always add the @Override annotation when overriding so the compiler can verify you are actually overriding and not accidentally creating a new method due to a typo.

Compile-time polymorphism is theoretically faster because the method is bound before the program runs — there is no dispatch overhead at runtime. Run-time polymorphism requires a vtable lookup at each overridden method call. That said, the JVM’s JIT compiler is very effective at optimising virtual method calls, especially for monomorphic call sites where the same object type is always used. In most real applications the difference is negligible. Choose based on what your design requires, not on micro-optimisation concerns.

No — run-time polymorphism requires either class inheritance via extends or interface implementation via implements. Without a parent-child relationship there is no overriding, and without overriding there is no dynamic dispatch. If you want polymorphic behaviour without traditional inheritance, Java 8+ lambda expressions are effectively anonymous implementations of functional interfaces and do give you a form of runtime-selected behaviour, though they rely on interface implementation under the hood.

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.

Related reading on DiffStudy:

Whatsapp-color Created with Sketch.

By Arun Kumar

Full Stack Developer with a BE in Computer Science, working with React, Next.js, Node.js, MongoDB, and AI/ML tools. Founder of DiffStudy — built to help CS students ace GATE and university exams, and keep developers up to date across AI, cloud, system design, web development, and every field of computer science. Every article is written from real hands-on experience, not just theory.

Leave a Reply

Your email address will not be published. Required fields are marked *


You cannot copy content of this page