DEV Community

Mayur Bodhare
Mayur Bodhare

Posted on

Compile-Time vs Run-Time Polymorphism in Java

Two things used to puzzle me about Java. First: I printed an object and got something like Dog@1b6d3586 instead of anything useful. Second: I knew Animal a = new Dog(); was legal, but I had no idea when I was allowed to turn it back into a Dog — and when doing so would crash my program.

Both puzzles live in the same neighborhood: polymorphism, Java's Object class, and casting. This post walks through all of them, one at a time, and connects the dots.

What is polymorphism?

Polymorphism means "one name, many forms." You write code against a general type, and the actual behavior depends on the specific object you're holding at run time.

The classic setup: a parent reference pointing at a child object.

class Animal {
    void sound() {
        System.out.println("Some sound");
    }
}

class Dog extends Animal {
    @Override
    void sound() {
        System.out.println("Bark");
    }
}

class Cat extends Animal {
    @Override
    void sound() {
        System.out.println("Meow");
    }
}

public class Main {
    public static void main(String[] args) {
        Animal a = new Dog();
        a.sound();   // Bark
        Animal b = new Cat();
        b.sound();   // Meow
    }
}
Enter fullscreen mode Exit fullscreen mode

Output:

Bark
Meow
Enter fullscreen mode Exit fullscreen mode

Same line of code, a.sound(), different results. That's the magic — and Java gives you two different ways to achieve it.

Compile-time vs run-time polymorphism

Compile-time: method overloading

Overloading is several methods with the same name but different parameter lists in one class:

class Calculator {
    int add(int a, int b) {
        return a + b;
    }

    double add(double a, double b) {
        return a + b;
    }
}

public class Main {
    public static void main(String[] args) {
        Calculator calc = new Calculator();
        System.out.println(calc.add(2, 3));     // int version
        System.out.println(calc.add(2.5, 3.5)); // double version
    }
}
Enter fullscreen mode Exit fullscreen mode

Output:

5
6.0
Enter fullscreen mode Exit fullscreen mode

The compiler looks at the argument types and picks the matching version before the program runs. Hence "compile-time."

Run-time: method overriding

Overriding is a child class redefining an inherited method with the same name and same parameters — exactly what Dog and Cat did with sound() above. The compiler can't know which object a reference will hold, so the choice is postponed until the program runs and the JVM looks at the real object.

              Overloading                Overriding
 who decides  compiler                  JVM at run time
 relation     same class                parent and child
 parameters   must be DIFFERENT         must be the SAME
 binding      early (compile time)      late (run time)
 keyword      none                      @Override recommended
Enter fullscreen mode Exit fullscreen mode

Memory trick: overloading = "more options loaded into one name." Overriding = "written over the parent's version."

Dynamic method dispatch: how the JVM picks

The mechanism behind run-time polymorphism is called dynamic method dispatch. When you write Animal a = new Dog();, two types are involved:

  • The reference type (Animal) decides what you're allowed to call.
  • The object type (Dog) decides which version runs.

Every object carries a hidden pointer to its class, and each class keeps a method table — a lookup list of its methods. On each call, the JVM follows the pointer to the object's actual class:

   a: Animal reference ----> Dog object in memory
                                   |
                                   v
                            Dog's method table
                            +------------------------+
                            | sound()  -> Dog's code |  <- overridden,
                            | toString() -> inherited |    so Dog's
                            |   from Animal          |    version wins
                            +------------------------+
Enter fullscreen mode Exit fullscreen mode

One trap to remember: only instance methods dispatch. Fields do not — they're chosen by the reference type at compile time. If Animal and Dog both declare a name field, a.name gives you Animal's version even when a holds a Dog. This "field hiding" is a classic bug; the fix is to keep fields private and go through methods, which do dispatch.

The Object class: the ancestor of everything

Here's something Java does silently. Every class you write — every single one — automatically extends Object. These two declarations are identical:

class Dog { }
class Dog extends Object { }   // same thing, written explicitly
Enter fullscreen mode Exit fullscreen mode

That means this is legal:

Object obj = new Dog();    // Object reference, Dog object
Object text = "hello";     // a String IS an Object
Object num = 42;           // autoboxed into Integer, which extends Object
Enter fullscreen mode Exit fullscreen mode

One type can hold anything. That's why Java's collections, before generics, could store any object at all — an ArrayList is really a list of Objects.

Object gives every class a handful of methods. The ones you'll meet daily:

  • toString() — called when you print the object or concatenate it with a string. Default output is the class name plus a hash code: Dog@1b6d3586. Override it to get something readable:
class Dog {
    String name = "Bruno";

    @Override
    public String toString() {
        return "Dog named " + name;
    }
}

public class Main {
    public static void main(String[] args) {
        Object obj = new Dog();
        System.out.println(obj);       // println calls toString()
        System.out.println("Hi " + obj);
    }
}
Enter fullscreen mode Exit fullscreen mode

Output:

Dog named Bruno
Hi Dog named Bruno
Enter fullscreen mode Exit fullscreen mode
  • equals(Object other) — compares this object with another. This is where the == trap comes in: == compares references (do both variables point to the same object?), while equals() is meant to compare content:
public class Main {
    public static void main(String[] args) {
        String a = new String("hi");
        String b = new String("hi");

        System.out.println(a == b);       // false: different objects
        System.out.println(a.equals(b));  // true: same characters
    }
}
Enter fullscreen mode Exit fullscreen mode

Output:

false
true
Enter fullscreen mode Exit fullscreen mode
  • hashCode() — a number derived from the object, used by hash-based collections like HashMap. If you override equals(), override hashCode() too, or those collections misbehave.
  • getClass() — returns the object's actual class. Notice something? It's a final method, so no one can override it to lie about their type. Even polymorphism has rules.

Here's the family picture:

                  Object
                 /   |    \
              String Animal  Integer ...
                     /  \
                   Dog  Cat
Enter fullscreen mode Exit fullscreen mode

Object sits at the root of every class tree. Polymorphism, upcasting, and toString() all build on this single inheritance fact.

Upcasting and downcasting

Casting is how you move along those tree branches. There are only two directions.

Upcasting: child → parent (safe, automatic)

Animal a = new Dog();   // upcasting happens implicitly
a.sound();              // Bark — polymorphism still works
Enter fullscreen mode Exit fullscreen mode

You write nothing extra. A Dog is an Animal, so the assignment is always safe. The price: you temporarily lose access to child-specific members. a.fetch() won't compile, because the compiler only sees Animal.

Upcasting is everywhere once you look: passing a Dog to a method that takes Animal, storing different objects in one Object variable, adding a Dog to a List<Animal>.

Downcasting: parent → child (risky, explicit)

To get the child-specific features back, you cast explicitly:

Animal a = new Dog();
Dog d = (Dog) a;   // explicit downcast
d.fetch();         // Fetching...
Enter fullscreen mode Exit fullscreen mode

The picture:

   Dog ------> Animal      upcast: implicit, always safe
 Animal ------> Dog        downcast: explicit, can fail
Enter fullscreen mode Exit fullscreen mode

So when does it fail? The compiler trusts your cast — it's just a promise. But the JVM checks the actual object at run time:

class Cat extends Animal {
    @Override
    void sound() {
        System.out.println("Meow");
    }
}

public class Main {
    public static void main(String[] args) {
        Animal a = new Cat();
        Dog d = (Dog) a;   // compiles fine...
        d.fetch();         // ...but crashes here
    }
}
Enter fullscreen mode Exit fullscreen mode

Output:

Exception in thread "main" java.lang.ClassCastException: Cat cannot be cast to Dog
Enter fullscreen mode Exit fullscreen mode

A Cat is not a Dog, and no cast can make it one. The fix is to check before you cast, using instanceof:

Animal a = new Cat();

if (a instanceof Dog) {
    Dog d = (Dog) a;
    d.fetch();
} else {
    System.out.println("Not a Dog, skipping fetch");
}
Enter fullscreen mode Exit fullscreen mode

Output:

Not a Dog, skipping fetch
Enter fullscreen mode Exit fullscreen mode

Since Java 16, you can combine the check and the cast in one line — the cast happens automatically if the test passes:

if (a instanceof Dog d) {   // Java 16+
    d.fetch();
}
Enter fullscreen mode Exit fullscreen mode

When do you actually need downcasting? Mostly when code receives a broad type and must recover a specific one. The equals(Object obj) method is the everyday example: it arrives as Object, so implementations check instanceof and cast before comparing fields. And in pre-generics code, everything came out of collections as Object, so casts were constant. Generics have quietly removed most everyday casting — which is a good thing.

The final keyword

final is the brake pedal for all of this. It means "this can't be changed or extended," in three places.

A final variable can't be reassigned:

final int MAX_USERS = 100;
// MAX_USERS = 200;  // ERROR: cannot assign a value to final variable
Enter fullscreen mode Exit fullscreen mode

A final method can't be overridden — this is final directly switching off run-time polymorphism for that one method:

class Config {
    final void loadDefaults() {
        System.out.println("Loading defaults...");
    }
}

class AppConfig extends Config {
    // void loadDefaults() { }  // ERROR: cannot override the final method
}
Enter fullscreen mode Exit fullscreen mode

A final class can't be extended at all. Java's String is final, deliberately — strings are used in security-sensitive places, and arbitrary subclasses could break the JVM's assumptions.

One warning: final is not the same as immutable. It stops the variable from being reassigned, not the object from changing:

import java.util.ArrayList;
import java.util.List;

final List<String> names = new ArrayList<>();
names.add("Asha");                 // fine: the object can change
// names = new ArrayList<>();      // ERROR: the reference cannot change
Enter fullscreen mode Exit fullscreen mode

Which one should I use?

My decision process looks like this:

  • Overloading when one idea takes different inputs — add(int, int) and add(double, double). It's caller convenience.
  • Overriding + parent references when behavior differs by type. This is the real design win, and it's how toString(), equals(), and every framework callback work.
  • Upcasting freely — it's free and safe. Design methods to accept parent types.
  • Downcasting sparingly, and only behind instanceof. If you find yourself casting a lot, that's a design smell; generics or polymorphic methods usually remove the need.
  • Object as a type almost never, in modern code. Prefer generics (List<String> over a raw List of Object). Reach for Object when a method genuinely accepts anything, like println.
  • final on variables by default (unless you must reassign), and on methods or classes only with a concrete reason — over-eager final on classes makes code hard to extend and test.

Common mistakes

  1. Mixing up overloading and overriding. Same name + different parameters = overloading (compile time). Same name + same parameters in parent and child = overriding (run time). Change a parameter "by accident" and you've silently overloaded instead of overridden.
  2. Expecting fields or static methods to dispatch. They follow the reference type, not the object. Only instance methods are polymorphic — field hiding bites beginners constantly.
  3. Blind downcasting. (Dog) a compiles even when a holds a Cat. Always guard with instanceof unless you're 100% sure of the object type.
  4. Comparing objects with ==. It compares references, not content. Use equals() — and remember, the other object can be null, so a.equals(b) can throw; the null-safe habit is "expected".equals(actual) for strings.
  5. Thinking final makes objects immutable. It only prevents reassigning the variable. A final List still grows and shrinks.
  6. Not overriding toString() and being puzzled by Dog@1b6d3586. Any class you print should have a friendly toString(). And if you override equals(), override hashCode() too — HashMap and HashSet depend on them agreeing.

Quick recap

  • Polymorphism = one name, many forms: overloading (compiler picks) and overriding (JVM picks).
  • Dynamic method dispatch = the JVM follows the object to its class's method table. Reference type decides what you can call; object type decides what runs.
  • Every class silently extends Object, gaining toString(), equals(), hashCode(), and more. One Object reference can hold anything.
  • Upcasting (child → parent) is implicit and safe; downcasting (parent → child) is explicit and can throw ClassCastException — always check with instanceof.
  • final locks a variable (no reassignment), a method (no overriding), or a class (no extending). It's the anti-polymorphism keyword — but it doesn't make objects immutable.

Try it yourself

Here's a small practice idea: create the Animal, Dog, and Cat classes, then an array of type Animal[] holding a Dog, a Cat, and another Dog. Loop through it, call sound() on each (watch the dispatch happen), and inside the loop use instanceof to call fetch() only on the dogs.

Then two experiments: remove toString() from Dog and print it; and override equals() without hashCode() and put your objects in a HashSet. Observe what surprises you — those surprises are the lessons.

If this helped, or if I got something wrong, tell me in the comments. Happy coding!

Top comments (0)